├── .babelrc
├── .dockerignore
├── .editorconfig
├── .env.example
├── .eslintrc
├── .github
└── workflows
│ ├── run-tests.yml
│ └── tagbuild.yml
├── .gitignore
├── .nvmrc
├── .prettierrc
├── .sequelizerc
├── API.md
├── CHANGELOG.md
├── CODEOWNERS
├── Dockerfile
├── LICENSE
├── README.md
├── RELEASE.md
├── jest.config.js
├── package-lock.json
├── package.json
├── pathfinder
├── scripts
├── entrypoint.sh
├── run-worker.sh
├── run.sh
└── wait-for-it.sh
├── src
├── constants.js
├── controllers
│ ├── news.js
│ ├── transfers.js
│ ├── uploads.js
│ └── users.js
├── database
│ ├── config.js
│ ├── index.js
│ ├── migrations
│ │ ├── 20190915155608-create-users.js
│ │ ├── 20200121140129-user-add-email-field.js
│ │ ├── 20200902194521-add-avatar-url-to-users.js
│ │ ├── 20200930130328-create-transfers.js
│ │ ├── 20201008130741-create-edges.js
│ │ ├── 20201012092801-create-metrics.js
│ │ ├── 20201021125825-add-index-to-edges.js
│ │ ├── 20230207095851-create-news.js
│ │ ├── 20230713102042-add-index-users.js
│ │ ├── 20230713111651-add-index-safe-address.js
│ │ ├── 20230814085817-add-news-title-field.js
│ │ └── 20240626201929-add-profile-migration-consent.js
│ └── seeders
│ │ └── 20201012093423-create-transfer-metrics.js
├── helpers
│ ├── compare.js
│ ├── env.js
│ ├── errors.js
│ ├── logger.js
│ ├── loop.js
│ ├── reduce.js
│ ├── responses.js
│ ├── signature.js
│ └── validate.js
├── index.js
├── middlewares
│ ├── errors.js
│ ├── images.js
│ └── uploads.js
├── models
│ ├── edges.js
│ ├── metrics.js
│ ├── news.js
│ ├── transfers.js
│ └── users.js
├── routes
│ ├── index.js
│ ├── news.js
│ ├── transfers.js
│ ├── uploads.js
│ └── users.js
├── services
│ ├── aws.js
│ ├── core.js
│ ├── edgesDatabase.js
│ ├── edgesFile.js
│ ├── edgesFromEvents.js
│ ├── edgesFromGraph.js
│ ├── edgesUpdate.js
│ ├── findTransferSteps.js
│ ├── graph.js
│ ├── metrics.js
│ ├── redis.js
│ ├── updateTransferSteps.js
│ ├── web3.js
│ └── web3Ws.js
├── tasks
│ ├── cleanup.js
│ ├── exportEdges.js
│ ├── index.js
│ ├── processor.js
│ ├── submitJob.js
│ ├── syncAddress.js
│ ├── syncFullGraph.js
│ └── uploadEdgesS3.js
├── validations
│ ├── news.js
│ ├── transfers.js
│ ├── uploads.js
│ └── users.js
└── worker.js
└── test
├── .eslintrc
├── api.test.js
├── aws-validation.test.js
├── data
└── graph-safes.json
├── helpers.test.js
├── news-find.test.js
├── transfers-create.test.js
├── transfers-find-steps.test.js
├── transfers-find.test.js
├── transfers-update-steps.test.js
├── transfers-validation.test.js
├── users-create-update.test.js
├── users-delete.test.js
├── users-find.test.js
├── users-get-email.test.js
├── users-profile-migration-consent.test.js
├── users-safe-verification.test.js
├── users-validation.test.js
└── utils
├── common.js
├── mocks.js
├── transfers.js
├── users.js
└── web3.js
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": [
3 | "@babel/preset-env"
4 | ],
5 | "plugins": [
6 | "add-module-exports",
7 | "@babel/plugin-transform-runtime",
8 | "@babel/proposal-class-properties",
9 | "@babel/proposal-object-rest-spread"
10 | ]
11 | }
12 |
--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------
1 | build
2 | node_modules
3 | test
4 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*]
4 | charset = utf-8
5 | indent_size = 2
6 | indent_style = space
7 | trim_trailing_whitespace = true
8 | insert_final_newline = true
9 |
--------------------------------------------------------------------------------
/.env.example:
--------------------------------------------------------------------------------
1 | PORT=3000
2 |
3 | # Core
4 | HUB_ADDRESS=0xCfEB869F69431e42cdB54A4F4f105C19C080A601
5 |
6 | # Gnosis Safe Smart Contract addresses - 1.3.0 version
7 | PROXY_FACTORY_ADDRESS=0x9b1f7F645351AF3631a656421eD2e40f2802E6c0
8 | SAFE_ADDRESS=0x2612Af3A521c2df9EAF28422Ca335b04AdF3ac66
9 | SAFE_DEFAULT_CALLBACK_HANDLER=0x67B5656d60a809915323Bf2C40A8bEF15A152e3e
10 |
11 | TX_SENDER_ADDRESS=0xA6452911d5274e2717BC1Fa793b21aB600EC9133
12 |
13 | # Circles Services
14 | API_SERVICE_ENDPOINT=http://api.circles.local
15 | PATHFINDER_SERVICE_ENDPOINT=http://localhost:8081
16 | RELAY_SERVICE_ENDPOINT=http://relay.circles.local
17 |
18 | # Ethereum RPC Node
19 | ETHEREUM_NODE_ENDPOINT=http://localhost:8545
20 | ETHEREUM_NODE_WS=ws://localhost:8545
21 |
22 | # Graph Node
23 | GRAPH_NODE_ENDPOINT=http://graph.circles.local
24 | GRAPH_NODE_INDEXING_STATUS_ENDPOINT=http://graph.circles.local:8030
25 | SUBGRAPH_NAME=circlesubi/circles-subgraph
26 |
27 | # Database
28 | DATABASE_URL=postgres://postgres:postgres@localhost:5432/api
29 |
30 | DATABASE_SOURCE=graph
31 |
32 | # Redis
33 | REDIS_URL=redis://localhost:6379
34 |
35 | # AWS S3
36 | AWS_REGION=eu-central-1
37 | AWS_S3_BUCKET=circles-garden-profile-pictures
38 | AWS_S3_BUCKET_TRUST_NETWORK=circles-ubi-network-development
39 | AWS_ACCESS_KEY_ID=
40 | AWS_SECRET_ACCESS_KEY=
41 |
--------------------------------------------------------------------------------
/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "env": {
3 | "es6": true,
4 | "node": true
5 | },
6 | "extends": [
7 | "eslint:recommended",
8 | "plugin:prettier/recommended"
9 | ],
10 | "parser": "@babel/eslint-parser",
11 | "plugins": [
12 | "prettier"
13 | ],
14 | "rules": {
15 | "no-console": "error"
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/.github/workflows/run-tests.yml:
--------------------------------------------------------------------------------
1 | name: run-tests
2 | on: [push]
3 | jobs:
4 | run-api-tests:
5 | runs-on: ubuntu-latest
6 | services:
7 | redis:
8 | image: redis:6-alpine
9 | ports:
10 | - 6379:6379
11 | postgres:
12 | image: postgres:12-alpine
13 | env:
14 | POSTGRES_PASSWORD: postgres
15 | POSTGRES_USER: postgres
16 | POSTGRES_DB: api
17 | ports:
18 | - 5432:5432
19 | # needed because the postgres container does not provide a healthcheck
20 | options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
21 |
22 | steps:
23 | - name: Check out repository code
24 | uses: actions/checkout@v4
25 |
26 | - name: Setup node
27 | uses: actions/setup-node@v4
28 | with:
29 | node-version-file: '.nvmrc'
30 | cache: 'npm'
31 |
32 | - name: Install dependencies
33 | run: npm ci
34 | - name: Setup env file
35 | run: cp .env.example .env
36 |
37 | - run: npm run db:migrate
38 | - run: npm run db:seed
39 | - run: npm run test
40 |
--------------------------------------------------------------------------------
/.github/workflows/tagbuild.yml:
--------------------------------------------------------------------------------
1 | name: Build docker and upload to registry
2 |
3 | on:
4 | push:
5 | tags:
6 | # strict semver regex
7 | - v[0-9]+.[0-9]+.[0-9]+*
8 |
9 | jobs:
10 | build-and-push:
11 | name: build-and-push
12 | runs-on: ubuntu-22.04
13 | steps:
14 | - name: Install dependencies
15 | run: |
16 | sudo snap install doctl
17 | sudo snap connect doctl:dot-docker
18 |
19 | - name: Configure auth for Digital Ocean
20 | run: |
21 | doctl auth init -t ${{ secrets.CIRCLES_CI_DO_TOKEN }}
22 |
23 | - name: Login to DockerHub
24 | uses: docker/login-action@v2
25 | with:
26 | username: ${{ secrets.DOCKERHUB_USERNAME }}
27 | password: ${{ secrets.DOCKERHUB_PASSWORD }}
28 |
29 | - name: Checkout
30 | uses: actions/checkout@v4
31 |
32 | - name: Get ref
33 | id: parse_ref
34 | run: |
35 | echo "tag=${GITHUB_REF/refs\/tags\//}" >> $GITHUB_OUTPUT
36 |
37 | - name: Digital Ocean Registry login
38 | run: |
39 | doctl registry login
40 |
41 | - name: Build and push
42 | uses: docker/build-push-action@v3
43 | with:
44 | context: .
45 | push: true
46 | tags: |
47 | registry.digitalocean.com/circles-registry/circles-api:${{ steps.parse_ref.outputs.tag }}
48 | joincircles/circles-api:latest
49 | joincircles/circles-api:${{ steps.parse_ref.outputs.tag }}
50 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .env
2 | build
3 | edges-data
4 | node_modules
5 |
--------------------------------------------------------------------------------
/.nvmrc:
--------------------------------------------------------------------------------
1 | 20
2 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "semi": true,
3 | "singleQuote": true,
4 | "trailingComma": "all"
5 | }
6 |
--------------------------------------------------------------------------------
/.sequelizerc:
--------------------------------------------------------------------------------
1 | var path = require('path');
2 |
3 | module.exports = {
4 | 'config': path.resolve('src', 'database', 'config.js'),
5 | 'migrations-path': path.resolve('src', 'database', 'migrations'),
6 | 'seeders-path': path.resolve('src', 'database', 'seeders'),
7 | };
8 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | All notable changes to this project will be documented in this file.
4 |
5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7 |
8 | ## [Unreleased]
9 |
10 | ## [1.9.4] - 2023-08-15
11 |
12 | ### Fixed
13 |
14 | - Fix missing field in news table [#202](https://github.com/CirclesUBI/circles-api/pull/202)
15 |
16 | ## [1.9.3] - 2023-08-10
17 |
18 | ### Fixed
19 |
20 | - Fix upload picture due to aws/sdk deprecated [#200](https://github.com/CirclesUBI/circles-api/pull/200)
21 |
22 | ### Changed
23 |
24 | - Bump word-wrap from 1.2.3 to 1.2.4 [#196](https://github.com/CirclesUBI/circles-api/pull/196) from CirclesUBI/dependabot/npm_and_yarn/word-wrap-1.2.4
25 |
26 | ## [1.9.2] - 2023-07-10
27 |
28 | ### Added
29 |
30 | - Add index for username and safe address in users table [#19](https://github.com/CirclesUBI/circles-api/pull/195)
31 |
32 | ## [1.9.1] - 2023-07-06
33 |
34 | ### Changed
35 |
36 | - Use `http` for the blockchain connection when `ws` not necessary [#186](https://github.com/CirclesUBI/circles-api/pull/186)
37 | - Use `node-version-file` option in `actions/setup-node@v3` [#190](https://github.com/CirclesUBI/circles-api/pull/190)
38 | - Update dependencies: Bump `fast-xml-parser` and `@aws-sdk/client-s3` [#191](https://github.com/CirclesUBI/circles-api/pull/191)
39 |
40 | ### Fixed
41 |
42 | - Fix node version in Dockerfile [#189](https://github.com/CirclesUBI/circles-api/pull/189)
43 |
44 | ## [1.9.0] - 2023-06-14
45 |
46 | ### Added
47 |
48 | - New API endpoint: search news database [#164](https://github.com/CirclesUBI/circles-api/pull/164)
49 |
50 | ### Changed
51 |
52 | - Update dependencies [#162](https://github.com/CirclesUBI/circles-api/pull/162) [#178](https://github.com/CirclesUBI/circles-api/pull/178) [#179](https://github.com/CirclesUBI/circles-api/pull/179) [#180](https://github.com/CirclesUBI/circles-api/pull/180) [#181](https://github.com/CirclesUBI/circles-api/pull/181) [#183](https://github.com/CirclesUBI/circles-api/pull/183)
53 |
54 | - Update to Node 16 [#178](https://github.com/CirclesUBI/circles-api/pull/178)
55 |
56 | ## [1.8.1] - 2023-01-12
57 |
58 | ### Changed
59 |
60 | - Reduce `HOPS_DEFAULT` to 3 for the pathfinder, convert all hops to string and update api documentation [#156](https://github.com/CirclesUBI/circles-api/pull/156) [#157](https://github.com/CirclesUBI/circles-api/pull/157)
61 |
62 | ## [1.8.0] - 2023-01-11
63 |
64 | ### Changed
65 |
66 | - Update dependencies [#144](https://github.com/CirclesUBI/circles-api/pull/144), [#155](https://github.com/CirclesUBI/circles-api/pull/155)
67 | - Integrate the [`pathfinder2`](https://github.com/chriseth/pathfinder2) using [`@circles/transfer@3.0.0`](https://github.com/CirclesUBI/circles-transfer/releases/tag/v3.0.0) library [#149](https://github.com/CirclesUBI/circles-api/pull/149), [#154](https://github.com/CirclesUBI/circles-api/pull/154)
68 | - Update tag of the docker image [#149](https://github.com/CirclesUBI/circles-api/pull/149)
69 |
70 | ### Added
71 |
72 | - Add `hops` as optional parameter in the endpoints `POST /api/transfers` and `POST /api/transfers/update` [#149](https://github.com/CirclesUBI/circles-api/pull/149)
73 |
74 | ## [1.7.2] - 2022-12-02
75 |
76 | ### Changed
77 |
78 | - Update circles-core and use the GnosisSafeL2 [#143](https://github.com/CirclesUBI/circles-api/pull/143)
79 |
80 | ## [1.7.1] - 2022-11-25
81 |
82 | ### Fixed
83 |
84 | - Increase timeout for finding and updating steps [#139](https://github.com/CirclesUBI/circles-api/pull/139)
85 |
86 | ### Changed
87 |
88 | - Update api with safe-contract 1.3.0 [#139](https://github.com/CirclesUBI/circles-api/pull/139)
89 | - Bump moment-timezone from 0.5.34 to 0.5.39 [#140](https://github.com/CirclesUBI/circles-api/pull/140)
90 | - Bump apollo-server-core from 3.10.0 to 3.11.1 [#141](https://github.com/CirclesUBI/circles-api/pull/141)
91 | - Update GH action dependencies [#142](https://github.com/CirclesUBI/circles-api/pull/142)
92 |
93 | ## [1.7.0] - 2022-08-23
94 |
95 | ### Added
96 |
97 | - Update user profile endpoint [#38](https://github.com/CirclesUBI/circles-api/pull/38), [#128](https://github.com/CirclesUBI/circles-api/pull/128)
98 | - Add get user email endpoint: [#128](https://github.com/CirclesUBI/circles-api/pull/128)
99 |
100 | ### Changed
101 |
102 | - Update dependencies [#120](https://github.com/CirclesUBI/circles-api/pull/120), [#121](https://github.com/CirclesUBI/circles-api/pull/121), [#126](https://github.com/CirclesUBI/circles-api/pull/126)
103 | - Update pathfinder to latest commit [chriseth/pathfinder@41f5eda](https://github.com/chriseth/pathfinder/commit/41f5eda7941e35dc67ebdb04a842eb7d65c810ef)
104 | - Change format of edges file from json to csv [#123](https://github.com/CirclesUBI/circles-api/pull/123)
105 | - Update circles-transfer dependency to be compatible with the new pathfinder version [#123](https://github.com/CirclesUBI/circles-api/pull/123)
106 |
107 | ## [1.6.0] - 2022-06-06
108 |
109 | ### Removed
110 |
111 | - Remove Check transfer steps functionality from the findTransferSteps service [#119](https://github.com/CirclesUBI/circles-api/pull/119)
112 |
113 | ### Added
114 |
115 | - Create a new route to update transitive transfer path [#119](https://github.com/CirclesUBI/circles-api/pull/119)
116 |
117 | ## [1.5.0] - 2022-05-13
118 |
119 | ### Added
120 |
121 | - Check transfer steps before returning them [#103](https://github.com/CirclesUBI/circles-api/pull/103)
122 |
123 | ## [1.4.0] - 2022-03-29
124 |
125 | ### Added
126 |
127 | - Update web3 method to subscribe to eth events [#113](https://github.com/CirclesUBI/circles-api/pull/113)
128 | - Add log with graph endpoint [#114](https://github.com/CirclesUBI/circles-api/pull/114)
129 |
130 | ## [1.3.22] - 2021-12-17
131 |
132 | ### Changed
133 |
134 | - Change graph health query [#101](https://github.com/CirclesUBI/circles-api/pull/101)
135 |
136 | ## [1.3.21] - 2021-12-16
137 |
138 | ### Fixed
139 |
140 | - Update queries and endpoints for graph-node v0.25.0 [#96](https://github.com/CirclesUBI/circles-api/pull/96)
141 |
142 | ## [1.3.20] - 2021-10-28
143 |
144 | ### Removed
145 |
146 | - Do not run syncFullGraph task
147 | - Do not destroy the edge in the db when there is an error updating the edge
148 | - Delete unused file: edgesGraph.js
149 |
150 | ## [1.3.16] - 2021-10-05
151 |
152 | ### Added
153 |
154 | - Debug graph errors [#95](https://github.com/CirclesUBI/circles-api/pull/95)
155 |
156 | ## [1.3.15] - 2021-10-04
157 |
158 | ### Fixed
159 |
160 | - Use last id instead of skip for pagination in graph queries with more than 5000 items in result [#94](https://github.com/CirclesUBI/circles-api/pull/94)
161 |
162 | ## [1.3.14] - 2021-10-01
163 |
164 | ### Changed
165 |
166 | - Migrate ubuntu-16.04 workflows to ubuntu-18.04 [#93](https://github.com/CirclesUBI/circles-api/pull/93)
167 |
168 | ## [1.3.13] - 2021-10-01
169 |
170 | ### Added
171 |
172 | - Run a task to sync the full graph every week [#88](https://github.com/CirclesUBI/circles-api/pull/88)
173 | - Create RELEASE.md
174 |
175 | ## [1.3.12] - 2021-06-22
176 |
177 | ### Changed
178 |
179 | - Improve search for usernames [#86](https://github.com/CirclesUBI/circles-api/pull/86)
180 | - Introduce case insensitivity for usernames [#85](https://github.com/CirclesUBI/circles-api/pull/85)
181 |
182 | ## [1.3.11] - 2021-05-03
183 |
184 | ### Fixed
185 |
186 | - Negative buffer fix [#76](https://github.com/CirclesUBI/circles-api/pull/76)
187 |
188 | ## [1.3.8] - 2021-04-28
189 |
190 | ### Fixed
191 |
192 | - Add negative buffer to trust edges [#74](https://github.com/CirclesUBI/circles-api/pull/74)
193 |
194 | ## [1.3.7] - 2021-04-21
195 |
196 | ### Fixed
197 |
198 | - Docker: Do not wait for graph in entrypoint but in program [#71](https://github.com/CirclesUBI/circles-api/pull/71)
199 |
200 | ## [1.3.6] - 2021-04-21
201 |
202 | ### Changed
203 |
204 | - Increase transfer step process timeout [cda7a01](https://github.com/CirclesUBI/circles-api/commit/cda7a0101271cf9f8f351fdf69e66dc2f552f96c)
205 |
206 | ## [1.3.5] - 2021-04-21
207 |
208 | ### Changed
209 |
210 | - Docker: Use bash version of wait-for-it script [745cf5b](https://github.com/CirclesUBI/circles-api/commit/745cf5ba2e404f2a2d5b2b5432ddc09b9cbc1e80)
211 |
212 | ## [1.3.4] - 2021-03-29
213 |
214 | ### Fixed
215 |
216 | - Docker: Remove multi-step build as it breaks `sharp` [99db714](https://github.com/CirclesUBI/circles-api/commit/99db7148924c2e536ba429b9815a9196d72078af)
217 |
218 | ## [1.3.3] - 2021-03-29
219 |
220 | ### Fixed
221 |
222 | - Docker: Start worker process when graph node is available [#63](https://github.com/CirclesUBI/circles-api/pull/63)
223 |
224 | ## [1.3.2] - 2021-03-12
225 |
226 | ### Added
227 |
228 | - Docker builds are now built and uploaded to our new DockerHub page: https://hub.docker.com/r/joincircles/circles-api
229 |
230 | ### Changed
231 |
232 | - Update dependencies [70c0b12](https://github.com/CirclesUBI/circles-api/commit/70c0b120536006610d76a293bad851563ff375cb)
233 | - Improve docker build time [b4f17c0](https://github.com/CirclesUBI/circles-api/commit/b4f17c0e78075475c81eb4f3f9bcaf8f6d845b7b)
234 |
235 | ## [1.3.1] - 2021-03-12
236 |
237 | ### Changed
238 |
239 | - Worker: Changed paths of `edges.json` file and `pathfinder` executable. [#58](https://github.com/CirclesUBI/circles-api/pull/58)
240 |
241 | ## [1.3.0] - 2021-03-11
242 |
243 | ### Added
244 |
245 | - Worker: Introduce new indexing task using `bull` scheduler. [#24](https://github.com/CirclesUBI/circles-api/pull/24)
246 |
--------------------------------------------------------------------------------
/CODEOWNERS:
--------------------------------------------------------------------------------
1 | # Lines starting with '#' are comments.
2 | # Each line is a file pattern followed by one or more owners.
3 |
4 | # These owners will be the default owners for everything in the repo.
5 | * @llunaCreixent
6 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:16-bullseye-slim
2 |
3 | WORKDIR /usr/src/app
4 |
5 | # Install dependencies
6 | RUN apt-get update \
7 | && apt-get install -y git python build-essential
8 |
9 | # Use changes to package.json to force Docker not to use the cache when we
10 | # change our application's NodeJS dependencies:
11 | COPY package*.json /tmp/
12 | RUN cd /tmp && npm install
13 | RUN mkdir -p /usr/src/app && cp -a /tmp/node_modules /usr/src/app
14 |
15 | # From here we load our application's code in, therefore the previous docker
16 | # "layer" thats been cached will be used if possible
17 | WORKDIR /usr/src/app
18 | COPY . .
19 |
20 | # Build project
21 | RUN npm run build
22 |
23 | # Delete development dependencies required to build app
24 | RUN npm prune --production
25 |
26 | # Remove unneeded dependencies
27 | RUN apt-get purge -y --auto-remove build-essential
28 |
29 | # Copy runtime scripts into root
30 | COPY scripts/*.sh ./
31 |
32 | EXPOSE 3000
33 |
34 | ENTRYPOINT ["./entrypoint.sh"]
35 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |

3 |
4 |
5 | circles-api
6 |
7 |
8 |
9 | Offchain API service for Circles
10 |
11 |
12 |
13 |
14 |
15 |
33 |
34 |
53 |
54 |
55 |
56 | An offchain API service to safely store and resolve [`Circles`] user data from public adresses and find transitive transfer paths to send tokens within the trust graph.
57 |
58 | [`circles`]: https://joincircles.net
59 |
60 | ## Features
61 |
62 | - Create and search off-chain data like transfer descriptions and user profiles
63 | - Indexes and stores Circles trust network
64 | - Calculate transitive transfer steps to send Circles
65 |
66 | ## Requirements
67 |
68 | - NodeJS environment (tested with v14)
69 | - PostgreSQL database
70 | - Redis
71 |
72 | ## Usage
73 |
74 | Check out the [`Dockerfile`] for running the `circles-api` on your server.
75 |
76 | [`Dockerfile`]: Dockerfile
77 |
78 | ## Development
79 |
80 | ```bash
81 | # Install dependencies
82 | npm install
83 |
84 | # Copy .env file for local development
85 | cp .env.example .env
86 |
87 | # Seed and migrate database
88 | npm run db:migrate
89 | npm run db:seed
90 |
91 | # Run tests
92 | npm run test
93 | npm run test:watch
94 |
95 | # Check code formatting
96 | npm run lint
97 |
98 | # Start local server and watch changes
99 | npm run watch:all
100 |
101 | # Build for production
102 | npm run build
103 |
104 | # Run production server
105 | npm start
106 | npm worker:start
107 | ```
108 |
109 | ## Pathfinder
110 |
111 | `pathfinder` is a rust program by [chriseth](https://github.com/chriseth/pathfinder2) compiled for Linux arm64 in this repository. To update the pathfinder in the api, build a native binary according to the README instructions from `chriseth` and move the target into your project.
112 |
113 | The version we are using corresponds with this commit: https://github.com/chriseth/pathfinder2/commit/641eb7a31e8a4f3418d9b59eb97e5307a265e195
114 |
115 | ## License
116 |
117 | GNU Affero General Public License v3.0 [`AGPL-3.0`]
118 |
119 | [`AGPL-3.0`]: LICENSE
120 |
--------------------------------------------------------------------------------
/RELEASE.md:
--------------------------------------------------------------------------------
1 | # Releasing `circles-api`
2 |
3 | Use this checklist to create a new release of `circles-api` and distribute the Docker image to our private DigitalOcean registry and [DockerHub](https://hub.docker.com/u/joincircles). All steps are intended to be run from the root directory of the repository.
4 |
5 | ## Creating a new release
6 |
7 | 1. Make sure you are currently on the `main` branch, otherwise run `git checkout main`.
8 | 2. `git pull` to make sure you haven’t missed any last-minute commits. After this point, nothing else is making it into this version.
9 | 3. `npm test` to ensure that all tests pass locally.
10 | 4. `git push` and verify all tests pass on all CI services.
11 | 5. Read the git history since the last release, for example via `git --no-pager log --oneline --no-decorate v1.3.12^..origin/main` (replace `v1.3.12` with the last published version).
12 | 6. Condense the list of changes into something user-readable and write it into the `CHANGELOG.md` file with the release date and version, following the specification here on [how to write a changelog](https://keepachangelog.com/en/1.0.0/). Make sure you add references to the regarding PRs and issues.
13 | 7. Commit the `CHANGELOG.md` changes you've just made.
14 | 8. Create a git and npm tag based on [semantic versioning](https://semver.org/) using `npm version [major | minor | patch]`.
15 | 9. `git push origin main --tags` to push the tag to GitHub.
16 | 10. `git push origin main` to push the automatic `package.json` change after creating the tag.
17 | 11. [Create](https://github.com/CirclesUBI/circles-api/releases/new) a new release on GitHub, select the tag you've just pushed under *"Tag version"* and use the same for the *"Release title"*. For *"Describe this release"* copy the same information you've entered in `CHANGELOG.md` for this release. See examples [here](https://github.com/CirclesUBI/circles-api/releases).
18 |
19 | ## Building and uploading Docker image to registry
20 |
21 | All tagged GitHub commits should be uploaded to our private DigitalOcean registry and the public DockerHub registry automatically by the [tagbuild.yaml](https://github.com/CirclesUBI/circles-api/blob/main/.github/workflows/tagbuild.yml) GitHub Action.
22 |
23 | After the action was completed successfully you can now use the uploaded Docker image to deploy it.
24 |
25 | ## Deploy release
26 |
27 | ### `circles-docker`
28 |
29 | For local development we use the [circles-docker](https://github.com/CirclesUBI/circles-docker) repository. To use the new version of `circles-api` please update the following configuration [here](https://github.com/CirclesUBI/circles-docker/blob/main/docker-compose.api-pull.yml) and commit the update to the `circles-docker` repository.
30 |
31 | Rebuild your environment via `make build` to use the updated version in your local development setup. Consult the `README.md` in the repository to read more about this.
32 |
33 | ### `circles-iac`
34 |
35 | The official `staging` and `production` servers of Circles are maintained via the [circles-iac](https://github.com/CirclesUBI/circles-iac) repository. Both environments have separate version configurations. You will need to change the version for [staging](https://github.com/CirclesUBI/circles-iac/blob/main/helm/circles-infra-suite/values-staging.yaml) and [production](https://github.com/CirclesUBI/circles-iac/blob/main/helm/circles-infra-suite/values-production.yaml) in the regarding `imageTag` fields. Commit the change to the `circles-iac` repository.
36 |
37 | Deploy the release via `helm` to the regarding Kubernetes cluster on DigitalOcean. Consult the `README.md` in the repository to read more about how deploy on Kubernetes.
38 |
--------------------------------------------------------------------------------
/jest.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | testEnvironment: 'node',
3 | testTimeout: 200000,
4 |
5 | // Resolve own modules with alias
6 | moduleNameMapper: {
7 | '^~(.*)$': '/src$1',
8 | },
9 | };
10 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "circles-api",
3 | "version": "1.12.0",
4 | "description": "Offchain database and API service for Circles",
5 | "main": "src/index.js",
6 | "private": true,
7 | "contributors": [
8 | "adzialocha",
9 | "llunaCreixent",
10 | "ana0",
11 | "louilinn",
12 | "JacqueGM"
13 | ],
14 | "license": "AGPL-3.0",
15 | "repository": {
16 | "type": "git",
17 | "url": "git://github.com/CirclesUBI/circles-api.git"
18 | },
19 | "engines": {
20 | "node": ">=20.0.0"
21 | },
22 | "scripts": {
23 | "build": "babel ./src --out-dir ./build",
24 | "clear": "rimraf ./build",
25 | "db:migrate": "sequelize db:migrate",
26 | "db:seed": "sequelize db:seed:all",
27 | "lint": "eslint --ignore-path .gitignore .",
28 | "serve": "cross-env NODE_ENV=development babel-node ./src/index.js --source-maps inline",
29 | "serve:all": "concurrently --prefix '{pid}-{name}' --names 'API,WORKERS' --kill-others 'npm run serve' 'npm run worker:serve'",
30 | "start": "cross-env NODE_ENV=production node ./build/index.js",
31 | "test": "jest --forceExit --detectOpenHandles",
32 | "test:watch": "npm run test -- --watch",
33 | "watch": "nodemon --watch ./src --exec npm run serve",
34 | "watch:all": "concurrently --prefix '{pid}-{name}' --names 'API,WORKERS' --kill-others 'npm run watch' 'npm run worker:watch'",
35 | "worker:serve": "NODE_ENV=development babel-node -r dotenv/config ./src/worker.js --source-maps inline",
36 | "worker:start": "cross-env NODE_ENV=production node ./build/worker.js",
37 | "worker:watch": "nodemon --watch ./src --exec npm run worker:serve"
38 | },
39 | "devDependencies": {
40 | "@babel/cli": "^7.24.8",
41 | "@babel/core": "^7.25.2",
42 | "@babel/eslint-parser": "^7.25.1",
43 | "@babel/node": "^7.25.0",
44 | "@babel/plugin-proposal-class-properties": "^7.18.6",
45 | "@babel/plugin-proposal-object-rest-spread": "^7.20.7",
46 | "@babel/plugin-transform-runtime": "^7.24.7",
47 | "@babel/preset-env": "^7.25.4",
48 | "babel-plugin-add-module-exports": "^1.0.4",
49 | "concurrently": "^8.2.2",
50 | "eslint": "^8.42.0",
51 | "eslint-config-prettier": "^8.8.0",
52 | "eslint-plugin-prettier": "^4.2.1",
53 | "ethereumjs-abi": "^0.6.8",
54 | "jest": "^27.5.1",
55 | "nock": "^13.5.5",
56 | "nodemon": "^2.0.22",
57 | "prettier": "^2.8.8",
58 | "rimraf": "^5.0.10",
59 | "supertest": "^6.3.4",
60 | "truffle": "^5.9.4"
61 | },
62 | "dependencies": {
63 | "@aws-sdk/client-s3": "^3.663.0",
64 | "@babel/runtime": "^7.25.4",
65 | "@circles/core": "^4.5.0",
66 | "@circles/transfer": "^3.1.0",
67 | "body-parser": "^1.20.2",
68 | "bull": "^4.16.0",
69 | "celebrate": "^15.0.3",
70 | "compression": "^1.7.4",
71 | "cors": "^2.8.5",
72 | "cross-env": "^7.0.3",
73 | "dotenv": "^16.4.5",
74 | "express": "^4.21.0",
75 | "fast-json-stringify": "^5.7.0",
76 | "helmet": "^7.1.0",
77 | "http-status": "^1.7.4",
78 | "isomorphic-fetch": "^3.0.0",
79 | "method-override": "^3.0.0",
80 | "mime": "^3.0.0",
81 | "morgan": "^1.10.0",
82 | "multer": "^1.4.4",
83 | "pg": "^8.12.0",
84 | "pg-copy-streams": "^6.0.6",
85 | "sequelize": "^6.37.3",
86 | "sequelize-cli": "^6.6.2",
87 | "sharp": "^0.32.6",
88 | "web3": "^1.10.4",
89 | "winston": "^3.14.2"
90 | }
91 | }
92 |
--------------------------------------------------------------------------------
/pathfinder:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CirclesUBI/circles-api/1e8b783bb41e526838724f717b8f673917cbc843/pathfinder
--------------------------------------------------------------------------------
/scripts/entrypoint.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | set -e
4 |
5 | CMD="$@"
6 |
7 | # Set default values
8 | POSTGRES_PORT=${POSTGRES_PORT:-5432}
9 | NODE_ENV=${NODE_ENV:-"production"}
10 |
11 | # Wait until database is ready
12 | ./wait-for-it.sh "$POSTGRES_HOST:$POSTGRES_PORT" -t 60
13 |
14 | # Set DATABASE_URL env variable in correct format for application
15 | export DATABASE_URL=postgres://$POSTGRES_USER:$POSTGRES_PASSWORD@$POSTGRES_HOST:$POSTGRES_PORT/$POSTGRES_DATABASE_API
16 |
17 | # Run migrations
18 | npm run db:migrate
19 | npm run db:seed
20 |
21 | # Finally execute start command
22 | exec $CMD
23 |
--------------------------------------------------------------------------------
/scripts/run-worker.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | # Fix "JavaScript heap out of memory" error, see:
4 | # https://stackoverflow.com/questions/53230823/fatal-error-ineffective-mark-compacts-near-heap-limit-allocation-failed-javas
5 | export NODE_OPTIONS="--max-old-space-size=4096"
6 |
7 | node ./build/worker.js
8 |
--------------------------------------------------------------------------------
/scripts/run.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | node ./build/index.js
4 |
--------------------------------------------------------------------------------
/scripts/wait-for-it.sh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 | # Use this script to test if a given TCP host/port are available
3 |
4 | WAITFORIT_cmdname=${0##*/}
5 |
6 | echoerr() { if [[ $WAITFORIT_QUIET -ne 1 ]]; then echo "$@" 1>&2; fi }
7 |
8 | usage()
9 | {
10 | cat << USAGE >&2
11 | Usage:
12 | $WAITFORIT_cmdname host:port [-s] [-t timeout] [-- command args]
13 | -h HOST | --host=HOST Host or IP under test
14 | -p PORT | --port=PORT TCP port under test
15 | Alternatively, you specify the host and port as host:port
16 | -s | --strict Only execute subcommand if the test succeeds
17 | -q | --quiet Don't output any status messages
18 | -t TIMEOUT | --timeout=TIMEOUT
19 | Timeout in seconds, zero for no timeout
20 | -- COMMAND ARGS Execute command with args after the test finishes
21 | USAGE
22 | exit 1
23 | }
24 |
25 | wait_for()
26 | {
27 | if [[ $WAITFORIT_TIMEOUT -gt 0 ]]; then
28 | echoerr "$WAITFORIT_cmdname: waiting $WAITFORIT_TIMEOUT seconds for $WAITFORIT_HOST:$WAITFORIT_PORT"
29 | else
30 | echoerr "$WAITFORIT_cmdname: waiting for $WAITFORIT_HOST:$WAITFORIT_PORT without a timeout"
31 | fi
32 | WAITFORIT_start_ts=$(date +%s)
33 | while :
34 | do
35 | if [[ $WAITFORIT_ISBUSY -eq 1 ]]; then
36 | nc -z $WAITFORIT_HOST $WAITFORIT_PORT
37 | WAITFORIT_result=$?
38 | else
39 | (echo -n > /dev/tcp/$WAITFORIT_HOST/$WAITFORIT_PORT) >/dev/null 2>&1
40 | WAITFORIT_result=$?
41 | fi
42 | if [[ $WAITFORIT_result -eq 0 ]]; then
43 | WAITFORIT_end_ts=$(date +%s)
44 | echoerr "$WAITFORIT_cmdname: $WAITFORIT_HOST:$WAITFORIT_PORT is available after $((WAITFORIT_end_ts - WAITFORIT_start_ts)) seconds"
45 | break
46 | fi
47 | sleep 1
48 | done
49 | return $WAITFORIT_result
50 | }
51 |
52 | wait_for_wrapper()
53 | {
54 | # In order to support SIGINT during timeout: http://unix.stackexchange.com/a/57692
55 | if [[ $WAITFORIT_QUIET -eq 1 ]]; then
56 | timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --quiet --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT &
57 | else
58 | timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT &
59 | fi
60 | WAITFORIT_PID=$!
61 | trap "kill -INT -$WAITFORIT_PID" INT
62 | wait $WAITFORIT_PID
63 | WAITFORIT_RESULT=$?
64 | if [[ $WAITFORIT_RESULT -ne 0 ]]; then
65 | echoerr "$WAITFORIT_cmdname: timeout occurred after waiting $WAITFORIT_TIMEOUT seconds for $WAITFORIT_HOST:$WAITFORIT_PORT"
66 | fi
67 | return $WAITFORIT_RESULT
68 | }
69 |
70 | # process arguments
71 | while [[ $# -gt 0 ]]
72 | do
73 | case "$1" in
74 | *:* )
75 | WAITFORIT_hostport=(${1//:/ })
76 | WAITFORIT_HOST=${WAITFORIT_hostport[0]}
77 | WAITFORIT_PORT=${WAITFORIT_hostport[1]}
78 | shift 1
79 | ;;
80 | --child)
81 | WAITFORIT_CHILD=1
82 | shift 1
83 | ;;
84 | -q | --quiet)
85 | WAITFORIT_QUIET=1
86 | shift 1
87 | ;;
88 | -s | --strict)
89 | WAITFORIT_STRICT=1
90 | shift 1
91 | ;;
92 | -h)
93 | WAITFORIT_HOST="$2"
94 | if [[ $WAITFORIT_HOST == "" ]]; then break; fi
95 | shift 2
96 | ;;
97 | --host=*)
98 | WAITFORIT_HOST="${1#*=}"
99 | shift 1
100 | ;;
101 | -p)
102 | WAITFORIT_PORT="$2"
103 | if [[ $WAITFORIT_PORT == "" ]]; then break; fi
104 | shift 2
105 | ;;
106 | --port=*)
107 | WAITFORIT_PORT="${1#*=}"
108 | shift 1
109 | ;;
110 | -t)
111 | WAITFORIT_TIMEOUT="$2"
112 | if [[ $WAITFORIT_TIMEOUT == "" ]]; then break; fi
113 | shift 2
114 | ;;
115 | --timeout=*)
116 | WAITFORIT_TIMEOUT="${1#*=}"
117 | shift 1
118 | ;;
119 | --)
120 | shift
121 | WAITFORIT_CLI=("$@")
122 | break
123 | ;;
124 | --help)
125 | usage
126 | ;;
127 | *)
128 | echoerr "Unknown argument: $1"
129 | usage
130 | ;;
131 | esac
132 | done
133 |
134 | if [[ "$WAITFORIT_HOST" == "" || "$WAITFORIT_PORT" == "" ]]; then
135 | echoerr "Error: you need to provide a host and port to test."
136 | usage
137 | fi
138 |
139 | WAITFORIT_TIMEOUT=${WAITFORIT_TIMEOUT:-15}
140 | WAITFORIT_STRICT=${WAITFORIT_STRICT:-0}
141 | WAITFORIT_CHILD=${WAITFORIT_CHILD:-0}
142 | WAITFORIT_QUIET=${WAITFORIT_QUIET:-0}
143 |
144 | # Check to see if timeout is from busybox?
145 | WAITFORIT_TIMEOUT_PATH=$(type -p timeout)
146 | WAITFORIT_TIMEOUT_PATH=$(realpath $WAITFORIT_TIMEOUT_PATH 2>/dev/null || readlink -f $WAITFORIT_TIMEOUT_PATH)
147 |
148 | WAITFORIT_BUSYTIMEFLAG=""
149 | if [[ $WAITFORIT_TIMEOUT_PATH =~ "busybox" ]]; then
150 | WAITFORIT_ISBUSY=1
151 | # Check if busybox timeout uses -t flag
152 | # (recent Alpine versions don't support -t anymore)
153 | if timeout &>/dev/stdout | grep -q -e '-t '; then
154 | WAITFORIT_BUSYTIMEFLAG="-t"
155 | fi
156 | else
157 | WAITFORIT_ISBUSY=0
158 | fi
159 |
160 | if [[ $WAITFORIT_CHILD -gt 0 ]]; then
161 | wait_for
162 | WAITFORIT_RESULT=$?
163 | exit $WAITFORIT_RESULT
164 | else
165 | if [[ $WAITFORIT_TIMEOUT -gt 0 ]]; then
166 | wait_for_wrapper
167 | WAITFORIT_RESULT=$?
168 | else
169 | wait_for
170 | WAITFORIT_RESULT=$?
171 | fi
172 | fi
173 |
174 | if [[ $WAITFORIT_CLI != "" ]]; then
175 | if [[ $WAITFORIT_RESULT -ne 0 && $WAITFORIT_STRICT -eq 1 ]]; then
176 | echoerr "$WAITFORIT_cmdname: strict mode, refusing to execute subprocess"
177 | exit $WAITFORIT_RESULT
178 | fi
179 | exec "${WAITFORIT_CLI[@]}"
180 | else
181 | exit $WAITFORIT_RESULT
182 | fi
183 |
--------------------------------------------------------------------------------
/src/constants.js:
--------------------------------------------------------------------------------
1 | import path from 'path';
2 |
3 | export const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000';
4 |
5 | export const BASE_PATH = path.join(__dirname, '..');
6 | export const EDGES_DIRECTORY_PATH = path.join(BASE_PATH, 'edges-data');
7 | export const EDGES_FILE_PATH = path.join(EDGES_DIRECTORY_PATH, 'edges.csv');
8 | export const PATHFINDER_FILE_PATH = path.join(BASE_PATH, 'pathfinder');
9 |
10 | export const HOPS_DEFAULT = '3';
11 |
--------------------------------------------------------------------------------
/src/controllers/news.js:
--------------------------------------------------------------------------------
1 | import { Op } from 'sequelize';
2 |
3 | import News from '../models/news';
4 | import { respondWithSuccess } from '../helpers/responses';
5 |
6 | function prepareNewsResult({ dataValues: { message_en, title_en, ...rest } }) {
7 | return {
8 | message: {
9 | en: message_en,
10 | },
11 | title: {
12 | en: title_en,
13 | },
14 | ...rest,
15 | };
16 | }
17 |
18 | async function resolveBatch(req, res, next) {
19 | const { isActive, limit, offset } = req.query;
20 |
21 | News.findAll({
22 | where: {
23 | isActive: isActive,
24 | },
25 | order: [['date', 'DESC']],
26 | limit: limit,
27 | offset: offset,
28 | })
29 | .then((response) => {
30 | respondWithSuccess(res, response.map(prepareNewsResult));
31 | })
32 | .catch((err) => {
33 | next(err);
34 | });
35 | }
36 |
37 | async function findByDate(req, res, next) {
38 | const { isActive, afterDate, limit, offset } = req.query;
39 |
40 | News.findAll({
41 | where: {
42 | isActive: isActive,
43 | date: { [Op.gte]: new Date(afterDate) },
44 | },
45 | order: [['date', 'DESC']],
46 | limit: limit,
47 | offset: offset,
48 | })
49 | .then((response) => {
50 | respondWithSuccess(res, response.map(prepareNewsResult));
51 | })
52 | .catch((err) => {
53 | next(err);
54 | });
55 | }
56 |
57 | export default {
58 | findNews: async (req, res, next) => {
59 | if (req.query.afterDate) {
60 | return await findByDate(req, res, next);
61 | }
62 | return await resolveBatch(req, res, next);
63 | },
64 | };
65 |
--------------------------------------------------------------------------------
/src/controllers/transfers.js:
--------------------------------------------------------------------------------
1 | import httpStatus from 'http-status';
2 |
3 | import APIError from '../helpers/errors';
4 | import Transfer from '../models/transfers';
5 | import transferSteps from '../services/findTransferSteps';
6 | import updatePath from '../services/updateTransferSteps';
7 | import { checkFileExists } from '../services/edgesFile';
8 | import { checkSignature } from '../helpers/signature';
9 | import { requestGraph } from '../services/graph';
10 | import { respondWithSuccess } from '../helpers/responses';
11 |
12 | function prepareTransferResult(response) {
13 | return {
14 | id: response.id,
15 | from: response.from,
16 | to: response.to,
17 | transactionHash: response.transactionHash,
18 | paymentNote: response.paymentNote,
19 | };
20 | }
21 |
22 | async function checkIfExists(transactionHash) {
23 | const response = await Transfer.findOne({
24 | where: {
25 | transactionHash,
26 | },
27 | });
28 |
29 | if (response) {
30 | throw new APIError(httpStatus.CONFLICT, 'Entry already exists');
31 | }
32 | }
33 |
34 | export default {
35 | createNewTransfer: async (req, res, next) => {
36 | const { address, signature, data } = req.body;
37 | const { from, to, transactionHash, paymentNote } = data;
38 |
39 | try {
40 | // Check signature
41 | if (!checkSignature([from, to, transactionHash], signature, address)) {
42 | throw new APIError(httpStatus.FORBIDDEN, 'Invalid signature');
43 | }
44 |
45 | // Check if entry already exists
46 | await checkIfExists(transactionHash);
47 | } catch (err) {
48 | return next(err);
49 | }
50 |
51 | // Everything is fine, create entry!
52 | Transfer.create({
53 | from,
54 | to,
55 | paymentNote,
56 | transactionHash,
57 | })
58 | .then(() => {
59 | respondWithSuccess(res, null, httpStatus.CREATED);
60 | })
61 | .catch((err) => {
62 | next(err);
63 | });
64 | },
65 |
66 | getByTransactionHash: async (req, res, next) => {
67 | const { transactionHash } = req.params;
68 | const { address, signature } = req.body;
69 | let safeAddresses = [];
70 |
71 | // Check signature
72 | try {
73 | if (!checkSignature([transactionHash], signature, address)) {
74 | throw new APIError(httpStatus.FORBIDDEN, 'Invalid signature');
75 | }
76 |
77 | // Check if signer ownes the claimed safe address
78 | const query = `{
79 | user(id: "${address.toLowerCase()}") {
80 | safeAddresses
81 | }
82 | }`;
83 |
84 | const data = await requestGraph(query);
85 |
86 | if (!data || !data.user) {
87 | throw new APIError(httpStatus.FORBIDDEN, 'Not allowed');
88 | }
89 |
90 | safeAddresses = data.user.safeAddresses;
91 | } catch (err) {
92 | return next(err);
93 | }
94 |
95 | Transfer.findOne({
96 | where: {
97 | transactionHash,
98 | },
99 | })
100 | .then((response) => {
101 | if (!response) {
102 | next(new APIError(httpStatus.NOT_FOUND));
103 | } else if (
104 | // Check if user is either sender or receiver
105 | !safeAddresses.includes(response.from.toLowerCase()) &&
106 | !safeAddresses.includes(response.to.toLowerCase())
107 | ) {
108 | next(new APIError(httpStatus.FORBIDDEN, 'Not allowed'));
109 | } else {
110 | respondWithSuccess(res, prepareTransferResult(response));
111 | }
112 | })
113 | .catch((err) => {
114 | next(err);
115 | });
116 | },
117 |
118 | findTransferSteps: async (req, res, next) => {
119 | if (!checkFileExists()) {
120 | next(
121 | new APIError(
122 | httpStatus.SERVICE_UNAVAILABLE,
123 | 'Trust network file does not exist',
124 | ),
125 | );
126 | }
127 |
128 | try {
129 | const result = await transferSteps({
130 | ...req.body,
131 | });
132 |
133 | respondWithSuccess(res, result);
134 | } catch (error) {
135 | next(new APIError(httpStatus.UNPROCESSABLE_ENTITY, error.message));
136 | }
137 | },
138 |
139 | updateTransferSteps: async (req, res, next) => {
140 | if (!checkFileExists()) {
141 | next(
142 | new APIError(
143 | httpStatus.SERVICE_UNAVAILABLE,
144 | 'Trust network file does not exist',
145 | ),
146 | );
147 | }
148 |
149 | try {
150 | const result = await updatePath({
151 | ...req.body,
152 | });
153 |
154 | respondWithSuccess(res, result);
155 | } catch (error) {
156 | next(new APIError(httpStatus.UNPROCESSABLE_ENTITY, error.message));
157 | }
158 | },
159 |
160 | getMetrics: async (req, res) => {
161 | // @DEPRECATED
162 | respondWithSuccess(res);
163 | },
164 | };
165 |
--------------------------------------------------------------------------------
/src/controllers/uploads.js:
--------------------------------------------------------------------------------
1 | import { PutObjectCommand, DeleteObjectCommand } from '@aws-sdk/client-s3';
2 | import httpStatus from 'http-status';
3 | import mime from 'mime';
4 |
5 | import APIError from '../helpers/errors';
6 | import User from '../models/users';
7 | import { FIELD_NAME } from '../routes/uploads';
8 | import { respondWithSuccess, respondWithError } from '../helpers/responses';
9 | import { s3, AWS_S3_DOMAIN } from '../services/aws';
10 |
11 | export const KEY_PATH = 'uploads/avatars/';
12 |
13 | export default {
14 | uploadAvatarImage: async (req, res, next) => {
15 | const bucket = process.env.AWS_S3_BUCKET;
16 |
17 | try {
18 | if (
19 | !req.locals ||
20 | !req.locals.images ||
21 | !req.locals.images[FIELD_NAME] ||
22 | req.locals.images[FIELD_NAME].length === 0
23 | ) {
24 | next(new APIError('No files given', httpStatus.BAD_REQUEST));
25 | return;
26 | }
27 |
28 | const { buffer, fileName, fileType } = req.locals.images[FIELD_NAME][0];
29 | const key = `${KEY_PATH}${fileName}`;
30 |
31 | const params = {
32 | Bucket: bucket, // The name of the bucket.
33 | Key: key, // The name of the object.
34 | Body: buffer, // The content of the object.
35 | ACL: 'public-read',
36 | ContentType: mime.getType(fileType),
37 | };
38 |
39 | const results = await s3.send(new PutObjectCommand(params));
40 | respondWithSuccess(
41 | res,
42 | {
43 | url: `https://${bucket}.${AWS_S3_DOMAIN}/${key}`,
44 | fileName,
45 | fileType,
46 | },
47 | results.$metadata.httpStatusCode,
48 | );
49 | } catch (error) {
50 | next(error);
51 | }
52 | },
53 |
54 | deleteAvatarImage: async (req, res, next) => {
55 | const bucket = process.env.AWS_S3_BUCKET;
56 | try {
57 | const { url } = req.body;
58 | const imageUrl = new URL(url);
59 | if (
60 | imageUrl.host != `${bucket}.${AWS_S3_DOMAIN}` ||
61 | !imageUrl.pathname.startsWith(`/${KEY_PATH}`)
62 | ) {
63 | respondWithError(res, {}, httpStatus.BAD_REQUEST);
64 | } else {
65 | // Check that the url is not used in the users db
66 | const avatarExists = await User.findOne({
67 | where: {
68 | avatarUrl: url,
69 | },
70 | });
71 | if (avatarExists) {
72 | respondWithError(res, {}, httpStatus.UNPROCESSABLE_ENTITY);
73 | } else {
74 | const pathSplit = imageUrl.pathname.split('/');
75 | const fileName = pathSplit[pathSplit.length - 1];
76 | const key = `${KEY_PATH}${fileName}`;
77 | const params = {
78 | Bucket: bucket, // The name of the bucket.
79 | Key: key, // The name of the object.
80 | };
81 | const results = await s3.send(new DeleteObjectCommand(params));
82 | respondWithSuccess(res, {}, results.$metadata.httpStatusCode);
83 | }
84 | }
85 | } catch (error) {
86 | next(error);
87 | }
88 | },
89 | };
90 |
--------------------------------------------------------------------------------
/src/database/config.js:
--------------------------------------------------------------------------------
1 | // Use require since it is used by sequelize-cli without babel
2 | require('../helpers/env');
3 |
4 | const url = process.env.DATABASE_URL;
5 | const dialect = process.env.DATABASE_DIALECT || 'postgres';
6 |
7 | const timezone = '+00:00'; // UTC
8 |
9 | const asInteger = (envVarName, fallbackValue = 0) => {
10 | if (envVarName in process.env) {
11 | return parseInt(process.env[envVarName], 10);
12 | }
13 | return fallbackValue;
14 | };
15 |
16 | const sslConfiguration = process.env.POSTGRES_USE_SSL
17 | ? {
18 | ssl: true,
19 | dialectOptions: {
20 | ssl: {
21 | require: true,
22 | rejectUnauthorized: false,
23 | },
24 | },
25 | }
26 | : {};
27 |
28 | const pool = {
29 | // Maximum number of connection in pool
30 | max: asInteger('POOL_MAX', 5),
31 | // Minimum number of connection in pool
32 | min: asInteger('POOL_MIN', 0),
33 | // The maximum time, in milliseconds, that pool will try to get
34 | // connection before throwing error
35 | acquire: asInteger('POOL_AQUIRE', 1000 * 60),
36 | // The maximum time, in milliseconds, that a connection can be idle
37 | // before being released
38 | idle: asInteger('POOL_IDLE', 1000 * 10),
39 | };
40 |
41 | module.exports = {
42 | test: {
43 | url,
44 | dialect,
45 | timezone,
46 | ...sslConfiguration,
47 | pool,
48 | },
49 | development: {
50 | url,
51 | dialect,
52 | timezone,
53 | ...sslConfiguration,
54 | pool,
55 | },
56 | staging: {
57 | url,
58 | dialect,
59 | timezone,
60 | ...sslConfiguration,
61 | pool,
62 | },
63 | production: {
64 | url,
65 | dialect,
66 | timezone,
67 | ...sslConfiguration,
68 | pool,
69 | },
70 | };
71 |
--------------------------------------------------------------------------------
/src/database/index.js:
--------------------------------------------------------------------------------
1 | import Sequelize from 'sequelize';
2 |
3 | import config from './config';
4 | import logger from '../helpers/logger';
5 |
6 | const { url, dialect, dialectOptions, ssl } = config[process.env.NODE_ENV];
7 |
8 | export default new Sequelize(url, {
9 | dialect,
10 | dialectOptions,
11 | ssl,
12 | logging: (msg) => {
13 | logger.debug(msg);
14 | },
15 | });
16 |
--------------------------------------------------------------------------------
/src/database/migrations/20190915155608-create-users.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | up: (queryInterface, Sequelize) => {
3 | return queryInterface.createTable('users', {
4 | id: {
5 | type: Sequelize.INTEGER,
6 | primaryKey: true,
7 | autoIncrement: true,
8 | },
9 | createdAt: {
10 | type: Sequelize.DATE,
11 | },
12 | updatedAt: {
13 | type: Sequelize.DATE,
14 | },
15 | username: {
16 | type: Sequelize.STRING,
17 | unique: true,
18 | allowNull: false,
19 | validate: {
20 | notEmpty: true,
21 | },
22 | },
23 | safeAddress: {
24 | type: Sequelize.STRING,
25 | unique: true,
26 | allowNull: false,
27 | validate: {
28 | notEmpty: true,
29 | },
30 | },
31 | });
32 | },
33 | down: (queryInterface) => {
34 | return queryInterface.dropTable('users');
35 | },
36 | };
37 |
--------------------------------------------------------------------------------
/src/database/migrations/20200121140129-user-add-email-field.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | up: (queryInterface, Sequelize) => {
3 | return queryInterface.addColumn('users', 'email', {
4 | type: Sequelize.STRING,
5 | allowNull: false,
6 | validate: {
7 | notEmpty: true,
8 | },
9 | });
10 | },
11 | down: (queryInterface) => {
12 | return queryInterface.removeColumn('users', 'email');
13 | },
14 | };
15 |
--------------------------------------------------------------------------------
/src/database/migrations/20200902194521-add-avatar-url-to-users.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | up: (queryInterface, Sequelize) => {
3 | return queryInterface.addColumn('users', 'avatarUrl', {
4 | type: Sequelize.STRING,
5 | allowNull: true,
6 | });
7 | },
8 | down: (queryInterface) => {
9 | return queryInterface.removeColumn('users', 'avatarUrl');
10 | },
11 | };
12 |
--------------------------------------------------------------------------------
/src/database/migrations/20200930130328-create-transfers.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | up: (queryInterface, Sequelize) => {
3 | return queryInterface.createTable('transfers', {
4 | id: {
5 | type: Sequelize.INTEGER,
6 | primaryKey: true,
7 | autoIncrement: true,
8 | },
9 | createdAt: {
10 | type: Sequelize.DATE,
11 | },
12 | updatedAt: {
13 | type: Sequelize.DATE,
14 | },
15 | from: {
16 | type: Sequelize.STRING,
17 | allowNull: false,
18 | validate: {
19 | notEmpty: true,
20 | },
21 | },
22 | to: {
23 | type: Sequelize.STRING,
24 | allowNull: false,
25 | validate: {
26 | notEmpty: true,
27 | },
28 | },
29 | transactionHash: {
30 | type: Sequelize.STRING,
31 | unique: true,
32 | allowNull: false,
33 | validate: {
34 | notEmpty: true,
35 | },
36 | },
37 | paymentNote: {
38 | type: Sequelize.TEXT,
39 | allowNull: true,
40 | },
41 | });
42 | },
43 | down: (queryInterface) => {
44 | return queryInterface.dropTable('transfers');
45 | },
46 | };
47 |
--------------------------------------------------------------------------------
/src/database/migrations/20201008130741-create-edges.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | up: (queryInterface, Sequelize) => {
3 | return queryInterface.createTable('edges', {
4 | id: {
5 | type: Sequelize.INTEGER,
6 | primaryKey: true,
7 | autoIncrement: true,
8 | },
9 | createdAt: {
10 | type: Sequelize.DATE,
11 | },
12 | updatedAt: {
13 | type: Sequelize.DATE,
14 | },
15 | from: {
16 | type: Sequelize.STRING(42),
17 | allowNull: false,
18 | unique: 'edges_unique',
19 | },
20 | to: {
21 | type: Sequelize.STRING(42),
22 | allowNull: false,
23 | unique: 'edges_unique',
24 | },
25 | token: {
26 | type: Sequelize.STRING(42),
27 | allowNull: false,
28 | unique: 'edges_unique',
29 | },
30 | capacity: {
31 | type: Sequelize.STRING,
32 | allowNull: false,
33 | },
34 | });
35 | },
36 | down: (queryInterface) => {
37 | return queryInterface.dropTable('edges');
38 | },
39 | };
40 |
--------------------------------------------------------------------------------
/src/database/migrations/20201012092801-create-metrics.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | up: (queryInterface, Sequelize) => {
3 | return queryInterface.createTable('metrics', {
4 | id: {
5 | type: Sequelize.INTEGER,
6 | primaryKey: true,
7 | autoIncrement: true,
8 | },
9 | createdAt: {
10 | type: Sequelize.DATE,
11 | },
12 | updatedAt: {
13 | type: Sequelize.DATE,
14 | },
15 | category: {
16 | type: Sequelize.STRING,
17 | allowNull: false,
18 | unique: 'metrics_unique',
19 | },
20 | name: {
21 | type: Sequelize.STRING,
22 | allowNull: false,
23 | unique: 'metrics_unique',
24 | },
25 | value: {
26 | type: Sequelize.BIGINT,
27 | allowNull: false,
28 | },
29 | });
30 | },
31 | down: (queryInterface) => {
32 | return queryInterface.dropTable('metrics');
33 | },
34 | };
35 |
--------------------------------------------------------------------------------
/src/database/migrations/20201021125825-add-index-to-edges.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | up: async (queryInterface) => {
3 | await queryInterface.addIndex('edges', ['from', 'to', 'token'], {
4 | name: 'edges_unique',
5 | unique: true,
6 | });
7 | },
8 | down: async (queryInterface) => {
9 | await queryInterface.removeIndex('edges', ['from', 'to', 'token']);
10 | },
11 | };
12 |
--------------------------------------------------------------------------------
/src/database/migrations/20230207095851-create-news.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | /** @type {import('sequelize-cli').Migration} */
4 | module.exports = {
5 | async up(queryInterface, Sequelize) {
6 | await queryInterface.createTable('news', {
7 | id: {
8 | type: Sequelize.INTEGER,
9 | primaryKey: true,
10 | autoIncrement: true,
11 | },
12 | createdAt: {
13 | type: Sequelize.DATE,
14 | },
15 | updatedAt: {
16 | type: Sequelize.DATE,
17 | },
18 | message_en: {
19 | type: Sequelize.TEXT,
20 | },
21 | date: {
22 | type: Sequelize.DATE,
23 | allowNull: false,
24 | validate: {
25 | notEmpty: true,
26 | },
27 | defaultValue: Sequelize.NOW,
28 | },
29 | iconId: {
30 | type: Sequelize.INTEGER,
31 | allowNull: false,
32 | },
33 | isActive: {
34 | type: Sequelize.BOOLEAN,
35 | allowNull: false,
36 | defaultValue: true,
37 | },
38 | });
39 | },
40 |
41 | async down(queryInterface) {
42 | await queryInterface.dropTable('news');
43 | },
44 | };
45 |
--------------------------------------------------------------------------------
/src/database/migrations/20230713102042-add-index-users.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | up: async (queryInterface) => {
3 | await queryInterface.addIndex('users', ['username'], {
4 | name: 'IX_users_name',
5 | unique: true,
6 | concurrently: true,
7 | });
8 | },
9 | down: async (queryInterface) => {
10 | await queryInterface.removeIndex('users', ['username']);
11 | },
12 | };
13 |
--------------------------------------------------------------------------------
/src/database/migrations/20230713111651-add-index-safe-address.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | up: async (queryInterface) => {
3 | await queryInterface.addIndex('users', ['safeAddress'], {
4 | name: 'IX_safe_address',
5 | unique: true,
6 | concurrently: true,
7 | });
8 | },
9 | down: async (queryInterface) => {
10 | await queryInterface.removeIndex('users', ['safeAddress']);
11 | },
12 | };
13 |
--------------------------------------------------------------------------------
/src/database/migrations/20230814085817-add-news-title-field.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | module.exports = {
4 | up: (queryInterface, Sequelize) => {
5 | return queryInterface.addColumn('news', 'title_en', {
6 | type: Sequelize.TEXT,
7 | });
8 | },
9 | down: (queryInterface) => {
10 | return queryInterface.removeColumn('news', 'title_en');
11 | },
12 | };
13 |
--------------------------------------------------------------------------------
/src/database/migrations/20240626201929-add-profile-migration-consent.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | up: (queryInterface, Sequelize) => {
3 | return queryInterface.addColumn('users', 'profileMigrationConsent', {
4 | type: Sequelize.BOOLEAN,
5 | allowNull: false,
6 | defaultValue: false,
7 | });
8 | },
9 | down: (queryInterface) => {
10 | return queryInterface.removeColumn('users', 'profileMigrationConsent');
11 | },
12 | };
13 |
--------------------------------------------------------------------------------
/src/database/seeders/20201012093423-create-transfer-metrics.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | up: async (queryInterface) => {
3 | await queryInterface.bulkInsert(
4 | 'metrics',
5 | [
6 | 'countEdges',
7 | 'countSafes',
8 | 'countTokens',
9 | 'edgesLastAdded',
10 | 'edgesLastRemoved',
11 | 'edgesLastUpdated',
12 | 'lastBlockNumber',
13 | 'lastUpdateAt',
14 | 'lastUpdateDuration',
15 | ].map((name) => {
16 | return {
17 | category: 'transfers',
18 | name,
19 | value: 0,
20 | };
21 | }),
22 | {},
23 | );
24 | },
25 |
26 | down: async (queryInterface) => {
27 | await queryInterface.bulkDelete('metrics', null, {});
28 | },
29 | };
30 |
--------------------------------------------------------------------------------
/src/helpers/compare.js:
--------------------------------------------------------------------------------
1 | // "100000000" > "200" returns false when comparing number strings but with
2 | // this workaround we're able to compare long numbers as strings:
3 | export function minNumberString(a, b) {
4 | // Which one is shorter?
5 | if (a.length < b.length) {
6 | return a;
7 | } else if (b.length < a.length) {
8 | return b;
9 | }
10 |
11 | // It does not matter, its the same string:
12 | if (a === b) {
13 | return a;
14 | }
15 |
16 | // If they have the same length, we can actually do this:
17 | return a < b ? a : b;
18 | }
19 |
--------------------------------------------------------------------------------
/src/helpers/env.js:
--------------------------------------------------------------------------------
1 | // Use require since it is used by sequelize-cli without babel
2 | const dotenv = require('dotenv');
3 | const path = require('path');
4 |
5 | // Load .env files
6 | dotenv.config({
7 | path: path.join(__dirname, '..', '..', '.env'),
8 | });
9 |
--------------------------------------------------------------------------------
/src/helpers/errors.js:
--------------------------------------------------------------------------------
1 | import httpStatus from 'http-status';
2 |
3 | class BaseError extends Error {
4 | constructor(code, message, isPublic) {
5 | super(message);
6 |
7 | this.name = this.constructor.name;
8 | this.message = message;
9 |
10 | if (code && code > 0) {
11 | this.code = code;
12 | } else {
13 | this.code = httpStatus.INTERNAL_SERVER_ERROR;
14 | }
15 |
16 | this.isPublic = isPublic;
17 |
18 | Error.captureStackTrace(this, this.constructor.name);
19 | }
20 | }
21 |
22 | export default class APIError extends BaseError {
23 | constructor(
24 | code = httpStatus.INTERNAL_SERVER_ERROR,
25 | message,
26 | isPublic = true,
27 | ) {
28 | super(code, message, isPublic);
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/src/helpers/logger.js:
--------------------------------------------------------------------------------
1 | import winston from 'winston';
2 |
3 | const DEFAULT_LOG_LEVEL = 'info';
4 |
5 | const { format } = winston;
6 |
7 | export default winston.createLogger({
8 | level: process.env.LOG_LEVEL || DEFAULT_LOG_LEVEL,
9 | format: format.combine(
10 | format.colorize(),
11 | format.timestamp(),
12 | format.printf((info) => {
13 | return `${info.timestamp} [${info.level}]: ${info.message}`;
14 | }),
15 | ),
16 | transports: [new winston.transports.Console()],
17 | });
18 |
--------------------------------------------------------------------------------
/src/helpers/loop.js:
--------------------------------------------------------------------------------
1 | const LOOP_INTERVAL = 5000;
2 | const MAX_ATTEMPTS = 12;
3 |
4 | export default async function loop(request, condition) {
5 | return new Promise((resolve, reject) => {
6 | let attempt = 0;
7 |
8 | const interval = setInterval(async () => {
9 | try {
10 | const response = await request();
11 | attempt += 1;
12 |
13 | if (condition(response)) {
14 | clearInterval(interval);
15 | resolve(response);
16 | } else if (attempt > MAX_ATTEMPTS) {
17 | throw new Error('Too many attempts');
18 | }
19 | } catch (error) {
20 | clearInterval(interval);
21 | reject(error);
22 | }
23 | }, LOOP_INTERVAL);
24 | });
25 | }
26 |
--------------------------------------------------------------------------------
/src/helpers/reduce.js:
--------------------------------------------------------------------------------
1 | const DEFAULT_BUFFER_DECIMALS = 15; // 1000000000000000 or 0.001 Circles
2 |
3 | export default function reduceCapacities(
4 | edges,
5 | bufferDecimals = DEFAULT_BUFFER_DECIMALS,
6 | ) {
7 | return edges
8 | .filter((edge) => {
9 | return edge.capacity.length >= bufferDecimals + 1;
10 | })
11 | .reduce((acc, edge) => {
12 | acc.push({
13 | ...edge,
14 | capacity: reduceCapacity(edge.capacity, bufferDecimals),
15 | });
16 | return acc;
17 | }, []);
18 | }
19 |
20 | export function reduceCapacity(
21 | value,
22 | bufferDecimals = DEFAULT_BUFFER_DECIMALS,
23 | ) {
24 | // Ignore too small values
25 | if (value.length < bufferDecimals + 1) {
26 | return value;
27 | }
28 |
29 | const index = value.length - bufferDecimals;
30 | const frontNumber = value.slice(0, index);
31 | const endOfNumber = value.slice(index + 1, value.length);
32 | const figureToChange = parseInt(value[index], 10);
33 |
34 | if (figureToChange === 0) {
35 | const reduced = parseInt(frontNumber, 10) - 1;
36 | return `${reduced === 0 ? '' : reduced}9${endOfNumber}`;
37 | } else {
38 | return `${frontNumber}${figureToChange - 1}${endOfNumber}`;
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/src/helpers/responses.js:
--------------------------------------------------------------------------------
1 | import httpStatus from 'http-status';
2 |
3 | function respond(res, status, data, code) {
4 | res.status(code).json({
5 | status,
6 | ...data,
7 | });
8 | }
9 |
10 | export function respondWithSuccess(res, data, status = httpStatus.OK) {
11 | respond(res, 'ok', { data }, status);
12 | }
13 |
14 | export function respondWithError(
15 | res,
16 | data,
17 | code = httpStatus.INTERNAL_SERVER_ERROR,
18 | ) {
19 | respond(res, 'error', data, code);
20 | }
21 |
--------------------------------------------------------------------------------
/src/helpers/signature.js:
--------------------------------------------------------------------------------
1 | import web3 from '../services/web3';
2 |
3 | export function checkSignature(fields, signature, claimedAddress) {
4 | const dataString = fields.join('');
5 |
6 | let recoveredAddress;
7 | try {
8 | recoveredAddress = web3.eth.accounts.recover(dataString, signature);
9 | } catch {
10 | // Do nothing ..
11 | }
12 |
13 | return recoveredAddress === claimedAddress;
14 | }
15 |
--------------------------------------------------------------------------------
/src/helpers/validate.js:
--------------------------------------------------------------------------------
1 | import { celebrate, Joi } from 'celebrate';
2 |
3 | import web3 from '../services/web3';
4 |
5 | export const customJoi = Joi.extend((joi) => {
6 | return {
7 | type: 'web3',
8 | base: joi.string(),
9 | messages: {
10 | 'web3.address': 'is invalid Ethereum address',
11 | 'web3.addressChecksum': 'is invalid address checksum',
12 | 'web3.transactionHash': 'is invalid transaction hash',
13 | },
14 | rules: {
15 | transactionHash: {
16 | validate(value, helpers) {
17 | if (!/^0x([A-Fa-f0-9]{64})$/.test(value)) {
18 | return helpers.error('web3.transactionHash');
19 | }
20 |
21 | return value;
22 | },
23 | },
24 | address: {
25 | validate(value, helpers) {
26 | if (!value || !web3.utils.isAddress(value)) {
27 | return helpers.error('web3.address');
28 | }
29 |
30 | return value;
31 | },
32 | },
33 | addressChecksum: {
34 | validate(value, helpers) {
35 | if (!value || !web3.utils.checkAddressChecksum(value)) {
36 | return helpers.error('web3.addressChecksum');
37 | }
38 |
39 | return value;
40 | },
41 | },
42 | },
43 | };
44 | });
45 |
46 | export default function validate(schema) {
47 | const joiOptions = {
48 | abortEarly: false,
49 | };
50 |
51 | return celebrate(schema, joiOptions);
52 | }
53 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import bodyParser from 'body-parser';
2 | import compression from 'compression';
3 | import cors from 'cors';
4 | import express from 'express';
5 | import helmet from 'helmet';
6 | import methodOverride from 'method-override';
7 | import morgan from 'morgan';
8 |
9 | import './helpers/env';
10 |
11 | import errorsMiddleware from './middlewares/errors';
12 | import logger from './helpers/logger';
13 | import db from './database';
14 |
15 | const DEFAULT_PORT = 3000;
16 |
17 | // Check database connection
18 | db.authenticate()
19 | .then(() => {
20 | logger.info('Database connection has been established successfully');
21 | })
22 | .catch(() => {
23 | logger.error('Unable to connect to database');
24 | process.exit(1);
25 | });
26 |
27 | // Initialize express instance
28 | const app = express();
29 | app.set('port', process.env.PORT || DEFAULT_PORT);
30 |
31 | // Use HTTP middlewares
32 | app.use(compression());
33 | app.use(methodOverride());
34 | app.use(bodyParser.json());
35 |
36 | // Use CORS and security middlewares
37 | app.use(cors());
38 | app.use(helmet());
39 |
40 | // Log HTTP requests and route them to winston
41 | app.use(
42 | morgan(
43 | (tokens, req, res) => {
44 | return [
45 | tokens.method(req, res),
46 | tokens.url(req, res),
47 | tokens.status(req, res),
48 | tokens.res(req, res, 'content-length'),
49 | '-',
50 | tokens['response-time'](req, res),
51 | 'ms',
52 | ].join(' ');
53 | },
54 | {
55 | stream: {
56 | write: (message) => logger.info(message.replace('\n', '')),
57 | },
58 | },
59 | ),
60 | );
61 |
62 | // Mount all API routes
63 | app.use('/api', require('./routes'));
64 |
65 | // Use middleware to handle all thrown errors
66 | app.use(errorsMiddleware);
67 |
68 | // Start server
69 | if (process.env.NODE_ENV !== 'test') {
70 | const env = app.get('env');
71 | const port = app.get('port');
72 |
73 | app.listen(port, () => {
74 | logger.info(`Server is listening at port ${port} in ${env} mode`);
75 | });
76 | }
77 |
78 | export default app;
79 |
--------------------------------------------------------------------------------
/src/middlewares/errors.js:
--------------------------------------------------------------------------------
1 | import httpStatus from 'http-status';
2 | import { isCelebrateError as isValidationError } from 'celebrate';
3 |
4 | import APIError from '../helpers/errors';
5 | import logger from '../helpers/logger';
6 | import { respondWithError } from '../helpers/responses';
7 |
8 | // eslint-disable-next-line no-unused-vars
9 | export default function errorsMiddleware(err, req, res, next) {
10 | // Check if error is public facing and known to us
11 | if (isValidationError(err)) {
12 | // Show validation errors to user
13 | err = new APIError(httpStatus.BAD_REQUEST);
14 |
15 | if (err.details) {
16 | err.data = {
17 | fields: err.details.map((detail) => {
18 | return {
19 | path: detail.path,
20 | message: detail.message,
21 | };
22 | }),
23 | };
24 | }
25 | } else if (
26 | !(err instanceof APIError) ||
27 | (!err.isPublic && process.env.NODE_ENV === 'production')
28 | ) {
29 | // Log error message internally ..
30 | if (err.code) {
31 | const message = err.message || httpStatus[err.code];
32 | logger.error(`${message} ${err.code} ${err.stack}`);
33 | } else {
34 | logger.error(err.stack);
35 | }
36 |
37 | // .. and expose generic message to public
38 | err = new APIError(httpStatus.INTERNAL_SERVER_ERROR);
39 | }
40 |
41 | // Respond with error message and status
42 | respondWithError(
43 | res,
44 | {
45 | code: err.code,
46 | message: err.message || httpStatus[err.code],
47 | ...err.data,
48 | },
49 | err.code,
50 | );
51 | }
52 |
--------------------------------------------------------------------------------
/src/middlewares/images.js:
--------------------------------------------------------------------------------
1 | import fs from 'fs';
2 | import sharp from 'sharp';
3 |
4 | const DEFAULT_QUALITY = 90;
5 | const DEFAULT_WIDTH = 1200;
6 | const DEFAULT_HEIGHT = 1200;
7 | const DEFAULT_SUFFIX = 'original';
8 |
9 | // Handle image file resizes for one or more fields, like:
10 | //
11 | // [
12 | // {
13 | // name: "headerImages",
14 | // versions: [{
15 | // width: 900,
16 | // height: 600,
17 | // }],
18 | // },
19 | // {
20 | // name: "galleryImages",
21 | // versions: [{
22 | // suffix: 'sm',
23 | // width: 900,
24 | // height: 600,
25 | // }, {
26 | // width: 1600,
27 | // height: 1600,
28 | // }],
29 | // },
30 | // ...
31 | // ]
32 | export default function convertImages(fields) {
33 | const fileType = 'jpeg';
34 | const fileTypeExt = 'jpg';
35 |
36 | return async (req, res, next) => {
37 | // Check if there are files uploaded via multer
38 | if (!req.files) {
39 | return next();
40 | }
41 |
42 | try {
43 | const operations = fields.reduce((acc, field) => {
44 | if (!(field.name in req.files)) {
45 | return acc;
46 | }
47 |
48 | field.versions.forEach((version) => {
49 | const {
50 | quality = DEFAULT_QUALITY,
51 | width = DEFAULT_WIDTH,
52 | height = DEFAULT_HEIGHT,
53 | suffix = DEFAULT_SUFFIX,
54 | } = version;
55 |
56 | req.files[field.name].forEach((file, index) => {
57 | // Rename file based on version suffix
58 | const originalFileName = file.filename;
59 | const originalFileNameBase = originalFileName.split('.')[0];
60 | const fileSuffix = suffix ? `-${suffix}` : '';
61 | const newFileName = `${originalFileNameBase}${fileSuffix}.${fileTypeExt}`;
62 |
63 | const promise = new Promise((resolve, reject) => {
64 | sharp(file.path)
65 | .resize(width, height, {
66 | fit: sharp.fit.cover,
67 | position: sharp.strategy.entropy,
68 | withoutEnlargement: true,
69 | })
70 | .toFormat(fileType, { quality })
71 | .toBuffer((error, buffer) => {
72 | if (error) {
73 | reject(error);
74 | } else {
75 | fs.unlink(file.path, () => {
76 | resolve({
77 | index,
78 | fieldname: field.name,
79 | originalFileName,
80 | fileName: newFileName,
81 | fileType,
82 | buffer,
83 | version,
84 | });
85 | });
86 | }
87 | });
88 | });
89 |
90 | acc.push(promise);
91 | });
92 | });
93 |
94 | return acc;
95 | }, []);
96 |
97 | const results = await Promise.all(operations);
98 |
99 | // Group versions by original image
100 | req.locals = req.locals || {};
101 | req.locals.images = results.reduce(
102 | (acc, { index, fieldname, ...rest }) => {
103 | if (!acc[fieldname]) {
104 | acc[fieldname] = [];
105 | }
106 |
107 | acc[fieldname][index] = rest;
108 |
109 | return acc;
110 | },
111 | {},
112 | );
113 |
114 | next();
115 | } catch (error) {
116 | next(error);
117 | }
118 | };
119 | }
120 |
--------------------------------------------------------------------------------
/src/middlewares/uploads.js:
--------------------------------------------------------------------------------
1 | import httpStatus from 'http-status';
2 | import mime from 'mime';
3 | import multer, { MulterError } from 'multer';
4 |
5 | import APIError from '../helpers/errors';
6 | import web3 from '../services/web3';
7 |
8 | const DEFAULT_FILE_MAX_SIZE = 5 * 1000 * 1000;
9 | const DEFAULT_FILE_TYPES = ['jpeg', 'jpg', 'png'];
10 |
11 | function generateFileName(ext) {
12 | const suffix = web3.utils.randomHex(32).slice(2);
13 | return `${suffix}.${ext === 'jpeg' ? 'jpg' : ext}`;
14 | }
15 |
16 | // Handle file uploads for one or more fields, like:
17 | //
18 | // [
19 | // {
20 | // name: "images",
21 | // maxFileSize: 5 * 1000 * 1000,
22 | // },
23 | // {
24 | // name: "files",
25 | // allowedFileTypes: ['pdf'],
26 | // },
27 | // ...
28 | // ]
29 | export default function uploadFiles(fields) {
30 | const fileFilter = (req, file, cb) => {
31 | const ext = mime.getExtension(file.mimetype);
32 |
33 | const {
34 | maxFileSize = DEFAULT_FILE_MAX_SIZE,
35 | allowedFileTypes = DEFAULT_FILE_TYPES,
36 | } = fields.find((field) => field.name === file.fieldname);
37 |
38 | if (!allowedFileTypes.includes(ext)) {
39 | cb(
40 | new APIError(httpStatus.UNSUPPORTED_MEDIA_TYPE, 'Invalid file format'),
41 | false,
42 | );
43 | } else if (file.size > maxFileSize) {
44 | cb(new APIError(httpStatus.REQUEST_TOO_LONG, 'File is too large'), false);
45 | } else {
46 | cb(null, true);
47 | }
48 | };
49 |
50 | const storage = multer.diskStorage({
51 | filename: (req, file, cb) => {
52 | const ext = mime.getExtension(file.mimetype);
53 | cb(null, generateFileName(ext));
54 | },
55 | });
56 |
57 | const uploadViaMulter = multer({
58 | fileFilter,
59 | storage,
60 | }).fields(fields);
61 |
62 | return (req, res, next) => {
63 | uploadViaMulter(req, res, (error) => {
64 | if (error instanceof MulterError) {
65 | next(new APIError(error.message, httpStatus.BAD_REQUEST));
66 | } else if (error) {
67 | next(error);
68 | } else {
69 | next();
70 | }
71 | });
72 | };
73 | }
74 |
--------------------------------------------------------------------------------
/src/models/edges.js:
--------------------------------------------------------------------------------
1 | import Sequelize from 'sequelize';
2 |
3 | import db from '../database';
4 |
5 | const Edge = db.define(
6 | 'edges',
7 | {
8 | id: {
9 | type: Sequelize.INTEGER,
10 | primaryKey: true,
11 | autoIncrement: true,
12 | },
13 | from: {
14 | type: Sequelize.STRING(42),
15 | allowNull: false,
16 | unique: 'edges_unique',
17 | },
18 | to: {
19 | type: Sequelize.STRING(42),
20 | allowNull: false,
21 | unique: 'edges_unique',
22 | },
23 | token: {
24 | type: Sequelize.STRING(42),
25 | allowNull: false,
26 | unique: 'edges_unique',
27 | },
28 | capacity: {
29 | type: Sequelize.STRING,
30 | allowNull: false,
31 | },
32 | },
33 | {
34 | indexes: [
35 | {
36 | name: 'edges_unique',
37 | unique: true,
38 | fields: ['from', 'to', 'token'],
39 | },
40 | ],
41 | },
42 | );
43 |
44 | export default Edge;
45 |
--------------------------------------------------------------------------------
/src/models/metrics.js:
--------------------------------------------------------------------------------
1 | import Sequelize from 'sequelize';
2 |
3 | import db from '../database';
4 |
5 | const Metric = db.define('metrics', {
6 | id: {
7 | type: Sequelize.INTEGER,
8 | primaryKey: true,
9 | autoIncrement: true,
10 | },
11 | category: {
12 | type: Sequelize.STRING,
13 | allowNull: false,
14 | },
15 | name: {
16 | type: Sequelize.STRING,
17 | allowNull: false,
18 | },
19 | value: {
20 | type: Sequelize.BIGINT,
21 | allowNull: false,
22 | },
23 | });
24 |
25 | export default Metric;
26 |
--------------------------------------------------------------------------------
/src/models/news.js:
--------------------------------------------------------------------------------
1 | import Sequelize from 'sequelize';
2 |
3 | import db from '../database';
4 |
5 | const News = db.define('news', {
6 | id: {
7 | type: Sequelize.INTEGER,
8 | primaryKey: true,
9 | autoIncrement: true,
10 | },
11 | title_en: {
12 | type: Sequelize.TEXT,
13 | },
14 | message_en: {
15 | type: Sequelize.TEXT,
16 | },
17 | date: {
18 | type: Sequelize.DATE,
19 | allowNull: false,
20 | validate: {
21 | notEmpty: true,
22 | },
23 | defaultValue: Sequelize.NOW,
24 | },
25 | iconId: {
26 | type: Sequelize.INTEGER,
27 | allowNull: false,
28 | },
29 | isActive: {
30 | type: Sequelize.BOOLEAN,
31 | allowNull: false,
32 | defaultValue: true,
33 | },
34 | });
35 |
36 | export default News;
37 |
--------------------------------------------------------------------------------
/src/models/transfers.js:
--------------------------------------------------------------------------------
1 | import Sequelize from 'sequelize';
2 |
3 | import db from '../database';
4 |
5 | const Transfer = db.define('transfers', {
6 | id: {
7 | type: Sequelize.INTEGER,
8 | primaryKey: true,
9 | autoIncrement: true,
10 | },
11 | from: {
12 | type: Sequelize.STRING,
13 | allowNull: false,
14 | validate: {
15 | notEmpty: true,
16 | },
17 | },
18 | to: {
19 | type: Sequelize.STRING,
20 | allowNull: false,
21 | validate: {
22 | notEmpty: true,
23 | },
24 | },
25 | transactionHash: {
26 | unique: true,
27 | allowNull: false,
28 | validate: {
29 | notEmpty: true,
30 | },
31 | type: Sequelize.STRING,
32 | },
33 | paymentNote: {
34 | type: Sequelize.TEXT,
35 | allowNull: true,
36 | },
37 | });
38 |
39 | export default Transfer;
40 |
--------------------------------------------------------------------------------
/src/models/users.js:
--------------------------------------------------------------------------------
1 | import Sequelize from 'sequelize';
2 |
3 | import db from '../database';
4 |
5 | const uniqueAndNotNull = {
6 | unique: true,
7 | allowNull: false,
8 | validate: {
9 | notEmpty: true,
10 | },
11 | };
12 |
13 | const User = db.define('users', {
14 | id: {
15 | type: Sequelize.INTEGER,
16 | primaryKey: true,
17 | autoIncrement: true,
18 | },
19 | username: {
20 | ...uniqueAndNotNull,
21 | type: Sequelize.STRING,
22 | },
23 | email: {
24 | type: Sequelize.STRING,
25 | allowNull: false,
26 | validate: {
27 | notEmpty: true,
28 | },
29 | },
30 | avatarUrl: {
31 | type: Sequelize.STRING,
32 | allowNull: true,
33 | },
34 | safeAddress: {
35 | ...uniqueAndNotNull,
36 | type: Sequelize.STRING,
37 | },
38 | profileMigrationConsent: {
39 | type: Sequelize.BOOLEAN,
40 | allowNull: false,
41 | defaultValue: false,
42 | },
43 | });
44 |
45 | export default User;
46 |
--------------------------------------------------------------------------------
/src/routes/index.js:
--------------------------------------------------------------------------------
1 | import express from 'express';
2 | import httpStatus from 'http-status';
3 |
4 | import APIError from '../helpers/errors';
5 | import newsRouter from './news';
6 | import transfersRouter from './transfers';
7 | import uploadsRouter from './uploads';
8 | import usersRouter from './users';
9 | import { respondWithSuccess } from '../helpers/responses';
10 |
11 | const router = express.Router();
12 |
13 | router.get('/', (req, res) => {
14 | respondWithSuccess(res);
15 | });
16 |
17 | router.use('/news', newsRouter);
18 | router.use('/transfers', transfersRouter);
19 | router.use('/uploads', uploadsRouter);
20 | router.use('/users', usersRouter);
21 |
22 | router.use(() => {
23 | throw new APIError(httpStatus.NOT_FOUND);
24 | });
25 |
26 | export default router;
27 |
--------------------------------------------------------------------------------
/src/routes/news.js:
--------------------------------------------------------------------------------
1 | import express from 'express';
2 |
3 | import newsController from '../controllers/news';
4 | import newsValidation from '../validations/news';
5 | import validate from '../helpers/validate';
6 |
7 | const router = express.Router();
8 |
9 | router.get('/', validate(newsValidation.findNews), newsController.findNews);
10 |
11 | export default router;
12 |
--------------------------------------------------------------------------------
/src/routes/transfers.js:
--------------------------------------------------------------------------------
1 | import express from 'express';
2 |
3 | import transfersController from '../controllers/transfers';
4 | import transfersValidation from '../validations/transfers';
5 | import validate from '../helpers/validate';
6 |
7 | const router = express.Router();
8 |
9 | router.put(
10 | '/',
11 | validate(transfersValidation.createNewTransfer),
12 | transfersController.createNewTransfer,
13 | );
14 |
15 | router.post(
16 | '/update',
17 | validate(transfersValidation.findTransferSteps),
18 | transfersController.updateTransferSteps,
19 | );
20 |
21 | router.post(
22 | '/:transactionHash',
23 | validate(transfersValidation.getByTransactionHash),
24 | transfersController.getByTransactionHash,
25 | );
26 |
27 | router.post(
28 | '/',
29 | validate(transfersValidation.findTransferSteps),
30 | transfersController.findTransferSteps,
31 | );
32 |
33 | router.get('/status', transfersController.getMetrics);
34 |
35 | export default router;
36 |
--------------------------------------------------------------------------------
/src/routes/uploads.js:
--------------------------------------------------------------------------------
1 | import express from 'express';
2 |
3 | import uploadsController from '../controllers/uploads';
4 | import uploadFilesMiddleware from '../middlewares/uploads';
5 | import convertImagesMiddleware from '../middlewares/images';
6 | import uploadsValidation from '../validations/uploads';
7 | import validate from '../helpers/validate';
8 |
9 | export const FIELD_NAME = 'files';
10 |
11 | const router = express.Router();
12 |
13 | router.post(
14 | '/avatar',
15 | uploadFilesMiddleware([
16 | {
17 | name: FIELD_NAME,
18 | maxCount: 1,
19 | },
20 | ]),
21 | convertImagesMiddleware([
22 | {
23 | name: FIELD_NAME,
24 | versions: [
25 | {
26 | width: 300,
27 | height: 300,
28 | suffix: null,
29 | },
30 | ],
31 | },
32 | ]),
33 | uploadsController.uploadAvatarImage,
34 | );
35 |
36 | router.delete(
37 | '/avatar',
38 | validate(uploadsValidation.deleteAvatarImage),
39 | uploadsController.deleteAvatarImage,
40 | );
41 |
42 | export default router;
43 |
--------------------------------------------------------------------------------
/src/routes/users.js:
--------------------------------------------------------------------------------
1 | import express from 'express';
2 |
3 | import usersController from '../controllers/users';
4 | import usersValidation from '../validations/users';
5 | import validate from '../helpers/validate';
6 |
7 | const router = express.Router();
8 |
9 | router.post(
10 | '/',
11 | validate(usersValidation.dryRunCreateNewUser),
12 | usersController.dryRunCreateNewUser,
13 | );
14 |
15 | router.put(
16 | '/',
17 | validate(usersValidation.createNewUser),
18 | usersController.createNewUser,
19 | );
20 |
21 | router.get('/', validate(usersValidation.findUsers), usersController.findUsers);
22 |
23 | router.post(
24 | '/:safeAddress/email',
25 | validate(usersValidation.getPrivateUserData),
26 | usersController.getEmail,
27 | );
28 |
29 | router.post(
30 | '/:safeAddress/get-profile-migration-consent',
31 | validate(usersValidation.getPrivateUserData),
32 | usersController.getProfileMigrationConsent,
33 | );
34 |
35 | router.post(
36 | '/:safeAddress/update-profile-migration-consent',
37 | validate(usersValidation.updateProfileMigrationConsent),
38 | usersController.updateProfileMigrationConsent,
39 | );
40 |
41 | router.get(
42 | '/:username',
43 | validate(usersValidation.getByUsername),
44 | usersController.getByUsername,
45 | );
46 |
47 | router.post(
48 | '/:safeAddress',
49 | validate(usersValidation.updateUser),
50 | usersController.updateUser,
51 | );
52 |
53 | router.delete(
54 | '/:safeAddress',
55 | validate(usersValidation.deleteUser),
56 | usersController.deleteUser,
57 | );
58 |
59 | export default router;
60 |
--------------------------------------------------------------------------------
/src/services/aws.js:
--------------------------------------------------------------------------------
1 | import { S3 } from '@aws-sdk/client-s3';
2 |
3 | const REGION = process.env.AWS_REGION || 'fra1';
4 |
5 | const s3 = new S3({
6 | forcePathStyle: false, // Configures to use subdomain/virtual calling format.
7 | region: REGION,
8 | endpoint: 'https://fra1.digitaloceanspaces.com',
9 | credentials: {
10 | accessKeyId: process.env.AWS_ACCESS_KEY_ID,
11 | secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
12 | },
13 | });
14 |
15 | export const AWS_S3_DOMAIN = 'fra1.cdn.digitaloceanspaces.com';
16 |
17 | export { s3 };
18 |
--------------------------------------------------------------------------------
/src/services/core.js:
--------------------------------------------------------------------------------
1 | import CirclesCore from '@circles/core';
2 |
3 | import web3 from './web3';
4 |
5 | const core = new CirclesCore(web3, {
6 | apiServiceEndpoint: process.env.API_SERVICE_ENDPOINT,
7 | fallbackHandlerAddress: process.env.SAFE_DEFAULT_CALLBACK_HANDLER,
8 | graphNodeEndpoint: process.env.GRAPH_NODE_ENDPOINT,
9 | hubAddress: process.env.HUB_ADDRESS,
10 | pathfinderServiceEndpoint: process.env.PATHFINDER_SERVICE_ENDPOINT,
11 | proxyFactoryAddress: process.env.PROXY_FACTORY_ADDRESS,
12 | relayServiceEndpoint: process.env.RELAY_SERVICE_ENDPOINT,
13 | safeMasterAddress: process.env.SAFE_ADDRESS,
14 | subgraphName: process.env.SUBGRAPH_NAME,
15 | });
16 |
17 | export default core;
18 |
--------------------------------------------------------------------------------
/src/services/edgesDatabase.js:
--------------------------------------------------------------------------------
1 | import Edge from '../models/edges';
2 |
3 | export async function upsertEdge(edge) {
4 | if (edge.capacity.toString() === '0') {
5 | return destroyEdge(edge);
6 | } else {
7 | return Edge.upsert(edge, {
8 | where: {
9 | token: edge.token,
10 | from: edge.from,
11 | to: edge.to,
12 | },
13 | });
14 | }
15 | }
16 |
17 | export async function destroyEdge(edge) {
18 | return Edge.destroy({
19 | where: {
20 | token: edge.token,
21 | from: edge.from,
22 | to: edge.to,
23 | },
24 | });
25 | }
26 |
27 | export async function queryEdges(where) {
28 | return await Edge.findAll({
29 | where,
30 | order: [['from', 'ASC']],
31 | raw: true,
32 | });
33 | }
34 |
35 | export async function getStoredEdges({ hasOnlyFileFields = false } = {}) {
36 | return await Edge.findAll({
37 | attributes: hasOnlyFileFields ? ['from', 'to', 'token', 'capacity'] : null,
38 | order: [['from', 'ASC']],
39 | raw: true,
40 | });
41 | }
42 |
--------------------------------------------------------------------------------
/src/services/edgesFile.js:
--------------------------------------------------------------------------------
1 | import path from 'path';
2 | import web3 from './web3';
3 | import fs from 'fs';
4 | import { execSync } from 'child_process';
5 | import copyTo from 'pg-copy-streams/copy-to';
6 |
7 | import db from '../database';
8 | import { EDGES_FILE_PATH, EDGES_DIRECTORY_PATH } from '../constants';
9 |
10 | // Export from PostgresDB to CSV file
11 | async function exportCSV(file) {
12 | return new Promise((resolve, reject) => {
13 | db.connectionManager
14 | .getConnection()
15 | .then((client) => {
16 | const output = client.query(
17 | copyTo(
18 | `COPY edges ("from", "to", "token", "capacity") TO STDOUT WITH (FORMAT CSV)`,
19 | ),
20 | );
21 | let data = '';
22 | const stream = fs.createWriteStream(file);
23 | stream.setDefaultEncoding('utf8');
24 | stream.on('error', (err) => {
25 | db.connectionManager.releaseConnection(client);
26 | reject('Error in DB connection. Error:', err);
27 | });
28 |
29 | stream.on('finish', () => {
30 | db.connectionManager.releaseConnection(client);
31 | resolve(data);
32 | });
33 | output.on('data', (chunk) => (data += chunk)).pipe(stream);
34 | })
35 | .catch((err) => {
36 | reject(err);
37 | });
38 | });
39 | }
40 |
41 | export function checkFileExists() {
42 | return fs.existsSync(EDGES_FILE_PATH);
43 | }
44 |
45 | // Store edges into .csv file for pathfinder
46 | export async function writeToFile(
47 | tmpFileKey = web3.utils.randomHex(16).slice(2),
48 | ) {
49 | // Create temporary file path first
50 | const tmpFilePath = path.join(
51 | EDGES_DIRECTORY_PATH,
52 | `edges-tmp-${tmpFileKey}.csv`,
53 | );
54 | try {
55 | // Check if `edges-data` folder exists and create it otherwise
56 | if (!fs.existsSync(EDGES_DIRECTORY_PATH)) {
57 | fs.mkdirSync(EDGES_DIRECTORY_PATH);
58 | }
59 | // Create empty file
60 | fs.closeSync(fs.openSync(tmpFilePath, 'w'));
61 | await exportCSV(tmpFilePath);
62 | fs.renameSync(tmpFilePath, EDGES_FILE_PATH);
63 | // count lines of final csv
64 | return parseInt(execSync(`wc -l ${EDGES_FILE_PATH} | awk '{ print $1 }'`));
65 | } catch (error) {
66 | try {
67 | fs.unlinkSync(tmpFilePath);
68 | } catch (err) {
69 | throw new Error('Could not delete temporary csv file. Error:' + err);
70 | }
71 | throw new Error('Could not create csv file. Error:' + error);
72 | }
73 | }
74 |
--------------------------------------------------------------------------------
/src/services/edgesFromEvents.js:
--------------------------------------------------------------------------------
1 | import HubContract from '@circles/circles-contracts/build/contracts/Hub.json';
2 |
3 | import EdgeUpdateManager from './edgesUpdate';
4 | import logger from '../helpers/logger';
5 | import web3 from './web3';
6 | import { ZERO_ADDRESS } from '../constants';
7 | import { queryEdges } from './edgesDatabase';
8 | import { requestGraph } from './graph';
9 |
10 | const hubContract = new web3.eth.Contract(
11 | HubContract.abi,
12 | process.env.HUB_ADDRESS,
13 | );
14 |
15 | function addressesFromTopics(topics) {
16 | return [
17 | web3.utils.toChecksumAddress(`0x${topics[1].slice(26)}`),
18 | web3.utils.toChecksumAddress(`0x${topics[2].slice(26)}`),
19 | ];
20 | }
21 |
22 | async function requestSafe(safeAddress) {
23 | const query = `{
24 | safe(id: "${safeAddress.toLowerCase()}") {
25 | outgoing {
26 | canSendToAddress
27 | userAddress
28 | }
29 | incoming {
30 | canSendToAddress
31 | userAddress
32 | }
33 | }
34 | }`;
35 |
36 | const data = await requestGraph(query);
37 |
38 | if (!data || !('safe' in data)) {
39 | throw new Error(`Could not fetch graph data for Safe ${safeAddress}`);
40 | }
41 |
42 | return data.safe;
43 | }
44 |
45 | async function updateAllWhoTrustToken(
46 | { tokenOwner, tokenAddress, address, tokenOwnerData },
47 | edgeUpdateManager,
48 | ) {
49 | logger.info(
50 | `Found ${tokenOwnerData.outgoing.length} outgoing addresses while processing job for Safe ${tokenOwner}`,
51 | );
52 |
53 | await Promise.all(
54 | tokenOwnerData.outgoing.map(async (trustObject) => {
55 | const canSendToAddress = web3.utils.toChecksumAddress(
56 | trustObject.canSendToAddress,
57 | );
58 |
59 | // address -> canSendToAddress
60 | await edgeUpdateManager.updateEdge(
61 | {
62 | token: tokenOwner,
63 | from: address,
64 | to: canSendToAddress,
65 | },
66 | tokenAddress,
67 | );
68 | }),
69 | );
70 | }
71 |
72 | export async function processTransferEvent(data) {
73 | const edgeUpdateManager = new EdgeUpdateManager();
74 | const [sender, recipient] = addressesFromTopics(data.topics);
75 |
76 | const tokenAddress = web3.utils.toChecksumAddress(data.tokenAddress);
77 | logger.info(`Processing transfer for ${tokenAddress}`);
78 |
79 | const tokenOwner = await hubContract.methods.tokenToUser(tokenAddress).call();
80 | if (tokenOwner === ZERO_ADDRESS) {
81 | logger.info(`${tokenAddress} is not a Circles token`);
82 | return false;
83 | }
84 |
85 | // a) Update the edge between the `recipient` safe and the `tokenOwner` safe.
86 | // The limit will increase here as the `recipient` will get tokens the
87 | // `tokenOwner` accepts. This update will be ignored if the `tokenOwner` is
88 | // also the `recipient`, if the recipient is the relayer, or if the recipient
89 | // is the zero address
90 | await edgeUpdateManager.updateEdge(
91 | {
92 | token: tokenOwner,
93 | from: recipient,
94 | to: tokenOwner,
95 | },
96 | tokenAddress,
97 | );
98 |
99 | // b) Update the edge between the `sender` safe and the `tokenOwner` safe.
100 | // The limit will decrease here as the `sender` will lose tokens the
101 | // `tokenOwner` accepts. This update will be ignored if the `tokenOwner` is
102 | // also the `sender`, or if the sender is the zero address
103 | await edgeUpdateManager.updateEdge(
104 | {
105 | token: tokenOwner,
106 | from: sender,
107 | to: tokenOwner,
108 | },
109 | tokenAddress,
110 | );
111 |
112 | // Get more information from the graph about the current trust connections of
113 | // `tokenOwner`
114 | let tokenOwnerData;
115 | try {
116 | tokenOwnerData = await requestSafe(tokenOwner);
117 | } catch (err) {
118 | logger.error(`Safe ${tokenOwner} for job is not registered in graph`);
119 | logger.error(err);
120 | return;
121 | }
122 |
123 | // c) Go through everyone who trusts this token, and update the limit from
124 | // the `recipient` to them. The recipient's capacity increases for this token
125 | // to everyone who trusts the tokenOwner
126 | await updateAllWhoTrustToken(
127 | {
128 | address: recipient,
129 | tokenAddress,
130 | tokenOwner,
131 | tokenOwnerData,
132 | },
133 | edgeUpdateManager,
134 | );
135 |
136 | // d) Go through everyone who trusts this token, and update the limit from
137 | // the `sender` to them. The sender's capacity decreases for this token to
138 | // everyone who trusts the tokenOwner
139 | await updateAllWhoTrustToken(
140 | {
141 | address: sender,
142 | tokenAddress,
143 | tokenOwner,
144 | tokenOwnerData,
145 | },
146 | edgeUpdateManager,
147 | );
148 |
149 | // e) If someone is sending or receiving their own token, the balance of their own
150 | // token has changed, and therefore the trust limits for all the tokens they accept
151 | // must be updated
152 | if (sender === tokenOwner || recipient === tokenOwner) {
153 | await Promise.all(
154 | tokenOwnerData.incoming.map(async (trustObject) => {
155 | const userTokenAddress = await hubContract.methods
156 | .userToToken(trustObject.userAddress)
157 | .call();
158 |
159 | if (tokenAddress === ZERO_ADDRESS) {
160 | logger.info(`${sender} is not a Circles user`);
161 | return;
162 | }
163 |
164 | const user = web3.utils.toChecksumAddress(trustObject.userAddress);
165 | return edgeUpdateManager.updateEdge(
166 | {
167 | token: user,
168 | from: user,
169 | to: tokenOwner,
170 | },
171 | userTokenAddress,
172 | );
173 | }),
174 | );
175 | }
176 |
177 | return true;
178 | }
179 |
180 | export async function processTrustEvent(data) {
181 | const edgeUpdateManager = new EdgeUpdateManager();
182 | const [truster, tokenOwner] = addressesFromTopics(data.topics);
183 |
184 | logger.info(`Processing trust for ${truster}`);
185 |
186 | const tokenAddress = await hubContract.methods.userToToken(tokenOwner).call();
187 | if (tokenAddress === ZERO_ADDRESS) {
188 | logger.info(`${tokenOwner} is not a Circles user`);
189 | return false;
190 | }
191 |
192 | // a) Update the edge between `tokenOwner` and the `truster`, as the latter
193 | // accepts their token now
194 | await edgeUpdateManager.updateEdge(
195 | {
196 | token: tokenOwner,
197 | from: tokenOwner,
198 | to: truster,
199 | },
200 | tokenAddress,
201 | );
202 |
203 | // b) Go through everyone else who holds this token, and update the path
204 | // from the `truster` to them as well, as they can send this token to the
205 | // `truster`
206 | const tokenholders = await queryEdges({ to: tokenOwner, token: tokenOwner });
207 | await Promise.all(
208 | tokenholders.map(async (edge) => {
209 | await edgeUpdateManager.updateEdge(
210 | {
211 | token: tokenOwner,
212 | from: edge.from,
213 | to: truster,
214 | },
215 | tokenAddress,
216 | );
217 | }),
218 | );
219 |
220 | return true;
221 | }
222 |
--------------------------------------------------------------------------------
/src/services/edgesFromGraph.js:
--------------------------------------------------------------------------------
1 | import HubContract from '@circles/circles-contracts/build/contracts/Hub.json';
2 | import TokenContract from '@circles/circles-contracts/build/contracts/Token.json';
3 | import fastJsonStringify from 'fast-json-stringify';
4 | import findTransferSteps from '@circles/transfer';
5 | import fs from 'fs';
6 | import { performance } from 'perf_hooks';
7 |
8 | import Edge from '../models/edges';
9 | import logger from '../helpers/logger';
10 | import fetchAllFromGraph from './graph';
11 | import web3 from './web3';
12 | import { getMetrics, setMetrics } from './metrics';
13 | import { minNumberString } from '../helpers/compare';
14 | import {
15 | EDGES_FILE_PATH,
16 | EDGES_TMP_FILE_PATH,
17 | HOPS_DEFAULT,
18 | PATHFINDER_FILE_PATH,
19 | } from '../constants';
20 |
21 | const METRICS_TRANSFERS = 'transfers';
22 |
23 | const DEFAULT_PROCESS_TIMEOUT = 1000 * 10;
24 |
25 | const hubContract = new web3.eth.Contract(
26 | HubContract.abi,
27 | process.env.HUB_ADDRESS,
28 | );
29 |
30 | const stringify = fastJsonStringify({
31 | title: 'Circles Edges Schema',
32 | type: 'array',
33 | properties: {
34 | from: {
35 | type: 'string',
36 | },
37 | to: {
38 | type: 'string',
39 | },
40 | token: {
41 | type: 'string',
42 | },
43 | capacity: {
44 | type: 'string',
45 | },
46 | },
47 | });
48 |
49 | const findToken = (tokens, tokenAddress) => {
50 | return tokens.find((token) => token.address === tokenAddress);
51 | };
52 |
53 | const findSafe = (safes, safeAddress) => {
54 | return safes.find((safe) => safe.address === safeAddress);
55 | };
56 |
57 | const findConnection = (connections, userAddress, canSendToAddress) => {
58 | return connections.find((edge) => {
59 | return (
60 | edge.canSendToAddress === canSendToAddress &&
61 | edge.userAddress === userAddress
62 | );
63 | });
64 | };
65 |
66 | export const safeQuery = `{
67 | canSendToAddress
68 | userAddress
69 | }`;
70 |
71 | export const safeFields = `
72 | id
73 | outgoing ${safeQuery}
74 | incoming ${safeQuery}
75 | balances {
76 | token {
77 | id
78 | owner {
79 | id
80 | }
81 | }
82 | }
83 | `;
84 |
85 | export async function getTrustNetworkEdges() {
86 | // Methods to parse the data we get to break all down into given safe
87 | // addresses, the tokens they own, the trust connections they have between
88 | // each other and finally a list of all tokens.
89 | const connections = [];
90 | const safes = [];
91 | const tokens = [];
92 |
93 | const addConnection = (userAddress, canSendToAddress) => {
94 | connections.push({
95 | canSendToAddress,
96 | userAddress,
97 | isExtended: false,
98 | });
99 | };
100 |
101 | const addConnections = (newConnections) => {
102 | newConnections.forEach((connection) => {
103 | const userAddress = web3.utils.toChecksumAddress(connection.userAddress);
104 | const canSendToAddress = web3.utils.toChecksumAddress(
105 | connection.canSendToAddress,
106 | );
107 |
108 | if (!findConnection(connections, userAddress, canSendToAddress)) {
109 | addConnection(userAddress, canSendToAddress);
110 | }
111 | });
112 | };
113 |
114 | const addToken = (address, safeAddress) => {
115 | tokens.push({
116 | address,
117 | safeAddress,
118 | });
119 | };
120 |
121 | const addSafe = (safeAddress, balances) => {
122 | const safe = balances.reduce(
123 | (acc, { token }) => {
124 | const tokenAddress = web3.utils.toChecksumAddress(token.id);
125 | const tokenSafeAddress = web3.utils.toChecksumAddress(token.owner.id);
126 |
127 | acc.tokens.push({
128 | address: tokenAddress,
129 | });
130 |
131 | if (!findToken(tokens, tokenAddress)) {
132 | addToken(tokenAddress, tokenSafeAddress);
133 | }
134 |
135 | return acc;
136 | },
137 | {
138 | address: web3.utils.toChecksumAddress(safeAddress),
139 | tokens: [],
140 | },
141 | );
142 |
143 | safes.push(safe);
144 | };
145 |
146 | const response = await fetchAllFromGraph('safes', safeFields);
147 |
148 | response.forEach((safe) => {
149 | if (!findSafe(safes, safe.id)) {
150 | addSafe(safe.id, safe.balances);
151 |
152 | addConnections(safe.outgoing);
153 | addConnections(safe.incoming);
154 | }
155 | });
156 |
157 | return {
158 | statistics: {
159 | safes: safes.length,
160 | connections: connections.length,
161 | tokens: tokens.length,
162 | },
163 | edges: findEdgesInGraphData({
164 | connections,
165 | safes,
166 | tokens,
167 | }),
168 | };
169 | }
170 |
171 | export function findEdgesInGraphData({ connections, safes, tokens }) {
172 | const edges = [];
173 |
174 | // Find tokens for each connection we can actually use for transitive
175 | // transactions
176 | const checkedEdges = {};
177 |
178 | const getKey = (from, to, token) => {
179 | return [from, to, token].join('');
180 | };
181 |
182 | const addEdge = ({ from, to, tokenAddress, tokenOwner }) => {
183 | // Ignore sending to ourselves
184 | if (from === to) {
185 | return;
186 | }
187 |
188 | // Ignore duplicates
189 | const key = getKey(from, to, tokenOwner);
190 | if (checkedEdges[key]) {
191 | return;
192 | }
193 | checkedEdges[key] = true;
194 |
195 | edges.push({
196 | from,
197 | to,
198 | tokenAddress,
199 | tokenOwner,
200 | });
201 | };
202 |
203 | connections.forEach((connection) => {
204 | const senderSafeAddress = connection.userAddress;
205 | const receiverSafeAddress = connection.canSendToAddress;
206 |
207 | // Get the senders Safe
208 | const senderSafe = findSafe(safes, senderSafeAddress);
209 |
210 | if (!senderSafe) {
211 | return;
212 | }
213 |
214 | // Get tokens the sender owns
215 | const senderTokens = senderSafe.tokens;
216 |
217 | // Which of them are trusted by the receiving node?
218 | const trustedTokens = senderTokens.reduce(
219 | (tokenAcc, { address, balance }) => {
220 | const token = findToken(tokens, address);
221 |
222 | const tokenConnection = connections.find(
223 | ({ limit, userAddress, canSendToAddress }) => {
224 | if (!limit) {
225 | return false;
226 | }
227 |
228 | // Calculate what maximum token value we can send. We use this
229 | // special string comparison method as using BN instances affects
230 | // performance significantly
231 | const capacity = minNumberString(limit, balance);
232 | return (
233 | userAddress === token.safeAddress &&
234 | canSendToAddress === receiverSafeAddress &&
235 | capacity !== '0'
236 | );
237 | },
238 | );
239 |
240 | if (tokenConnection) {
241 | tokenAcc.push({
242 | tokenAddress: token.address,
243 | tokenOwner: token.safeAddress,
244 | });
245 | }
246 | return tokenAcc;
247 | },
248 | [],
249 | );
250 | // Merge all known data to get a list in the end containing what Token can
251 | // be sent to whom with what maximum value.
252 | trustedTokens.reduce((acc, trustedToken) => {
253 | // Ignore sending to ourselves
254 | if (senderSafeAddress === receiverSafeAddress) {
255 | return;
256 | }
257 |
258 | // Ignore duplicates
259 | const key = getKey(
260 | senderSafeAddress,
261 | receiverSafeAddress,
262 | trustedToken.token,
263 | );
264 |
265 | if (checkedEdges[key]) {
266 | return;
267 | }
268 |
269 | checkedEdges[key] = true;
270 |
271 | acc.push({
272 | from: senderSafeAddress,
273 | to: receiverSafeAddress,
274 | tokenAddress: trustedToken.tokenAddress,
275 | tokenOwner: trustedToken.tokenOwner,
276 | });
277 |
278 | return acc;
279 | }, []);
280 | });
281 |
282 | // Add connections between token owners and the original safe of the token as
283 | // they might not be represented by trust connections (for example when an
284 | // organization owns tokens it can still send them even though noone trusts
285 | // the organization)
286 | safes.forEach(({ address, tokens: ownedTokens }) => {
287 | ownedTokens.forEach(({ address: tokenAddress }) => {
288 | const token = findToken(tokens, tokenAddress);
289 |
290 | connections.forEach((connection) => {
291 | if (connection.userAddress === token.safeAddress) {
292 | addEdge({
293 | from: address,
294 | to: connection.canSendToAddress,
295 | tokenAddress,
296 | tokenOwner: token.safeAddress,
297 | });
298 | }
299 | });
300 | });
301 | });
302 |
303 | return edges;
304 | }
305 |
306 | export async function upsert(edge) {
307 | if (edge.capacity.toString() === '0') {
308 | return Edge.destroy({
309 | where: {
310 | token: edge.token,
311 | from: edge.from,
312 | to: edge.to,
313 | },
314 | });
315 | } else {
316 | return Edge.upsert(edge, {
317 | where: {
318 | token: edge.token,
319 | from: edge.from,
320 | to: edge.to,
321 | },
322 | });
323 | }
324 | }
325 |
326 | export async function updateEdge(edge, tokenAddress) {
327 | // Ignore self-trust
328 | if (edge.from === edge.to) {
329 | return;
330 | }
331 |
332 | try {
333 | // Get send limit
334 | const limit = await hubContract.methods
335 | .checkSendLimit(edge.token, edge.from, edge.to)
336 | .call();
337 |
338 | // Get Token balance
339 | const tokenContract = new web3.eth.Contract(
340 | TokenContract.abi,
341 | tokenAddress,
342 | );
343 | const balance = await tokenContract.methods.balanceOf(edge.from).call();
344 |
345 | // Update edge capacity
346 | edge.capacity = minNumberString(limit, balance);
347 |
348 | await upsert(edge);
349 | } catch (error) {
350 | logger.error(
351 | `Found error with checking sending limit for token of ${edge.token} from ${edge.from} to ${edge.to} [${error}]`,
352 | );
353 |
354 | await Edge.destroy({
355 | where: {
356 | token: edge.token,
357 | from: edge.from,
358 | to: edge.to,
359 | },
360 | });
361 | }
362 | }
363 |
364 | export async function setTransferMetrics(metrics) {
365 | return await setMetrics(METRICS_TRANSFERS, metrics);
366 | }
367 |
368 | export async function getTransferMetrics() {
369 | return await getMetrics(METRICS_TRANSFERS);
370 | }
371 |
372 | export async function getStoredEdges(isWithAttributes = false) {
373 | return await Edge.findAll({
374 | attributes: isWithAttributes ? ['from', 'to', 'token', 'capacity'] : null,
375 | order: [['from', 'ASC']],
376 | raw: true,
377 | });
378 | }
379 |
380 | export function checkFileExists() {
381 | return fs.existsSync(EDGES_FILE_PATH);
382 | }
383 |
384 | // Store edges into .json file for pathfinder executable
385 | export async function writeToFile(edges) {
386 | return new Promise((resolve, reject) => {
387 | fs.writeFile(EDGES_TMP_FILE_PATH, stringify(edges), (error) => {
388 | if (error) {
389 | reject(
390 | new Error(`Could not write to ${EDGES_TMP_FILE_PATH} file: ${error}`),
391 | );
392 | } else {
393 | fs.renameSync(EDGES_TMP_FILE_PATH, EDGES_FILE_PATH);
394 | resolve();
395 | }
396 | });
397 | });
398 | }
399 |
400 | export async function transferSteps({ from, to, value, hops = HOPS_DEFAULT }) {
401 | if (from === to) {
402 | throw new Error('Can not send to yourself');
403 | }
404 |
405 | const startTime = performance.now();
406 |
407 | const result = await findTransferSteps(
408 | {
409 | from,
410 | to,
411 | value,
412 | hops: hops.toString(),
413 | },
414 | {
415 | edgesFile: EDGES_FILE_PATH,
416 | pathfinderExecutable: PATHFINDER_FILE_PATH,
417 | timeout: process.env.TRANSFER_STEPS_TIMEOUT || DEFAULT_PROCESS_TIMEOUT,
418 | },
419 | );
420 |
421 | const endTime = performance.now();
422 |
423 | return {
424 | from,
425 | to,
426 | maxFlowValue: result.maxFlowValue,
427 | processDuration: Math.round(endTime - startTime),
428 | transferValue: value,
429 | transferSteps: result.transferSteps.map(({ token, ...step }) => {
430 | return {
431 | ...step,
432 | tokenOwnerAddress: token,
433 | };
434 | }),
435 | };
436 | }
437 |
--------------------------------------------------------------------------------
/src/services/edgesUpdate.js:
--------------------------------------------------------------------------------
1 | import HubContract from '@circles/circles-contracts/build/contracts/Hub.json';
2 | import TokenContract from '@circles/circles-contracts/build/contracts/Token.json';
3 |
4 | import logger from '../helpers/logger';
5 | import web3 from './web3';
6 |
7 | import { minNumberString } from '../helpers/compare';
8 | import { upsertEdge } from './edgesDatabase';
9 | import { ZERO_ADDRESS } from '../constants';
10 |
11 | const hubContract = new web3.eth.Contract(
12 | HubContract.abi,
13 | process.env.HUB_ADDRESS,
14 | );
15 |
16 | const getKey = (from, to, token) => {
17 | return [from, to, token].join('');
18 | };
19 |
20 | export default class EdgeUpdateManager {
21 | constructor() {
22 | this.checkedEdges = {};
23 | }
24 |
25 | checkDuplicate(edge) {
26 | const key = getKey(edge.from, edge.to, edge.token);
27 | if (key in this.checkedEdges) {
28 | return true;
29 | }
30 | this.checkedEdges[key] = true;
31 | return false;
32 | }
33 |
34 | async updateEdge(edge, tokenAddress) {
35 | // Don't store edges from relayer
36 | if (edge.from === process.env.TX_SENDER_ADDRESS) {
37 | return;
38 | }
39 |
40 | // Don't store edges to or from zero address
41 | if (edge.to === ZERO_ADDRESS || edge.from === ZERO_ADDRESS) {
42 | return;
43 | }
44 |
45 | // Ignore self-referential edges
46 | if (edge.from === edge.to) {
47 | return;
48 | }
49 |
50 | // Ignore duplicates
51 | if (this.checkDuplicate(edge)) {
52 | return;
53 | }
54 |
55 | // Update edge capacity
56 | try {
57 | // Get send limit
58 | const limit = await hubContract.methods
59 | .checkSendLimit(edge.token, edge.from, edge.to)
60 | .call();
61 |
62 | // Get Token balance
63 | const tokenContract = new web3.eth.Contract(
64 | TokenContract.abi,
65 | tokenAddress,
66 | );
67 | const balance = await tokenContract.methods.balanceOf(edge.from).call();
68 |
69 | // Update edge capacity
70 | edge.capacity = minNumberString(limit, balance);
71 |
72 | await upsertEdge(edge);
73 | } catch (error) {
74 | logger.error(
75 | `Found error with checking sending limit for token of ${edge.token} from ${edge.from} to ${edge.to} [${error}]`,
76 | );
77 | }
78 | }
79 | }
80 |
--------------------------------------------------------------------------------
/src/services/findTransferSteps.js:
--------------------------------------------------------------------------------
1 | import findTransferSteps from '@circles/transfer';
2 | import { performance } from 'perf_hooks';
3 |
4 | import {
5 | EDGES_FILE_PATH,
6 | HOPS_DEFAULT,
7 | PATHFINDER_FILE_PATH,
8 | } from '../constants';
9 |
10 | const DEFAULT_PROCESS_TIMEOUT = 1000 * 200;
11 | const FLAG = '--csv';
12 |
13 | export default async function transferSteps({
14 | from,
15 | to,
16 | value,
17 | hops = HOPS_DEFAULT,
18 | }) {
19 | if (from === to) {
20 | throw new Error('Cannot send to yourself');
21 | }
22 | const startTime = performance.now();
23 |
24 | const timeout = process.env.TRANSFER_STEPS_TIMEOUT
25 | ? parseInt(process.env.TRANSFER_STEPS_TIMEOUT, 10)
26 | : DEFAULT_PROCESS_TIMEOUT;
27 |
28 | const result = await findTransferSteps(
29 | {
30 | from,
31 | to,
32 | value,
33 | hops: hops.toString(),
34 | },
35 | {
36 | edgesFile: EDGES_FILE_PATH,
37 | pathfinderExecutable: PATHFINDER_FILE_PATH,
38 | flag: FLAG,
39 | timeout,
40 | },
41 | );
42 |
43 | const endTime = performance.now();
44 |
45 | return {
46 | from,
47 | to,
48 | maxFlowValue: result.maxFlowValue,
49 | processDuration: Math.round(endTime - startTime),
50 | transferValue: value,
51 | transferSteps: result.transferSteps.map(({ token, ...step }) => {
52 | return {
53 | ...step,
54 | tokenOwnerAddress: token,
55 | };
56 | }),
57 | };
58 | }
59 |
--------------------------------------------------------------------------------
/src/services/graph.js:
--------------------------------------------------------------------------------
1 | import fetch from 'isomorphic-fetch';
2 |
3 | import logger from '../helpers/logger';
4 | import loop from '../helpers/loop';
5 | import core from './core';
6 |
7 | const PAGINATION_SIZE = 500;
8 |
9 | function isOfficialNode() {
10 | return process.env.GRAPH_NODE_ENDPOINT.includes('api.thegraph.com');
11 | }
12 |
13 | async function fetchFromGraphStatus(query) {
14 | const endpoint = isOfficialNode()
15 | ? `${process.env.GRAPH_NODE_ENDPOINT}/index-node/graphql`
16 | : `${process.env.GRAPH_NODE_INDEXING_STATUS_ENDPOINT}/graphql`;
17 | return await fetch(endpoint, {
18 | method: 'POST',
19 | headers: {
20 | 'Content-Type': 'application/json',
21 | },
22 | body: JSON.stringify({
23 | query: query.replace(/\s\s+/g, ' '),
24 | }),
25 | })
26 | .then((response) => {
27 | return response.json();
28 | })
29 | .then((response) => {
30 | return response.data;
31 | });
32 | }
33 |
34 | // This function aims to replace `fetchFromGraphStatus()` when `index-node`
35 | // requests don't work for thegraph.com/hosted-service
36 | async function fetchFromSubgraphStatus(query) {
37 | const endpoint = `${process.env.GRAPH_NODE_ENDPOINT}/subgraphs/name/${process.env.SUBGRAPH_NAME}`;
38 | logger.info(`Graph endpoint: ${endpoint}`);
39 | return await fetch(endpoint, {
40 | method: 'POST',
41 | headers: {
42 | 'Content-Type': 'application/json',
43 | },
44 | body: JSON.stringify({
45 | query: query.replace(/\s\s+/g, ' '),
46 | }),
47 | })
48 | .then((response) => {
49 | return response.json();
50 | })
51 | .then((response) => {
52 | return response.data;
53 | });
54 | }
55 |
56 | async function wait(ms) {
57 | return new Promise((resolve) => {
58 | setTimeout(resolve, ms);
59 | });
60 | }
61 |
62 | export async function requestGraph(query) {
63 | // Strip newlines in query before doing request
64 | return await core.utils.requestGraph({
65 | query: query.replace(/(\r\n|\n|\r)/gm, ' '),
66 | });
67 | }
68 |
69 | export async function fetchFromGraph(
70 | name,
71 | fields,
72 | extra = '',
73 | lastID = '',
74 | first = PAGINATION_SIZE,
75 | ) {
76 | const query = `{
77 | ${name}(${extra} first: ${first}, orderBy: id, where: { id_gt: "${lastID}"}) {
78 | ${fields}
79 | }
80 | }`;
81 | const data = await requestGraph(query);
82 | if (!data) {
83 | logger.error(`Error requesting graph with query: ${query}`);
84 | return false;
85 | }
86 | return data[name];
87 | }
88 |
89 | async function* fetchGraphGenerator(name, fields, extra = '') {
90 | // The `skip` argument must be between 0 and 5000 (current limitations by TheGraph).
91 | // Therefore, we sort the elements by id and reference the last element id for the next query
92 | let hasData = true;
93 | let lastID = '';
94 |
95 | while (hasData) {
96 | //console.log({lastID});
97 | const data = await fetchFromGraph(name, fields, extra, lastID);
98 | await wait(500);
99 | hasData = data.length > 0;
100 | if (hasData) lastID = data[data.length - 1].id;
101 | yield data;
102 | }
103 | }
104 |
105 | export default async function fetchAllFromGraph(name, fields, extra = '') {
106 | let result = [];
107 | let index = 0;
108 |
109 | for await (let data of fetchGraphGenerator(name, fields, extra)) {
110 | result = result.concat(
111 | data.map((entry) => {
112 | entry.index = ++index;
113 | return entry;
114 | }),
115 | );
116 | }
117 |
118 | return result;
119 | }
120 |
121 | export async function waitUntilGraphIsReady() {
122 | const query = `{ _meta { block { number } } }`;
123 | return await loop(
124 | async () => {
125 | try {
126 | return await fetchFromSubgraphStatus(query);
127 | } catch {
128 | return false;
129 | }
130 | },
131 | (isHealthy) => {
132 | return isHealthy;
133 | },
134 | );
135 | }
136 |
137 | export async function waitForBlockNumber(blockNumber) {
138 | const query = `{
139 | indexingStatusForCurrentVersion(subgraphName: "${process.env.SUBGRAPH_NAME}") {
140 | chains {
141 | latestBlock {
142 | number
143 | }
144 | }
145 | }
146 | }`;
147 |
148 | await loop(
149 | () => {
150 | return fetchFromGraphStatus(query);
151 | },
152 | (data) => {
153 | const { chains } = data.indexingStatusForCurrentVersion;
154 | if (chains.length === 0) {
155 | return false;
156 | }
157 | return parseInt(chains[0].latestBlock.number, 10) >= blockNumber;
158 | },
159 | );
160 | }
161 |
162 | export async function getBlockNumber() {
163 | const query = `{
164 | indexingStatusForCurrentVersion(subgraphName: "${process.env.SUBGRAPH_NAME}") {
165 | chains {
166 | latestBlock {
167 | number
168 | }
169 | }
170 | }
171 | }`;
172 |
173 | const data = await fetchFromGraphStatus(query);
174 | const { chains } = data.indexingStatusForCurrentVersion;
175 | if (chains.length === 0) {
176 | return 0;
177 | }
178 | return parseInt(chains[0].latestBlock.number, 10);
179 | }
180 |
--------------------------------------------------------------------------------
/src/services/metrics.js:
--------------------------------------------------------------------------------
1 | import Metric from '../models/metrics';
2 |
3 | export async function setMetrics(category, metrics = []) {
4 | const promises = metrics.map(({ name, value }) => {
5 | return Metric.update(
6 | { value },
7 | {
8 | where: {
9 | category,
10 | name,
11 | },
12 | },
13 | );
14 | });
15 |
16 | return Promise.all(promises);
17 | }
18 |
19 | export async function getMetrics(category) {
20 | return Metric.findAll({
21 | where: {
22 | category,
23 | },
24 | }).then((response) => {
25 | return response.reduce((acc, item) => {
26 | acc[item.name] = parseInt(item.value, 10);
27 | return acc;
28 | }, {});
29 | });
30 | }
31 |
--------------------------------------------------------------------------------
/src/services/redis.js:
--------------------------------------------------------------------------------
1 | export const redisUrl = process.env.REDIS_URL || 'redis://127.0.0.1:6379';
2 |
3 | export const redisOptions = {
4 | settings: {
5 | lockDuration: 1000 * 30, // Key expiration time for job locks
6 | stalledInterval: 1000 * 30, // How often check for stalled jobs (use 0 for never checking)
7 | maxStalledCount: 1, // Max amount of times a stalled job will be re-processed
8 | guardInterval: 1000 * 5, // Poll interval for delayed jobs and added jobs
9 | retryProcessDelay: 1000 * 5, // delay before processing next job in case of internal error
10 | },
11 | };
12 |
13 | export const redisLongRunningOptions = {
14 | settings: {
15 | lockDuration: 1000 * 60 * 30,
16 | lockRenewTime: 1000 * 15,
17 | stalledInterval: 1000 * 60 * 1,
18 | maxStalledCount: 2,
19 | guardInterval: 1000 * 10,
20 | retryProcessDelay: 1000 * 15,
21 | },
22 | };
23 |
--------------------------------------------------------------------------------
/src/services/updateTransferSteps.js:
--------------------------------------------------------------------------------
1 | import HubContract from '@circles/circles-contracts/build/contracts/Hub.json';
2 | import findTransferSteps from '@circles/transfer';
3 |
4 | import web3 from './web3';
5 | import EdgeUpdateManager from './edgesUpdate';
6 | import logger from '../helpers/logger';
7 | import tasks from '../tasks';
8 | import submitJob from '../tasks/submitJob';
9 | import {
10 | EDGES_FILE_PATH,
11 | HOPS_DEFAULT,
12 | PATHFINDER_FILE_PATH,
13 | } from '../constants';
14 |
15 | const DEFAULT_PROCESS_TIMEOUT = 1000 * 200;
16 | const FLAG = '--csv';
17 |
18 | const hubContract = new web3.eth.Contract(
19 | HubContract.abi,
20 | process.env.HUB_ADDRESS,
21 | );
22 |
23 | // All the steps are updated
24 | async function updateSteps(result) {
25 | const edgeUpdateManager = new EdgeUpdateManager();
26 |
27 | const values = await Promise.allSettled(
28 | result.transferSteps.map(async (step) => {
29 | const tokenAddress = await hubContract.methods
30 | .userToToken(step.token)
31 | .call();
32 |
33 | // Update the edge
34 | await edgeUpdateManager.updateEdge(
35 | {
36 | token: step.token,
37 | from: step.from,
38 | to: step.to,
39 | },
40 | tokenAddress,
41 | );
42 |
43 | return true;
44 | }),
45 | );
46 |
47 | // Write edges.csv file to update edges
48 | submitJob(tasks.exportEdges, 'exportEdges-updateSteps');
49 |
50 | return values.every((step) => step.status === 'fulfilled');
51 | }
52 |
53 | export default async function updatePath({
54 | from,
55 | to,
56 | value,
57 | hops = HOPS_DEFAULT,
58 | }) {
59 | const timeout = process.env.TRANSFER_STEPS_TIMEOUT
60 | ? parseInt(process.env.TRANSFER_STEPS_TIMEOUT, 10)
61 | : DEFAULT_PROCESS_TIMEOUT;
62 |
63 | try {
64 | return {
65 | updated: await updateSteps(
66 | await findTransferSteps(
67 | {
68 | from,
69 | to,
70 | value,
71 | hops: hops.toString(),
72 | },
73 | {
74 | edgesFile: EDGES_FILE_PATH,
75 | pathfinderExecutable: PATHFINDER_FILE_PATH,
76 | flag: FLAG,
77 | timeout,
78 | },
79 | ),
80 | ),
81 | };
82 | } catch (error) {
83 | logger.error(`Error updating steps [${error.message}]`);
84 | throw error;
85 | }
86 | }
87 |
--------------------------------------------------------------------------------
/src/services/web3.js:
--------------------------------------------------------------------------------
1 | import Web3 from 'web3';
2 | import HttpProvider from 'web3-providers-http';
3 |
4 | var options = {
5 | timeout: 30000, // ms
6 | keepAlive: true,
7 | };
8 |
9 | const web3 = new Web3(
10 | new HttpProvider(process.env.ETHEREUM_NODE_ENDPOINT, options),
11 | );
12 |
13 | export default web3;
14 |
--------------------------------------------------------------------------------
/src/services/web3Ws.js:
--------------------------------------------------------------------------------
1 | import Web3 from 'web3';
2 | import Web3WsProvider from 'web3-providers-ws';
3 |
4 | import web3 from './web3';
5 | import logger from '../helpers/logger';
6 |
7 | var options = {
8 | timeout: 30000, // ms
9 |
10 | clientConfig: {
11 | // Useful to keep a connection alive
12 | keepalive: true,
13 | keepaliveInterval: 60000, // ms
14 | },
15 |
16 | // Enable auto reconnection
17 | reconnect: {
18 | auto: true,
19 | delay: 5000, // ms
20 | maxAttempts: 5,
21 | onTimeout: false,
22 | },
23 | };
24 |
25 | const web3Ws = new Web3(
26 | new Web3WsProvider(process.env.ETHEREUM_NODE_WS, options),
27 | );
28 |
29 | export async function checkConnection() {
30 | return (await web3.eth.getBlock('latest')).number;
31 | }
32 |
33 | export function getEventSignature(contract, eventName) {
34 | const { signature } = contract._jsonInterface.find((item) => {
35 | return item.name === eventName && item.type === 'event';
36 | });
37 | return signature;
38 | }
39 |
40 | export function subscribeEvent(contract, address, eventName, callbackFn) {
41 | const handleCallback = (error, result) => {
42 | if (error) {
43 | logger.error(`Web3 subscription error: ${error}`);
44 | // Subscribe again with same parameters when disconnected
45 | subscription.subscribe(handleCallback);
46 | } else {
47 | callbackFn(result);
48 | }
49 | };
50 |
51 | const subscription = web3Ws.eth.subscribe(
52 | 'logs',
53 | {
54 | address,
55 | topics: [getEventSignature(contract, eventName)],
56 | },
57 | handleCallback,
58 | );
59 | }
60 |
--------------------------------------------------------------------------------
/src/tasks/cleanup.js:
--------------------------------------------------------------------------------
1 | import Queue from 'bull';
2 |
3 | import processor from './processor';
4 | import { allTasks } from './';
5 | import { redisUrl, redisLongRunningOptions } from '../services/redis';
6 |
7 | const cleanup = new Queue('Clean up queues', redisUrl, {
8 | settings: redisLongRunningOptions,
9 | });
10 |
11 | processor(cleanup).process(async () => {
12 | return await Promise.all([
13 | cleanup.clean(0),
14 | ...allTasks.map((queue) => queue.clean(1000, 'completed')),
15 | ]);
16 | });
17 |
18 | export default cleanup;
19 |
--------------------------------------------------------------------------------
/src/tasks/exportEdges.js:
--------------------------------------------------------------------------------
1 | import Queue from 'bull';
2 | import { performance } from 'perf_hooks';
3 |
4 | import logger from '../helpers/logger';
5 | import processor from './processor';
6 | import { redisUrl, redisOptions } from '../services/redis';
7 | import { writeToFile } from '../services/edgesFile';
8 |
9 | const exportEdges = new Queue('Export edges to csv', redisUrl, {
10 | settings: redisOptions,
11 | });
12 |
13 | processor(exportEdges).process(async () => {
14 | // Measure time of the whole process
15 | const startTime = performance.now();
16 |
17 | try {
18 | // Write edges.csv
19 | const lines = await writeToFile();
20 |
21 | // End time
22 | const endTime = performance.now();
23 | const milliseconds = Math.round(endTime - startTime);
24 |
25 | // Show metrics
26 | logger.info(`Written ${lines} lines edges.csv in ${milliseconds}ms`);
27 |
28 | return Promise.resolve();
29 | } catch (error) {
30 | logger.error(`Export edges failed [${error.message}]`);
31 | throw error;
32 | }
33 | });
34 |
35 | export default exportEdges;
36 |
--------------------------------------------------------------------------------
/src/tasks/index.js:
--------------------------------------------------------------------------------
1 | import cleanup from './cleanup';
2 | import exportEdges from './exportEdges';
3 | import syncAddress from './syncAddress';
4 | import syncFullGraph from './syncFullGraph';
5 | import uploadEdgesS3 from './uploadEdgesS3';
6 |
7 | export const allTasks = [
8 | cleanup,
9 | exportEdges,
10 | syncAddress,
11 | syncFullGraph,
12 | uploadEdgesS3,
13 | ];
14 |
15 | export default {
16 | cleanup,
17 | exportEdges,
18 | syncAddress,
19 | syncFullGraph,
20 | uploadEdgesS3,
21 | };
22 |
--------------------------------------------------------------------------------
/src/tasks/processor.js:
--------------------------------------------------------------------------------
1 | import logger from '../helpers/logger';
2 |
3 | export default function processor(queue) {
4 | queue.on('error', (error) => {
5 | logger.error(`ERROR "${queue.name}" job: ${error}`);
6 |
7 | // eslint-disable-next-line
8 | console.error(error);
9 | });
10 |
11 | queue.on('active', (job) => {
12 | logger.info(`[${job.id}] ACTIVE "${queue.name}" job started`);
13 | });
14 |
15 | queue.on('progress', (job, progress) => {
16 | logger.info(`[${job.id}]" PROGRESS ${queue.name}" job: ${progress}`);
17 | });
18 |
19 | queue.on('completed', (job) => {
20 | logger.info(`[${job.id}] COMPLETE "${queue.name}" job`);
21 | });
22 |
23 | queue.on('failed', (job, error) => {
24 | logger.warn(`[${job.id}] FAILED "${queue.name}": ${error}`);
25 |
26 | // eslint-disable-next-line
27 | console.error(error);
28 | });
29 |
30 | queue.on('stalled', (job) => {
31 | logger.info(`[${job.id}] STALLED "${queue.name}"`);
32 | });
33 |
34 | queue.on('cleaned', (jobs, type) => {
35 | logger.info(`"${queue.name}" cleaned ${type} ${jobs.length}`);
36 | });
37 |
38 | queue.on('paused', () => {
39 | logger.info(`"${queue.name}" queue paused`);
40 | });
41 |
42 | queue.on('resumed', () => {
43 | logger.info(`"${queue.name}" queue resumed`);
44 | });
45 |
46 | return queue;
47 | }
48 |
--------------------------------------------------------------------------------
/src/tasks/submitJob.js:
--------------------------------------------------------------------------------
1 | import logger from '../helpers/logger';
2 |
3 | const jobDefaultOptions = {
4 | timeout: 1000 * 60 * 40,
5 | attempts: 100,
6 | removeOnComplete: true,
7 | backoff: { type: 'fixed', delay: 1000 * 10 },
8 | };
9 |
10 | export default function submitJob(queue, id, data = {}, jobOptions = {}) {
11 | return queue.getJob(id).then((job) => {
12 | if (job) {
13 | logger.warn(`Job "${queue.name}" with id "${id}" is already running`);
14 | return;
15 | }
16 |
17 | logger.info(`Adding job "${queue.name}" with id "${id}"`);
18 |
19 | return queue.add(
20 | { id, ...data },
21 | { jobId: id, ...jobDefaultOptions, ...jobOptions },
22 | );
23 | });
24 | }
25 |
--------------------------------------------------------------------------------
/src/tasks/syncAddress.js:
--------------------------------------------------------------------------------
1 | import Queue from 'bull';
2 |
3 | import processor from './processor';
4 | import submitJob from './submitJob';
5 | import tasks from './';
6 | import {
7 | processTrustEvent,
8 | processTransferEvent,
9 | } from '../services/edgesFromEvents';
10 | import { redisUrl, redisOptions } from '../services/redis';
11 |
12 | const syncAddress = new Queue('Sync trust graph for address', redisUrl, {
13 | settings: redisOptions,
14 | });
15 |
16 | processor(syncAddress).process(async (job) => {
17 | let isSuccessful = false;
18 |
19 | // This job is either triggered by a transfer event or a trust event.
20 | if (job.data.type === 'Transfer') {
21 | isSuccessful = await processTransferEvent(job.data);
22 | } else {
23 | isSuccessful = await processTrustEvent(job.data);
24 | }
25 |
26 | // Always write edges .csv file afterwards
27 | if (isSuccessful) {
28 | submitJob(
29 | tasks.exportEdges,
30 | `exportEdges-after-chain-event-${job.data.transactionHash}`,
31 | );
32 | }
33 | });
34 |
35 | export default syncAddress;
36 |
--------------------------------------------------------------------------------
/src/tasks/syncFullGraph.js:
--------------------------------------------------------------------------------
1 | import Queue from 'bull';
2 | import { performance } from 'perf_hooks';
3 |
4 | import EdgeUpdateManager from '../services/edgesUpdate';
5 | import logger from '../helpers/logger';
6 | import processor from './processor';
7 | import submitJob from './submitJob';
8 | import tasks from './';
9 | import { getBlockNumber } from '../services/graph';
10 | import { getTrustNetworkEdges } from '../services/edgesFromGraph';
11 | import { redisUrl, redisLongRunningOptions } from '../services/redis';
12 |
13 | const syncFullGraph = new Queue('Sync full trust graph', redisUrl, {
14 | settings: redisLongRunningOptions,
15 | });
16 |
17 | async function rebuildTrustNetwork() {
18 | const edgeUpdateManager = new EdgeUpdateManager();
19 | const blockNumber = await getBlockNumber();
20 |
21 | if (blockNumber === 0) {
22 | logger.warn('Found block number 0 from graph, aborting');
23 | return;
24 | }
25 |
26 | logger.info(`Syncing trust graph with current block ${blockNumber}`);
27 |
28 | // Measure time of the whole process
29 | const startTime = performance.now();
30 |
31 | try {
32 | const { edges, statistics } = await getTrustNetworkEdges();
33 |
34 | logger.info(
35 | `Finished getting trust network edges (${edges.length} entities). Start updating capacities.`,
36 | );
37 |
38 | for await (const edge of edges) {
39 | await edgeUpdateManager.updateEdge(
40 | {
41 | ...edge,
42 | token: edge.tokenOwner,
43 | },
44 | edge.tokenAddress,
45 | );
46 | }
47 |
48 | const endTime = performance.now();
49 | const milliseconds = Math.round(endTime - startTime);
50 |
51 | const checkedEdges = Object.keys(edgeUpdateManager.checkedEdges).length;
52 | logger.info(
53 | `Updated ${checkedEdges} edges with ${statistics.safes} safes, ${statistics.connections} connections and ${statistics.tokens} tokens (${milliseconds}ms)`,
54 | );
55 | } catch (error) {
56 | logger.error(`Worker failed [${error.message}]`);
57 | throw error;
58 | }
59 | }
60 |
61 | processor(syncFullGraph).process(async () => {
62 | await rebuildTrustNetwork();
63 |
64 | // Always write edges .csv file afterwards
65 | submitJob(tasks.exportEdges, `exportEdges-after-fullSync`);
66 | });
67 |
68 | export default syncFullGraph;
69 |
--------------------------------------------------------------------------------
/src/tasks/uploadEdgesS3.js:
--------------------------------------------------------------------------------
1 | import Queue from 'bull';
2 | import fs from 'fs';
3 |
4 | import logger from '../helpers/logger';
5 | import processor from './processor';
6 | import { EDGES_FILE_PATH } from '../constants';
7 | import { checkFileExists } from '../services/edgesFile';
8 | import { redisUrl, redisOptions } from '../services/redis';
9 | import { s3 } from '../services/aws';
10 | import { PutObjectCommand } from '@aws-sdk/client-s3';
11 |
12 | const uploadEdgesS3 = new Queue('Upload edges to S3 storage', redisUrl, {
13 | settings: redisOptions,
14 | });
15 |
16 | processor(uploadEdgesS3).process(async () => {
17 | if (!checkFileExists()) {
18 | logger.log(`${EDGES_FILE_PATH} does not exist yet. Skip job`);
19 | return;
20 | }
21 |
22 | const edges = fs.readFileSync(EDGES_FILE_PATH);
23 | const params = {
24 | Bucket: process.env.AWS_S3_BUCKET_TRUST_NETWORK,
25 | Key: `${new Date()}.csv`,
26 | Body: edges,
27 | ACL: 'public-read',
28 | ContentType: 'application/json',
29 | };
30 | return await s3.send(new PutObjectCommand(params));
31 | });
32 | export default uploadEdgesS3;
33 |
--------------------------------------------------------------------------------
/src/validations/news.js:
--------------------------------------------------------------------------------
1 | import { Joi } from 'celebrate';
2 |
3 | export default {
4 | findNews: {
5 | query: Joi.object({
6 | isActive: Joi.boolean().default(true),
7 | afterDate: Joi.date(),
8 | limit: Joi.number().integer().default(10),
9 | offset: Joi.number().integer().default(0),
10 | }),
11 | },
12 | };
13 |
--------------------------------------------------------------------------------
/src/validations/transfers.js:
--------------------------------------------------------------------------------
1 | import { Joi } from 'celebrate';
2 |
3 | import { customJoi } from '../helpers/validate';
4 |
5 | export default {
6 | createNewTransfer: {
7 | body: Joi.object({
8 | address: customJoi.web3().address().addressChecksum().required(),
9 | signature: Joi.string().length(132).required(),
10 | data: Joi.object({
11 | from: customJoi.web3().address().addressChecksum().required(),
12 | paymentNote: Joi.string().max(100).empty(''),
13 | to: customJoi.web3().address().addressChecksum().required(),
14 | transactionHash: customJoi.web3().transactionHash().required(),
15 | }).required(),
16 | }),
17 | },
18 | getByTransactionHash: {
19 | params: {
20 | transactionHash: customJoi.web3().transactionHash().required(),
21 | },
22 | body: Joi.object({
23 | address: customJoi.web3().address().addressChecksum().required(),
24 | signature: Joi.string().length(132).required(),
25 | }),
26 | },
27 | findTransferSteps: {
28 | body: Joi.object({
29 | from: customJoi.web3().address().addressChecksum().required(),
30 | to: customJoi.web3().address().addressChecksum().required(),
31 | value: Joi.string()
32 | .pattern(/^[0-9]+$/, { name: 'numbers' })
33 | .required(),
34 | hops: Joi.number().integer().min(1).max(100).allow(null),
35 | }),
36 | },
37 | };
38 |
--------------------------------------------------------------------------------
/src/validations/uploads.js:
--------------------------------------------------------------------------------
1 | import { Joi } from 'celebrate';
2 |
3 | export default {
4 | deleteAvatarImage: {
5 | body: Joi.object({
6 | url: Joi.string()
7 | .uri({ scheme: ['http', 'https'] })
8 | .required(),
9 | }),
10 | },
11 | };
12 |
--------------------------------------------------------------------------------
/src/validations/users.js:
--------------------------------------------------------------------------------
1 | import { Joi } from 'celebrate';
2 |
3 | import { customJoi } from '../helpers/validate';
4 |
5 | export default {
6 | dryRunCreateNewUser: {
7 | body: Joi.object({
8 | avatarUrl: Joi.string().uri().empty(''),
9 | email: Joi.string().email().empty(''),
10 | username: Joi.string().alphanum().min(3).max(24).empty(''),
11 | }),
12 | },
13 | createNewUser: {
14 | body: Joi.object({
15 | address: customJoi.web3().address().addressChecksum().required(),
16 | nonce: Joi.number().min(0).integer(),
17 | signature: Joi.string().length(132).required(),
18 | data: Joi.object({
19 | safeAddress: customJoi.web3().address().addressChecksum().required(),
20 | username: Joi.string().alphanum().min(3).max(24).required(),
21 | email: Joi.string().email().required(''),
22 | avatarUrl: Joi.string().uri().empty(''),
23 | }).required(),
24 | }),
25 | },
26 | updateUser: {
27 | body: Joi.object({
28 | address: customJoi.web3().address().addressChecksum().required(),
29 | signature: Joi.string().length(132).required(),
30 | data: Joi.object({
31 | safeAddress: customJoi.web3().address().addressChecksum().required(),
32 | username: Joi.string().alphanum().min(3).max(24).required(),
33 | email: Joi.string().email().required(''),
34 | avatarUrl: Joi.string().uri().empty(''),
35 | }).required(),
36 | }),
37 | params: {
38 | safeAddress: customJoi.web3().address().addressChecksum(),
39 | },
40 | },
41 | getPrivateUserData: {
42 | body: Joi.object({
43 | address: customJoi.web3().address().addressChecksum().required(),
44 | signature: Joi.string().length(132).required(),
45 | }),
46 | params: {
47 | safeAddress: customJoi.web3().address().addressChecksum(),
48 | },
49 | },
50 | updateProfileMigrationConsent: {
51 | body: Joi.object({
52 | address: customJoi.web3().address().addressChecksum().required(),
53 | signature: Joi.string().length(132).required(),
54 | data: Joi.object({
55 | safeAddress: customJoi.web3().address().addressChecksum().required(),
56 | profileMigrationConsent: Joi.boolean().required(),
57 | }).required(),
58 | }),
59 | params: {
60 | safeAddress: customJoi.web3().address().addressChecksum(),
61 | },
62 | },
63 | getByUsername: {
64 | params: {
65 | username: Joi.string().required(),
66 | },
67 | },
68 | findUsers: {
69 | query: Joi.object({
70 | username: Joi.array().items(Joi.string().alphanum()),
71 | address: Joi.array().items(customJoi.web3().address().addressChecksum()),
72 | query: Joi.string().max(256),
73 | }).or('username', 'address', 'query'),
74 | },
75 | deleteUser: {
76 | body: Joi.object({
77 | address: customJoi.web3().address().addressChecksum().required(),
78 | signature: Joi.string().length(132).required(),
79 | }),
80 | params: {
81 | safeAddress: customJoi.web3().address().addressChecksum(),
82 | },
83 | },
84 | };
85 |
--------------------------------------------------------------------------------
/src/worker.js:
--------------------------------------------------------------------------------
1 | import HubContract from '@circles/circles-contracts/build/contracts/Hub.json';
2 | import TokenContract from '@circles/circles-contracts/build/contracts/Token.json';
3 |
4 | import './helpers/env';
5 |
6 | import db from './database';
7 | import logger from './helpers/logger';
8 | import tasks from './tasks';
9 | import submitJob from './tasks/submitJob';
10 | import web3 from './services/web3';
11 | import {
12 | checkConnection,
13 | getEventSignature,
14 | subscribeEvent,
15 | } from './services/web3Ws';
16 | import { waitUntilGraphIsReady } from './services/graph';
17 |
18 | const CRON_NIGHTLY = '0 0 0 * * *';
19 |
20 | // Connect with postgres database
21 | db.authenticate()
22 | .then(() => {
23 | logger.info('Database connection has been established successfully');
24 | })
25 | .catch(() => {
26 | logger.error('Unable to connect to database');
27 | process.exit(1);
28 | });
29 |
30 | // Check blockchain connection
31 | checkConnection()
32 | .then((num) => {
33 | logger.info(`Blockchain connection established, block height is ${num}`);
34 | })
35 | .catch(() => {
36 | logger.error('Unable to connect to blockchain');
37 | process.exit(1);
38 | });
39 |
40 | // Listen for blockchain events which might alter the trust limit between users
41 | // in the trust network
42 | const hubContract = new web3.eth.Contract(HubContract.abi);
43 | const tokenContract = new web3.eth.Contract(TokenContract.abi);
44 |
45 | const transferSignature = getEventSignature(tokenContract, 'Transfer');
46 | const trustSignature = getEventSignature(hubContract, 'Trust');
47 |
48 | function handleTrustChange({ address, topics, transactionHash }) {
49 | if (topics.includes(transferSignature)) {
50 | submitJob(
51 | tasks.syncAddress,
52 | `syncAddress-transfer-${address}-${Date.now()}`,
53 | {
54 | tokenAddress: address,
55 | type: 'Transfer',
56 | topics,
57 | transactionHash,
58 | },
59 | );
60 | } else if (topics.includes(trustSignature)) {
61 | submitJob(
62 | tasks.syncAddress,
63 | `syncAddress-trust-${topics[1]}-${Date.now()}`,
64 | {
65 | type: 'Trust',
66 | topics,
67 | transactionHash,
68 | },
69 | );
70 | }
71 | }
72 |
73 | waitUntilGraphIsReady()
74 | .then(() => {
75 | logger.info('Graph node connection has been established successfully');
76 | })
77 | .then(() => {
78 | // Subscribe to events to handle trust graph updates for single addresses
79 | subscribeEvent(
80 | hubContract,
81 | process.env.HUB_ADDRESS,
82 | 'Trust',
83 | handleTrustChange,
84 | );
85 | subscribeEvent(tokenContract, null, 'Transfer', handleTrustChange);
86 |
87 | // Clean up worker queues every night
88 | submitJob(tasks.cleanup, 'cleanUp-nightly', null, {
89 | repeat: {
90 | cron: CRON_NIGHTLY,
91 | },
92 | });
93 |
94 | // Upload latest edges .json to S3 every night
95 | submitJob(tasks.uploadEdgesS3, 'uploadEdgesS3-nightly', null, {
96 | repeat: {
97 | cron: CRON_NIGHTLY,
98 | },
99 | });
100 |
101 | // Always write edges.json file on start to make sure it exists
102 | submitJob(tasks.exportEdges, 'exportEdges-initial');
103 | })
104 | .catch(() => {
105 | logger.error('Unable to connect to graph node');
106 | process.exit(1);
107 | });
108 |
--------------------------------------------------------------------------------
/test/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "env": {
3 | "jest": true
4 | }
5 | }
6 |
--------------------------------------------------------------------------------
/test/api.test.js:
--------------------------------------------------------------------------------
1 | import httpStatus from 'http-status';
2 | import request from 'supertest';
3 |
4 | import app from '~';
5 |
6 | describe('API', () => {
7 | describe('GET /api', () => {
8 | it('should respond with a successful message', async () => {
9 | await request(app).get('/api').expect(httpStatus.OK, {
10 | status: 'ok',
11 | });
12 | });
13 |
14 | it('should respond with not found error on invalid routes', async () => {
15 | await request(app).get('/okapi').expect(httpStatus.NOT_FOUND);
16 | });
17 | });
18 | });
19 |
--------------------------------------------------------------------------------
/test/aws-validation.test.js:
--------------------------------------------------------------------------------
1 | import httpStatus from 'http-status';
2 | import request from 'supertest';
3 | import app from '~';
4 | import { AWS_S3_DOMAIN } from '~/services/aws';
5 | import { KEY_PATH } from '~/controllers/uploads';
6 |
7 | async function expectErrorStatus(body, status = httpStatus.BAD_REQUEST) {
8 | return await request(app)
9 | .delete('/api/uploads/avatar')
10 | .send(body)
11 | .expect(status);
12 | }
13 |
14 | describe('AWS', () => {
15 | describe('delete object with validation', () => {
16 | it('validate uri', async () => {
17 | const bucket = process.env.AWS_S3_BUCKET;
18 | // Missing fields
19 | await expectErrorStatus({});
20 | // Wrong fields
21 | await expectErrorStatus({
22 | name: 'kaka',
23 | });
24 | // Empty url
25 | await expectErrorStatus({
26 | url: '',
27 | });
28 | // Null url
29 | await expectErrorStatus({
30 | url: null,
31 | });
32 | // Invalid uri protocol
33 | await expectErrorStatus({
34 | url: 'git://miau.com',
35 | });
36 | // Invalid uri domain
37 | await expectErrorStatus({
38 | url: `https://${bucket}.s3.amazonaws.miau/${KEY_PATH}kaka.jpg`,
39 | });
40 | // Invalid pathname
41 | await expectErrorStatus({
42 | url: `https://${bucket}.${AWS_S3_DOMAIN}/downloads/avatars/kaka.jpg`,
43 | });
44 | });
45 | });
46 | });
47 |
--------------------------------------------------------------------------------
/test/helpers.test.js:
--------------------------------------------------------------------------------
1 | import reduceCapacities, { reduceCapacity } from '~/helpers/reduce';
2 | import { minNumberString } from '~/helpers/compare';
3 |
4 | describe('Helpers', () => {
5 | describe('minNumberString', () => {
6 | it('should return the string with the lower number', () => {
7 | expect(minNumberString('100000000', '200')).toBe('200');
8 | expect(minNumberString('1000', '5000')).toBe('1000');
9 | expect(minNumberString('100', '5000')).toBe('100');
10 | expect(minNumberString('5500', '5000')).toBe('5000');
11 | expect(minNumberString('5500000000', '70000000')).toBe('70000000');
12 | expect(minNumberString('20', '20')).toBe('20');
13 |
14 | for (let i = 0; i < 1000; i += 1) {
15 | const a = Math.floor(Math.random() * Math.MAX_SAFE_INTEGER);
16 | const b = Math.floor(Math.random() * Math.MAX_SAFE_INTEGER);
17 |
18 | expect(minNumberString(a.toString(), b.toString())).toBe(
19 | Math.min(a, b).toString(),
20 | );
21 | }
22 | });
23 | });
24 |
25 | describe('reduceCapacities', () => {
26 | it('should preserve object fields', () => {
27 | const edges = [
28 | {
29 | from: '0x0',
30 | to: '0x1',
31 | token: '0x3',
32 | capacity: '1000',
33 | },
34 | ];
35 |
36 | expect(reduceCapacities(edges, 3)).toEqual([
37 | {
38 | from: '0x0',
39 | to: '0x1',
40 | token: '0x3',
41 | capacity: '900',
42 | },
43 | ]);
44 | });
45 |
46 | it('should filter out the edges with too small capacity', () => {
47 | const edgesRegular = [
48 | // Omit these other fields here as the method does not use them
49 | // from: '0x0',
50 | // to: '0x1',
51 | // token: '0x0',
52 | { capacity: '2000000000000000' },
53 | { capacity: '10000000000000' },
54 | { capacity: '91000000000000000' },
55 | { capacity: '4500000000000222' },
56 | ];
57 |
58 | // default value
59 | expect(reduceCapacities(edgesRegular)).toEqual([
60 | { capacity: '1900000000000000' },
61 | { capacity: '90900000000000000' },
62 | { capacity: '4400000000000222' },
63 | ]);
64 |
65 | // custom values
66 | expect(reduceCapacities(edgesRegular, 17).length).toBe(0);
67 | expect(reduceCapacities(edgesRegular, 2).length).toBe(4);
68 |
69 | const edgesSmall = [
70 | { capacity: '9' },
71 | { capacity: '90' },
72 | { capacity: '1000' },
73 | { capacity: '10000' },
74 | ];
75 |
76 | expect(reduceCapacities(edgesSmall, 1)).toEqual([
77 | { capacity: '89' },
78 | { capacity: '999' },
79 | { capacity: '9999' },
80 | ]);
81 | expect(reduceCapacities(edgesSmall, 2)).toEqual([
82 | { capacity: '990' },
83 | { capacity: '9990' },
84 | ]);
85 | expect(reduceCapacities(edgesSmall, 3)).toEqual([
86 | { capacity: '900' },
87 | { capacity: '9900' },
88 | ]);
89 | expect(reduceCapacities(edgesSmall, 4)).toEqual([{ capacity: '9000' }]);
90 | expect(reduceCapacities(edgesSmall, 5)).toEqual([]);
91 | });
92 | });
93 |
94 | describe('reduceCapacity', () => {
95 | it('should reduce capacity values correctly', () => {
96 | // Rename method here for better readability
97 | const r = reduceCapacity;
98 |
99 | expect(r('10000', 1)).toBe('9999');
100 | expect(r('10000', 2)).toBe('9990');
101 | expect(r('10000', 3)).toBe('9900');
102 | expect(r('10000', 4)).toBe('9000');
103 |
104 | expect(r('12345', 1)).toBe('12344');
105 | expect(r('12345', 2)).toBe('12335');
106 | expect(r('12345', 3)).toBe('12245');
107 | expect(r('12345', 4)).toBe('11345');
108 |
109 | expect(r('17000000000000000', 1)).toBe('16999999999999999');
110 | expect(r('17000000000000000', 2)).toBe('16999999999999990');
111 | expect(r('17000000000000000', 3)).toBe('16999999999999900');
112 | expect(r('17000000000000000', 4)).toBe('16999999999999000');
113 | expect(r('17000000000000000', 5)).toBe('16999999999990000');
114 | expect(r('17000000000000000', 6)).toBe('16999999999900000');
115 | expect(r('17000000000000000', 7)).toBe('16999999999000000');
116 | expect(r('17000000000000000', 8)).toBe('16999999990000000');
117 | expect(r('17000000000000000', 9)).toBe('16999999900000000');
118 | expect(r('17000000000000000', 10)).toBe('16999999000000000');
119 | expect(r('17000000000000000', 11)).toBe('16999990000000000');
120 | expect(r('17000000000000000', 12)).toBe('16999900000000000');
121 | expect(r('17000000000000000', 13)).toBe('16999000000000000');
122 | expect(r('17000000000000000', 14)).toBe('16990000000000000');
123 | expect(r('17000000000000000', 15)).toBe('16900000000000000');
124 | expect(r('17000000000000000', 16)).toBe('16000000000000000');
125 |
126 | expect(r('1094000000000012345', 1)).toBe('1094000000000012344');
127 | expect(r('1094000000000012345', 2)).toBe('1094000000000012335');
128 | expect(r('1094000000000012345', 3)).toBe('1094000000000012245');
129 | expect(r('1094000000000012345', 4)).toBe('1094000000000011345');
130 | expect(r('1094000000000012345', 5)).toBe('1094000000000002345');
131 | expect(r('1094000000000012345', 6)).toBe('1093999999999912345');
132 | expect(r('1094000000000012345', 7)).toBe('1093999999999012345');
133 | expect(r('1094000000000012345', 8)).toBe('1093999999990012345');
134 | expect(r('1094000000000012345', 9)).toBe('1093999999900012345');
135 | expect(r('1094000000000012345', 10)).toBe('1093999999000012345');
136 | expect(r('1094000000000012345', 11)).toBe('1093999990000012345');
137 | expect(r('1094000000000012345', 12)).toBe('1093999900000012345');
138 | expect(r('1094000000000012345', 13)).toBe('1093999000000012345');
139 | expect(r('1094000000000012345', 14)).toBe('1093990000000012345');
140 | expect(r('1094000000000012345', 15)).toBe('1093900000000012345');
141 | expect(r('1094000000000012345', 16)).toBe('1093000000000012345');
142 | expect(r('1094000000000012345', 17)).toBe('1084000000000012345');
143 | expect(r('1094000000000012345', 18)).toBe('994000000000012345');
144 |
145 | // Using default value
146 | expect(r('17000000000000000')).toBe('16900000000000000');
147 | expect(r('91000000000000000')).toBe('90900000000000000');
148 | expect(r('9999999999999999999')).toBe('9999899999999999999');
149 | });
150 |
151 | it('should not reduce capacity when the value is the same order of magnitude as the buffer', () => {
152 | const r = reduceCapacity;
153 | expect(r('10000', 5)).toBe('10000');
154 | expect(r('12345', 5)).toBe('12345');
155 | expect(r('11000000000000000', 17)).toBe('11000000000000000');
156 | });
157 |
158 | it('should not reduce capacity when the value is smaller than the buffer', () => {
159 | const r = reduceCapacity;
160 | expect(r('1000', 15)).toBe('1000');
161 | expect(r('123', 4)).toBe('123');
162 | });
163 | });
164 | });
165 |
--------------------------------------------------------------------------------
/test/news-find.test.js:
--------------------------------------------------------------------------------
1 | import httpStatus from 'http-status';
2 | import request from 'supertest';
3 |
4 | import News from '~/models/news';
5 | import app from '~';
6 |
7 | const NUM_TEST_NEWS = 5;
8 |
9 | let news = [];
10 |
11 | beforeAll(async () => {
12 | news = await Promise.all(
13 | new Array(NUM_TEST_NEWS).fill(0).map((_, index) =>
14 | News.create({
15 | message_en: `Message ${index + 1}`,
16 | date: new Date(`2015-03-${10 + index}T12:00:00Z`).toISOString(),
17 | iconId: index + 1,
18 | isActive: index !== 2, // Only one news in inactive
19 | title_en: `Title ${index + 1}`,
20 | }),
21 | ),
22 | ).then((result) =>
23 | result.map(
24 | ({
25 | dataValues: { date, iconId, id, isActive, message_en, title_en },
26 | }) => ({
27 | date,
28 | iconId,
29 | id,
30 | isActive,
31 | message_en,
32 | title_en,
33 | }),
34 | ),
35 | );
36 | });
37 |
38 | afterAll(async () => {
39 | await Promise.all(
40 | news.map(async (newsItem) => {
41 | return await News.destroy({
42 | where: {
43 | message_en: newsItem.message_en,
44 | },
45 | });
46 | }),
47 | );
48 | });
49 |
50 | describe('GET /news/?afterDate=... - Search via date', () => {
51 | it('should return the correct response format', () =>
52 | request(app)
53 | .get('/api/news')
54 | .set('Accept', 'application/json')
55 | .expect(httpStatus.OK)
56 | .expect(({ body: { data } }) =>
57 | data.forEach((obj) => {
58 | expect(obj).toHaveProperty('id');
59 | expect(obj).toHaveProperty('date');
60 | expect(obj).toHaveProperty('iconId');
61 | expect(obj).toHaveProperty('isActive');
62 | expect(obj).toHaveProperty('createdAt');
63 | expect(obj).toHaveProperty('updatedAt');
64 | expect(obj).toHaveProperty('title.en');
65 | expect(obj).toHaveProperty('message.en');
66 | }),
67 | ));
68 |
69 | it('should return active news by default', async () => {
70 | await request(app)
71 | .get('/api/news')
72 | .set('Accept', 'application/json')
73 | .expect(httpStatus.OK)
74 | .expect(({ body }) => {
75 | if (body.data.length !== NUM_TEST_NEWS - 1) {
76 | throw new Error('Did not return all expected entries');
77 | }
78 | });
79 | });
80 |
81 | it('should return all matching news ordered by the most recent first (last row in the table)', async () => {
82 | await request(app)
83 | .get(`/api/news/?afterDate=${news[2].date}`)
84 | .set('Accept', 'application/json')
85 | .expect(httpStatus.OK)
86 | .expect(({ body }) => {
87 | if (
88 | body.data.length !== 2 ||
89 | body.data[0].message.en !== news[4].message_en ||
90 | body.data[0].iconId !== news[4].iconId ||
91 | body.data[0].title.en !== news[4].title_en
92 | ) {
93 | throw new Error('Did not return expected entries');
94 | }
95 | });
96 | });
97 |
98 | it('should return all matching news with pagination', async () => {
99 | await request(app)
100 | .get(`/api/news/?afterDate=${news[1].date}&limit=1&offset=1`)
101 | .set('Accept', 'application/json')
102 | .expect(httpStatus.OK)
103 | .expect(({ body }) => {
104 | if (body.data.length !== 1 || body.data[0].iconId !== news[3].iconId) {
105 | throw new Error('Did not return expected entries');
106 | }
107 | });
108 | });
109 |
110 | it('should fail silently when no items were found', async () => {
111 | await request(app)
112 | .get(`/api/news/?afterDate=${new Date()}`)
113 | .set('Accept', 'application/json')
114 | .expect(httpStatus.OK)
115 | .expect(({ body }) => {
116 | if (body.data.length !== 0) {
117 | throw new Error('Invalid entries found');
118 | }
119 | });
120 | });
121 |
122 | it('should return inactive news when asking for inactive news', async () => {
123 | await request(app)
124 | .get('/api/news/?isActive=false')
125 | .set('Accept', 'application/json')
126 | .expect(httpStatus.OK)
127 | .expect(({ body }) => {
128 | if (body.data.length !== 1 || body.data[0].isActive !== false) {
129 | throw new Error('Invalid entries found');
130 | }
131 | });
132 | });
133 |
134 | it('should return active news when asking for active news', async () => {
135 | await request(app)
136 | .get('/api/news/?isActive=true')
137 | .set('Accept', 'application/json')
138 | .expect(httpStatus.OK)
139 | .expect(({ body }) => {
140 | if (
141 | body.data.length !== NUM_TEST_NEWS - 1 ||
142 | body.data[0].isActive !== true
143 | ) {
144 | throw new Error('Did not return all expected entries');
145 | }
146 | });
147 | });
148 |
149 | it('should fail when afterDate contains invalid date format', async () => {
150 | await request(app)
151 | .get('/api/news/?afterDate=lala')
152 | .set('Accept', 'application/json')
153 | .expect(httpStatus.BAD_REQUEST);
154 | });
155 |
156 | it('should fail when afterDate is empty', async () => {
157 | await request(app)
158 | .get('/api/news/?afterDate=')
159 | .set('Accept', 'application/json')
160 | .expect(httpStatus.BAD_REQUEST);
161 | });
162 | });
163 |
--------------------------------------------------------------------------------
/test/transfers-create.test.js:
--------------------------------------------------------------------------------
1 | import httpStatus from 'http-status';
2 | import request from 'supertest';
3 |
4 | import { createTransferPayload } from './utils/transfers';
5 | import { randomChecksumAddress, randomTransactionHash } from './utils/common';
6 |
7 | import Transfer from '~/models/transfers';
8 | import app from '~';
9 |
10 | describe('PUT /transfers - Creating a new transfer', () => {
11 | let from;
12 | let to;
13 | let transactionHash;
14 | let paymentNote;
15 |
16 | let payload;
17 |
18 | beforeEach(() => {
19 | from = randomChecksumAddress();
20 | to = randomChecksumAddress();
21 | transactionHash = randomTransactionHash();
22 | paymentNote = 'Thank you for the banana';
23 |
24 | payload = createTransferPayload({
25 | from,
26 | to,
27 | transactionHash,
28 | paymentNote,
29 | });
30 | });
31 |
32 | afterAll(async () => {
33 | return await Transfer.destroy({
34 | where: {
35 | transactionHash,
36 | },
37 | });
38 | });
39 |
40 | it('should successfully respond and fail when we try again', async () => {
41 | await request(app)
42 | .put('/api/transfers')
43 | .send(payload)
44 | .set('Accept', 'application/json')
45 | .expect(httpStatus.CREATED);
46 |
47 | await request(app)
48 | .put('/api/transfers')
49 | .send(payload)
50 | .set('Accept', 'application/json')
51 | .expect(httpStatus.CONFLICT);
52 | });
53 | });
54 |
--------------------------------------------------------------------------------
/test/transfers-find-steps.test.js:
--------------------------------------------------------------------------------
1 | import httpStatus from 'http-status';
2 | import request from 'supertest';
3 |
4 | import { mockGraphSafes } from './utils/mocks';
5 | import { randomChecksumAddress } from './utils/common';
6 |
7 | import app from '~';
8 |
9 | describe('POST /transfers - Find transfer steps', () => {
10 | beforeAll(async () => {
11 | mockGraphSafes();
12 | });
13 |
14 | it('should return an error when value is not positive', async () => {
15 | await request(app)
16 | .post('/api/transfers')
17 | .send({
18 | from: randomChecksumAddress(),
19 | to: randomChecksumAddress(),
20 | value: 0,
21 | })
22 | .set('Accept', 'application/json')
23 | .expect(httpStatus.BAD_REQUEST);
24 | });
25 | it('should return an error when hops is not positive', async () => {
26 | await request(app)
27 | .post('/api/transfers')
28 | .send({
29 | from: randomChecksumAddress(),
30 | to: randomChecksumAddress(),
31 | value: '5',
32 | hops: '0',
33 | })
34 | .set('Accept', 'application/json')
35 | .expect(httpStatus.BAD_REQUEST);
36 | });
37 | it('should return an error when hops is empty', async () => {
38 | await request(app)
39 | .post('/api/transfers')
40 | .send({
41 | from: randomChecksumAddress(),
42 | to: randomChecksumAddress(),
43 | value: '5',
44 | hops: '',
45 | })
46 | .set('Accept', 'application/json')
47 | .expect(httpStatus.BAD_REQUEST);
48 | });
49 | });
50 |
--------------------------------------------------------------------------------
/test/transfers-find.test.js:
--------------------------------------------------------------------------------
1 | import httpStatus from 'http-status';
2 | import request from 'supertest';
3 |
4 | import web3 from './utils/web3';
5 | import { mockGraphUsers } from './utils/mocks';
6 | import {
7 | randomTransactionHash,
8 | randomChecksumAddress,
9 | getSignature,
10 | } from './utils/common';
11 |
12 | import Transfer from '~/models/transfers';
13 | import app from '~';
14 |
15 | const NUM_TEST_TRANSFERS = 5;
16 |
17 | const transfers = [];
18 | const accounts = [];
19 |
20 | async function expectTransfer(app, account, { transactionHash, from, to }) {
21 | mockGraphUsers(account.address, to);
22 | const signature = getSignature([transactionHash], account.privateKey);
23 |
24 | return await request(app)
25 | .post(`/api/transfers/${transactionHash}`)
26 | .send({
27 | address: account.address,
28 | signature,
29 | })
30 | .set('Accept', 'application/json')
31 | .expect(httpStatus.OK)
32 | .expect(({ body }) => {
33 | if (!('id' in body.data)) {
34 | throw new Error('id missing');
35 | }
36 |
37 | if (body.data.transactionHash !== transactionHash) {
38 | throw new Error('Wrong transactionHash');
39 | }
40 |
41 | if (body.data.from !== from) {
42 | throw new Error('Wrong from address');
43 | }
44 |
45 | if (body.data.to !== to) {
46 | throw new Error('Wrong to address');
47 | }
48 | });
49 | }
50 |
51 | beforeAll(async () => {
52 | const items = new Array(NUM_TEST_TRANSFERS).fill(0);
53 |
54 | await Promise.all(
55 | items.map(async () => {
56 | const account = web3.eth.accounts.create();
57 | const address = account.address;
58 | const privateKey = account.privateKey;
59 |
60 | const from = randomChecksumAddress();
61 | const to = randomChecksumAddress();
62 | const transactionHash = randomTransactionHash();
63 | const paymentNote = `This is a payment note ${Math.random() * 10000}`;
64 |
65 | const signature = getSignature([from, to, transactionHash], privateKey);
66 |
67 | await request(app)
68 | .put('/api/transfers')
69 | .send({
70 | address,
71 | signature,
72 | data: {
73 | from,
74 | to,
75 | transactionHash,
76 | paymentNote,
77 | },
78 | })
79 | .set('Accept', 'application/json')
80 | .expect(httpStatus.CREATED);
81 |
82 | accounts.push(account);
83 |
84 | transfers.push({
85 | from,
86 | to,
87 | transactionHash,
88 | paymentNote,
89 | });
90 | }),
91 | );
92 | });
93 |
94 | afterAll(async () => {
95 | await Promise.all(
96 | transfers.map(async (transfer) => {
97 | return await Transfer.destroy({
98 | where: {
99 | transactionHash: transfer.transactionHash,
100 | },
101 | });
102 | }),
103 | );
104 | });
105 |
106 | describe('POST /transfers/:transactionHash - Resolve by transactionHash', () => {
107 | it('should find one transfer', async () => {
108 | await Promise.all(
109 | transfers.map(async (transfer, index) => {
110 | const account = accounts[index];
111 | return await expectTransfer(app, account, transfer);
112 | }),
113 | );
114 | });
115 |
116 | it('should throw an error when signature is invalid', async () => {
117 | const transactionHash = transfers[1].transactionHash;
118 | const account = accounts[1];
119 |
120 | const signature = getSignature(
121 | [randomTransactionHash()],
122 | account.privateKey,
123 | );
124 |
125 | await request(app)
126 | .post(`/api/transfers/${transactionHash}`)
127 | .send({
128 | address: account.address,
129 | signature,
130 | })
131 | .set('Accept', 'application/json')
132 | .expect(httpStatus.FORBIDDEN);
133 | });
134 |
135 | describe('validation', () => {
136 | let sender;
137 | let receiver;
138 | let from;
139 | let to;
140 | let transactionHash;
141 | let paymentNote;
142 |
143 | beforeEach(async () => {
144 | sender = web3.eth.accounts.create();
145 | receiver = web3.eth.accounts.create();
146 |
147 | from = randomChecksumAddress();
148 | to = randomChecksumAddress();
149 | transactionHash = randomTransactionHash();
150 | paymentNote = 'Thank you!';
151 |
152 | const signature = getSignature(
153 | [from, to, transactionHash],
154 | sender.privateKey,
155 | );
156 |
157 | await request(app)
158 | .put('/api/transfers')
159 | .send({
160 | address: sender.address,
161 | signature,
162 | data: {
163 | from,
164 | to,
165 | transactionHash,
166 | paymentNote,
167 | },
168 | })
169 | .set('Accept', 'application/json')
170 | .expect(httpStatus.CREATED);
171 |
172 | mockGraphUsers(sender.address, from);
173 | mockGraphUsers(receiver.address, to);
174 | });
175 |
176 | it('should return the result for only sender or receiver', async () => {
177 | const senderSignature = getSignature(
178 | [transactionHash],
179 | sender.privateKey,
180 | );
181 | const receiverSignature = getSignature(
182 | [transactionHash],
183 | receiver.privateKey,
184 | );
185 |
186 | await request(app)
187 | .post(`/api/transfers/${transactionHash}`)
188 | .send({
189 | address: sender.address,
190 | signature: senderSignature,
191 | })
192 | .set('Accept', 'application/json')
193 | .expect(httpStatus.OK);
194 |
195 | await request(app)
196 | .post(`/api/transfers/${transactionHash}`)
197 | .send({
198 | address: receiver.address,
199 | signature: receiverSignature,
200 | })
201 | .set('Accept', 'application/json')
202 | .expect(httpStatus.OK);
203 | });
204 |
205 | it('should return an error when signature is valid but entry was not found', async () => {
206 | const wrongTransactionHash = randomTransactionHash();
207 | const senderSignature = getSignature(
208 | [wrongTransactionHash],
209 | sender.privateKey,
210 | );
211 |
212 | await request(app)
213 | .post(`/api/transfers/${wrongTransactionHash}`)
214 | .send({
215 | address: sender.address,
216 | signature: senderSignature,
217 | })
218 | .set('Accept', 'application/json')
219 | .expect(httpStatus.NOT_FOUND);
220 | });
221 |
222 | it('should throw an error when sender or receiver is not the signer', async () => {
223 | const thirdAccount = web3.eth.accounts.create();
224 | const signature = getSignature(
225 | [transactionHash],
226 | thirdAccount.privateKey,
227 | );
228 |
229 | mockGraphUsers(thirdAccount.address, randomChecksumAddress());
230 |
231 | await request(app)
232 | .post(`/api/transfers/${transactionHash}`)
233 | .send({
234 | address: thirdAccount.address,
235 | signature,
236 | })
237 | .set('Accept', 'application/json')
238 | .expect(httpStatus.FORBIDDEN);
239 | });
240 | });
241 | });
242 |
--------------------------------------------------------------------------------
/test/transfers-update-steps.test.js:
--------------------------------------------------------------------------------
1 | import httpStatus from 'http-status';
2 | import request from 'supertest';
3 |
4 | import { mockGraphSafes } from './utils/mocks';
5 | import { randomChecksumAddress } from './utils/common';
6 |
7 | import app from '~';
8 |
9 | describe('POST /transfers/update - Update transfer steps', () => {
10 | beforeAll(async () => {
11 | mockGraphSafes();
12 | });
13 |
14 | it('should return an error when value is not positive', async () => {
15 | await request(app)
16 | .post('/api/transfers/update')
17 | .send({
18 | from: randomChecksumAddress(),
19 | to: randomChecksumAddress(),
20 | value: 0,
21 | })
22 | .set('Accept', 'application/json')
23 | .expect(httpStatus.BAD_REQUEST);
24 | });
25 | });
26 |
--------------------------------------------------------------------------------
/test/transfers-validation.test.js:
--------------------------------------------------------------------------------
1 | import httpStatus from 'http-status';
2 | import request from 'supertest';
3 |
4 | import web3 from './utils/web3';
5 | import {
6 | randomChecksumAddress,
7 | randomTransactionHash,
8 | getSignature,
9 | } from './utils/common';
10 |
11 | import app from '~';
12 |
13 | async function expectErrorStatus(body, status = httpStatus.BAD_REQUEST) {
14 | return await request(app)
15 | .put('/api/transfers')
16 | .send(body)
17 | .set('Accept', 'application/json')
18 | .expect(status);
19 | }
20 |
21 | describe('PUT /transfers - validation', () => {
22 | let address;
23 | let privateKey;
24 | let signature;
25 | let from;
26 | let to;
27 | let transactionHash;
28 | let paymentNote;
29 | let correctBody;
30 |
31 | beforeEach(() => {
32 | const account = web3.eth.accounts.create();
33 |
34 | address = account.address;
35 | privateKey = account.privateKey;
36 |
37 | from = randomChecksumAddress();
38 | to = randomChecksumAddress();
39 | transactionHash = randomTransactionHash();
40 | paymentNote = 'Thank you for the banana';
41 |
42 | signature = getSignature([from, to, transactionHash], privateKey);
43 |
44 | correctBody = {
45 | address,
46 | signature,
47 | data: {
48 | from,
49 | to,
50 | transactionHash,
51 | paymentNote,
52 | },
53 | };
54 | });
55 |
56 | describe('when using invalid parameters', () => {
57 | it('should return errors', async () => {
58 | // Missing fields
59 | await expectErrorStatus({
60 | ...correctBody,
61 | address: 'invalid',
62 | });
63 |
64 | // Missing signature
65 | await expectErrorStatus({
66 | ...correctBody,
67 | signature: '',
68 | });
69 |
70 | // Wrong address
71 | await expectErrorStatus({
72 | ...correctBody,
73 | address: web3.utils.randomHex(21),
74 | });
75 |
76 | // Wrong address checksum
77 | await expectErrorStatus({
78 | ...correctBody,
79 | address: web3.utils.randomHex(20),
80 | });
81 |
82 | // Invalid transaction hash
83 | await expectErrorStatus({
84 | ...correctBody,
85 | data: {
86 | ...correctBody.data,
87 | transaction: web3.utils.randomHex(10),
88 | },
89 | });
90 |
91 | // Invalid from field
92 | await expectErrorStatus({
93 | ...correctBody,
94 | data: {
95 | ...correctBody.data,
96 | from: web3.utils.randomHex(16),
97 | },
98 | });
99 |
100 | // Invalid payment note
101 | await expectErrorStatus({
102 | ...correctBody,
103 | data: {
104 | ...correctBody.data,
105 | paymentNote: 123,
106 | },
107 | });
108 | });
109 | });
110 |
111 | describe('when using invalid signatures', () => {
112 | it('should return errors', async () => {
113 | // Wrong address
114 | await expectErrorStatus(
115 | {
116 | ...correctBody,
117 | address: randomChecksumAddress(),
118 | },
119 | httpStatus.FORBIDDEN,
120 | );
121 |
122 | // Wrong from field
123 | await expectErrorStatus(
124 | {
125 | ...correctBody,
126 | data: {
127 | ...correctBody.data,
128 | from: randomChecksumAddress(),
129 | },
130 | },
131 | httpStatus.FORBIDDEN,
132 | );
133 |
134 | // Wrong transaction hash
135 | await expectErrorStatus(
136 | {
137 | ...correctBody,
138 | data: {
139 | ...correctBody.data,
140 | transactionHash: randomTransactionHash(),
141 | },
142 | },
143 | httpStatus.FORBIDDEN,
144 | );
145 | });
146 | });
147 | });
148 |
--------------------------------------------------------------------------------
/test/users-create-update.test.js:
--------------------------------------------------------------------------------
1 | import httpStatus from 'http-status';
2 | import { Op } from 'sequelize';
3 | import request from 'supertest';
4 |
5 | import { createUserPayload } from './utils/users';
6 | import { mockRelayerSafe, mockGraphUsers } from './utils/mocks';
7 | import { randomChecksumAddress, getSignature } from './utils/common';
8 |
9 | import User from '~/models/users';
10 | import app from '~';
11 |
12 | function prepareUser({ username = 'donkey' } = {}, returnPrivateKey = false) {
13 | const safeAddress = randomChecksumAddress();
14 | const nonce = new Date().getTime();
15 | const email = 'dk@kong.com';
16 | const avatarUrl = 'https://storage.com/image.jpg';
17 |
18 | const userPayload = createUserPayload(
19 | {
20 | nonce,
21 | safeAddress,
22 | username,
23 | email,
24 | avatarUrl,
25 | },
26 | returnPrivateKey,
27 | );
28 |
29 | mockRelayerSafe({
30 | address: returnPrivateKey
31 | ? userPayload.payload.address
32 | : userPayload.address,
33 | nonce,
34 | safeAddress,
35 | isCreated: true,
36 | isDeployed: false,
37 | });
38 |
39 | return userPayload;
40 | }
41 |
42 | describe('PUT /users - Creating a new user', () => {
43 | let payload;
44 |
45 | beforeEach(() => {
46 | payload = prepareUser();
47 | });
48 |
49 | afterEach(async () => {
50 | return await User.destroy({
51 | where: {
52 | username: payload.data.username,
53 | },
54 | });
55 | });
56 |
57 | it('should successfully respond and fail when we try again', async () => {
58 | await request(app)
59 | .put('/api/users')
60 | .send(payload)
61 | .set('Accept', 'application/json')
62 | .expect(httpStatus.CREATED);
63 |
64 | await request(app)
65 | .put('/api/users')
66 | .send(payload)
67 | .set('Accept', 'application/json')
68 | .expect(httpStatus.CONFLICT);
69 | });
70 | });
71 |
72 | describe('PUT /users - Fail when username is too similar', () => {
73 | let correctPayload;
74 | const duplicatePayloads = [];
75 |
76 | beforeEach(() => {
77 | correctPayload = prepareUser({ username: 'myUsername' });
78 | duplicatePayloads[0] = prepareUser({ username: 'myusername' });
79 | duplicatePayloads[1] = prepareUser({ username: 'MYUSERNAME' });
80 | duplicatePayloads[2] = prepareUser({ username: 'MyUsername' });
81 | duplicatePayloads[3] = prepareUser({ username: 'myUserName' });
82 | });
83 |
84 | afterEach(async () => {
85 | return await User.destroy({
86 | where: {
87 | username: correctPayload.data.username,
88 | },
89 | });
90 | });
91 |
92 | it('should reject same username with different letter case', async () => {
93 | await request(app)
94 | .put('/api/users')
95 | .send(correctPayload)
96 | .set('Accept', 'application/json')
97 | .expect(httpStatus.CREATED);
98 |
99 | // Same username already exists
100 | for (const payload of duplicatePayloads) {
101 | await request(app)
102 | .put('/api/users')
103 | .send(payload)
104 | .set('Accept', 'application/json')
105 | .expect(httpStatus.CONFLICT);
106 | }
107 | });
108 | });
109 |
110 | describe('POST /users/:safeAddress - Updating user data', () => {
111 | let payload;
112 | let privateKey;
113 | const newUsername = 'dolfin';
114 | const newEmail = 'dol@fin.com';
115 | const newAvatarUrl = 'https://storage.com/image2.jpg';
116 | const similarUserName = 'Doggy';
117 |
118 | beforeEach(() => {
119 | const response = prepareUser({ username: 'doggy' }, true);
120 | payload = response.payload;
121 | privateKey = response.privateKey;
122 | });
123 |
124 | afterEach(async () => {
125 | return await User.destroy({
126 | where: {
127 | username: payload.data.username,
128 | },
129 | });
130 | });
131 |
132 | describe('when user was already created', () => {
133 | it('should successfully respond when I update all the fields', async () => {
134 | await request(app)
135 | .put('/api/users')
136 | .send(payload)
137 | .set('Accept', 'application/json')
138 | .expect(httpStatus.CREATED);
139 |
140 | const signature = getSignature(
141 | [payload.address, payload.data.safeAddress, newUsername],
142 | privateKey,
143 | );
144 | // Update payload values
145 | payload.data.username = newUsername;
146 | payload.data.email = newEmail;
147 | payload.data.avatarUrl = newAvatarUrl;
148 | payload.signature = signature;
149 |
150 | mockGraphUsers(payload.address, payload.data.safeAddress);
151 | await request(app)
152 | .post(`/api/users/${payload.data.safeAddress}`)
153 | .send({
154 | address: payload.address,
155 | signature: payload.signature,
156 | data: payload.data,
157 | })
158 | .set('Accept', 'application/json')
159 | .expect(httpStatus.OK);
160 | });
161 |
162 | it('should successfully respond when I update only the username', async () => {
163 | await request(app)
164 | .put('/api/users')
165 | .send(payload)
166 | .set('Accept', 'application/json')
167 | .expect(httpStatus.CREATED);
168 |
169 | const signature = getSignature(
170 | [payload.address, payload.data.safeAddress, newUsername],
171 | privateKey,
172 | );
173 | // Update payload values
174 | payload.data.username = newUsername;
175 | payload.signature = signature;
176 |
177 | mockGraphUsers(payload.address, payload.data.safeAddress);
178 | await request(app)
179 | .post(`/api/users/${payload.data.safeAddress}`)
180 | .send({
181 | address: payload.address,
182 | signature: payload.signature,
183 | data: payload.data,
184 | })
185 | .set('Accept', 'application/json')
186 | .expect(httpStatus.OK);
187 | });
188 |
189 | it('should successfully respond when I update to a similar username', async () => {
190 | await request(app)
191 | .put('/api/users')
192 | .send(payload)
193 | .set('Accept', 'application/json')
194 | .expect(httpStatus.CREATED);
195 |
196 | const signature = getSignature(
197 | [payload.address, payload.data.safeAddress, similarUserName],
198 | privateKey,
199 | );
200 | // Update payload values
201 | payload.data.username = similarUserName;
202 | payload.signature = signature;
203 |
204 | mockGraphUsers(payload.address, payload.data.safeAddress);
205 | await request(app)
206 | .post(`/api/users/${payload.data.safeAddress}`)
207 | .send({
208 | address: payload.address,
209 | signature: payload.signature,
210 | data: payload.data,
211 | })
212 | .set('Accept', 'application/json')
213 | .expect(httpStatus.OK);
214 | });
215 |
216 | it('should successfully respond when I update only the avatarUrl', async () => {
217 | await request(app)
218 | .put('/api/users')
219 | .send(payload)
220 | .set('Accept', 'application/json')
221 | .expect(httpStatus.CREATED);
222 |
223 | const signature = getSignature(
224 | [payload.address, payload.data.safeAddress, payload.data.username],
225 | privateKey,
226 | );
227 | // Update payload values
228 | payload.data.avatarUrl = newAvatarUrl;
229 | payload.signature = signature;
230 |
231 | mockGraphUsers(payload.address, payload.data.safeAddress);
232 | await request(app)
233 | .post(`/api/users/${payload.data.safeAddress}`)
234 | .send({
235 | address: payload.address,
236 | signature: payload.signature,
237 | data: payload.data,
238 | })
239 | .set('Accept', 'application/json')
240 | .expect(httpStatus.OK);
241 | });
242 | });
243 |
244 | describe('when user was not registered', () => {
245 | it('should not fail when providing all the data fields', async () => {
246 | const signature = getSignature(
247 | [payload.address, payload.data.safeAddress, newUsername],
248 | privateKey,
249 | );
250 | // Update payload values
251 | payload.data.username = newUsername;
252 | payload.data.email = newEmail;
253 | payload.data.avatarUrl = newAvatarUrl;
254 | payload.signature = signature;
255 |
256 | mockGraphUsers(payload.address, payload.data.safeAddress);
257 | await request(app)
258 | .post(`/api/users/${payload.data.safeAddress}`)
259 | .send({
260 | address: payload.address,
261 | signature: payload.signature,
262 | data: payload.data,
263 | })
264 | .set('Accept', 'application/json')
265 | .expect(httpStatus.OK);
266 | });
267 | });
268 | });
269 |
270 | describe('POST /users/:safeAddress - Fail when username is too similar', () => {
271 | let correctPayload;
272 | let correctPrivateKey;
273 | let otherPayload;
274 | let otherPrivateKey;
275 | const correctOldUsername = 'myUsername';
276 | const oldUsername = 'kitty';
277 | const newUsername = 'MYusername';
278 |
279 | beforeEach(() => {
280 | const response = prepareUser({ username: correctOldUsername }, true);
281 | correctPayload = response.payload;
282 | correctPrivateKey = response.privateKey;
283 |
284 | const response2 = prepareUser({ username: oldUsername }, true);
285 | otherPayload = response2.payload;
286 | otherPrivateKey = response2.privateKey;
287 | });
288 |
289 | afterEach(async () => {
290 | return await User.destroy({
291 | where: {
292 | username: {
293 | [Op.or]: [
294 | correctPayload.data.username,
295 | correctOldUsername,
296 | oldUsername,
297 | ],
298 | },
299 | },
300 | });
301 | });
302 |
303 | it('should reject when is too similar to other username', async () => {
304 | await request(app)
305 | .put('/api/users')
306 | .send(correctPayload)
307 | .set('Accept', 'application/json')
308 | .expect(httpStatus.CREATED);
309 |
310 | await request(app)
311 | .put('/api/users')
312 | .send(otherPayload)
313 | .set('Accept', 'application/json')
314 | .expect(httpStatus.CREATED);
315 |
316 | // Same username already exists
317 | mockGraphUsers(otherPayload.address, otherPayload.data.safeAddress);
318 |
319 | const signature = getSignature(
320 | [otherPayload.address, otherPayload.data.safeAddress, newUsername],
321 | otherPrivateKey,
322 | );
323 | // Update payload values
324 | otherPayload.data.username = newUsername;
325 | otherPayload.signature = signature;
326 | await request(app)
327 | .post(`/api/users/${otherPayload.data.safeAddress}`)
328 | .send({
329 | address: otherPayload.address,
330 | signature: otherPayload.signature,
331 | data: otherPayload.data,
332 | })
333 | .set('Accept', 'application/json')
334 | .expect(httpStatus.CONFLICT);
335 | });
336 |
337 | it('should suceed when is similar to same username', async () => {
338 | await request(app)
339 | .put('/api/users')
340 | .send(correctPayload)
341 | .set('Accept', 'application/json')
342 | .expect(httpStatus.CREATED);
343 |
344 | // Same username already exists
345 | mockGraphUsers(correctPayload.address, correctPayload.data.safeAddress);
346 |
347 | const signature = getSignature(
348 | [correctPayload.address, correctPayload.data.safeAddress, newUsername],
349 | correctPrivateKey,
350 | );
351 | // Update payload values
352 | correctPayload.data.username = newUsername;
353 | correctPayload.signature = signature;
354 | await request(app)
355 | .post(`/api/users/${correctPayload.data.safeAddress}`)
356 | .send({
357 | address: correctPayload.address,
358 | signature: correctPayload.signature,
359 | data: correctPayload.data,
360 | })
361 | .set('Accept', 'application/json')
362 | .expect(httpStatus.OK);
363 | });
364 | });
365 |
--------------------------------------------------------------------------------
/test/users-delete.test.js:
--------------------------------------------------------------------------------
1 | import httpStatus from 'http-status';
2 | import request from 'supertest';
3 |
4 | import web3 from './utils/web3';
5 | import { createUserPayload } from './utils/users';
6 | import { mockRelayerSafe, mockGraphUsers } from './utils/mocks';
7 | import { randomChecksumAddress, getSignature } from './utils/common';
8 |
9 | import app from '~';
10 |
11 | function prepareUser({ username = 'donkey' } = {}, returnPrivateKey = false) {
12 | const safeAddress = randomChecksumAddress();
13 | const nonce = new Date().getTime();
14 | const email = 'dk@kong.com';
15 | const avatarUrl = 'https://storage.com/image.jpg';
16 |
17 | const userPayload = createUserPayload(
18 | {
19 | nonce,
20 | safeAddress,
21 | username,
22 | email,
23 | avatarUrl,
24 | },
25 | returnPrivateKey,
26 | );
27 |
28 | mockRelayerSafe({
29 | address: returnPrivateKey
30 | ? userPayload.payload.address
31 | : userPayload.address,
32 | nonce,
33 | safeAddress,
34 | isCreated: true,
35 | isDeployed: false,
36 | });
37 |
38 | return userPayload;
39 | }
40 |
41 | describe('DELETE /users/:safeAddress - Delete the user entry (idempotent)', () => {
42 | let payload;
43 | let privateKey;
44 |
45 | beforeEach(() => {
46 | const response = prepareUser({ username: 'doggy' }, true);
47 | payload = response.payload;
48 | privateKey = response.privateKey;
49 | });
50 |
51 | it('should successfully respond when we try again', async () => {
52 | // Create a user
53 | await request(app)
54 | .put('/api/users')
55 | .send(payload)
56 | .set('Accept', 'application/json')
57 | .expect(httpStatus.CREATED);
58 |
59 | // Remove entry
60 | mockGraphUsers(payload.address, payload.data.safeAddress);
61 | const signature = getSignature(
62 | [payload.address, payload.data.safeAddress],
63 | privateKey,
64 | );
65 | await request(app)
66 | .delete(`/api/users/${payload.data.safeAddress}`)
67 | .send({
68 | address: payload.address,
69 | signature: signature,
70 | })
71 | .set('Accept', 'application/json')
72 | .expect(httpStatus.OK);
73 |
74 | // Try again removing entry
75 | mockGraphUsers(payload.address, payload.data.safeAddress);
76 | await request(app)
77 | .delete(`/api/users/${payload.data.safeAddress}`)
78 | .send({
79 | address: payload.address,
80 | signature: signature,
81 | })
82 | .set('Accept', 'application/json')
83 | .expect(httpStatus.OK);
84 | });
85 |
86 | it('should return sucess when signature is valid but entry was not found', async () => {
87 | // Create a user and not register
88 | const account = web3.eth.accounts.create();
89 | const address = account.address;
90 | const privateKey = account.privateKey;
91 | const safeAddress = randomChecksumAddress();
92 | const signature = getSignature([address, safeAddress], privateKey);
93 |
94 | // Remove the user entry
95 | mockGraphUsers(address, safeAddress);
96 | await request(app)
97 | .delete(`/api/users/${safeAddress}`)
98 | .send({
99 | address: address,
100 | signature: signature,
101 | })
102 | .set('Accept', 'application/json')
103 | .expect(httpStatus.OK);
104 | });
105 | });
106 |
--------------------------------------------------------------------------------
/test/users-find.test.js:
--------------------------------------------------------------------------------
1 | import httpStatus from 'http-status';
2 | import request from 'supertest';
3 |
4 | import web3 from './utils/web3';
5 | import { mockRelayerSafe } from './utils/mocks';
6 | import { randomChecksumAddress, getSignature } from './utils/common';
7 |
8 | import User from '~/models/users';
9 | import app from '~';
10 |
11 | const NUM_TEST_USERS = 5;
12 |
13 | const users = [];
14 |
15 | async function expectUser(app, username, safeAddress) {
16 | return await request(app)
17 | .get(`/api/users/${username}`)
18 | .set('Accept', 'application/json')
19 | .expect(httpStatus.OK)
20 | .expect(({ body }) => {
21 | if (!('id' in body.data)) {
22 | throw new Error('id missing');
23 | }
24 |
25 | if (body.data.username !== username) {
26 | throw new Error('Wrong username');
27 | }
28 |
29 | if (body.data.safeAddress !== safeAddress) {
30 | throw new Error('Wrong Safe address');
31 | }
32 | });
33 | }
34 |
35 | beforeAll(async () => {
36 | const items = new Array(NUM_TEST_USERS).fill(0);
37 |
38 | await Promise.all(
39 | items.map(async (item, index) => {
40 | const account = web3.eth.accounts.create();
41 | const address = account.address;
42 | const privateKey = account.privateKey;
43 |
44 | const safeAddress = randomChecksumAddress();
45 | const nonce = index + 1;
46 | const username = `panda${index + 1}`;
47 | const email = `panda${index + 1}@zoo.org`;
48 |
49 | const signature = getSignature(
50 | [address, nonce, safeAddress, username],
51 | privateKey,
52 | );
53 |
54 | mockRelayerSafe({
55 | address,
56 | nonce,
57 | safeAddress,
58 | isCreated: true,
59 | isDeployed: false,
60 | });
61 |
62 | await request(app)
63 | .put('/api/users')
64 | .send({
65 | address,
66 | nonce,
67 | signature,
68 | data: {
69 | safeAddress,
70 | username,
71 | email,
72 | },
73 | })
74 | .set('Accept', 'application/json')
75 | .expect(httpStatus.CREATED);
76 |
77 | users.push({
78 | username,
79 | safeAddress,
80 | });
81 | }),
82 | );
83 | });
84 |
85 | afterAll(async () => {
86 | await Promise.all(
87 | users.map(async (user) => {
88 | return await User.destroy({
89 | where: {
90 | username: user.username,
91 | },
92 | });
93 | }),
94 | );
95 | });
96 |
97 | describe('GET /users/:username - Resolve by username', () => {
98 | it('should find one user', async () => {
99 | await Promise.all(
100 | users.map(async ({ username, safeAddress }) => {
101 | return await expectUser(app, username, safeAddress);
102 | }),
103 | );
104 | });
105 |
106 | it('should return an error when not found', async () => {
107 | await request(app)
108 | .get('/api/users/giraffe')
109 | .set('Accept', 'application/json')
110 | .expect(httpStatus.NOT_FOUND);
111 | });
112 | });
113 |
114 | describe('GET /users/?username[]=... - Resolve by usernames and addresses', () => {
115 | it('should return a list of all results', async () => {
116 | const params = users
117 | .reduce((acc, user) => {
118 | if (Math.random() > 0.5) {
119 | acc.push(`username[]=${user.username}`);
120 | } else {
121 | acc.push(`address[]=${user.safeAddress}`);
122 | }
123 |
124 | return acc;
125 | }, [])
126 | .join('&');
127 |
128 | await request(app)
129 | .get(`/api/users/?${params}`)
130 | .set('Accept', 'application/json')
131 | .expect(httpStatus.OK)
132 | .expect(({ body }) => {
133 | let foundTotal = 0;
134 |
135 | users.forEach((user) => {
136 | const isFound = body.data.find((item) => {
137 | return (
138 | item.username === user.username &&
139 | item.safeAddress === user.safeAddress
140 | );
141 | });
142 |
143 | if (isFound) {
144 | foundTotal += 1;
145 | } else {
146 | throw new Error('User was not resolved');
147 | }
148 | });
149 |
150 | if (foundTotal > body.data.length) {
151 | throw new Error('Too many results where returned');
152 | }
153 | });
154 | });
155 |
156 | it('should filter duplicates automatically', async () => {
157 | const { username, safeAddress } = users[1];
158 |
159 | await request(app)
160 | .get(`/api/users/?address[]=${safeAddress}&username[]=${username}`)
161 | .set('Accept', 'application/json')
162 | .expect(httpStatus.OK)
163 | .expect(({ body }) => {
164 | if (body.data.length !== 1) {
165 | throw new Error('Duplicates found');
166 | }
167 | });
168 | });
169 |
170 | it('should fail silently and not include the failed results', async () => {
171 | const { username, safeAddress } = users[4];
172 |
173 | const params = [
174 | `address[]=${safeAddress}`,
175 | `username[]=${username}`,
176 | `username[]=notexisting`,
177 | `address[]=${randomChecksumAddress()}`,
178 | ].join('&');
179 |
180 | await request(app)
181 | .get(`/api/users/?${params}`)
182 | .set('Accept', 'application/json')
183 | .expect(httpStatus.OK)
184 | .expect(({ body }) => {
185 | if (body.data.length !== 1) {
186 | throw new Error('Invalid entries found');
187 | }
188 |
189 | if (body.data[0].username !== username) {
190 | throw new Error('Invalid result found');
191 | }
192 | });
193 | });
194 | });
195 |
196 | describe('GET /users/?query=... - Search via username', () => {
197 | it('should return all matching users', async () => {
198 | await request(app)
199 | .get('/api/users/?query=panda')
200 | .set('Accept', 'application/json')
201 | .expect(httpStatus.OK)
202 | .expect(({ body }) => {
203 | if (body.data.length !== NUM_TEST_USERS) {
204 | throw new Error('Did not return all expected entries');
205 | }
206 | });
207 |
208 | await request(app)
209 | .get(`/api/users/?query=${users[1].username}`)
210 | .set('Accept', 'application/json')
211 | .expect(httpStatus.OK)
212 | .expect(({ body }) => {
213 | if (
214 | body.data.length !== 1 ||
215 | body.data[0].username !== users[1].username ||
216 | body.data[0].safeAddress !== users[1].safeAddress
217 | ) {
218 | throw new Error('Did not return expected entry');
219 | }
220 | });
221 | });
222 |
223 | it('should fail silently when no items were found', async () => {
224 | await request(app)
225 | .get('/api/users/?query=lala')
226 | .set('Accept', 'application/json')
227 | .expect(httpStatus.OK)
228 | .expect(({ body }) => {
229 | if (body.data.length !== 0) {
230 | throw new Error('Invalid entries found');
231 | }
232 | });
233 | });
234 |
235 | it('should fail silently when query contains non alphanumeric characters', async () => {
236 | await request(app)
237 | .get('/api/users/?query=lala%20lulu')
238 | .set('Accept', 'application/json')
239 | .expect(httpStatus.OK);
240 | });
241 |
242 | it('should fail when query is empty', async () => {
243 | await request(app)
244 | .get('/api/users/?query=')
245 | .set('Accept', 'application/json')
246 | .expect(httpStatus.BAD_REQUEST);
247 | });
248 | });
249 |
--------------------------------------------------------------------------------
/test/users-get-email.test.js:
--------------------------------------------------------------------------------
1 | import httpStatus from 'http-status';
2 | import request from 'supertest';
3 |
4 | import web3 from './utils/web3';
5 | import { createUserPayload } from './utils/users';
6 | import { mockRelayerSafe, mockGraphUsers } from './utils/mocks';
7 | import { randomChecksumAddress, getSignature } from './utils/common';
8 |
9 | import User from '~/models/users';
10 | import app from '~';
11 |
12 | function prepareUser({ username = 'donkey' } = {}, returnPrivateKey = false) {
13 | const safeAddress = randomChecksumAddress();
14 | const nonce = new Date().getTime();
15 | const email = 'dk@kong.com';
16 | const avatarUrl = 'https://storage.com/image.jpg';
17 |
18 | const userPayload = createUserPayload(
19 | {
20 | nonce,
21 | safeAddress,
22 | username,
23 | email,
24 | avatarUrl,
25 | },
26 | returnPrivateKey,
27 | );
28 |
29 | mockRelayerSafe({
30 | address: returnPrivateKey
31 | ? userPayload.payload.address
32 | : userPayload.address,
33 | nonce,
34 | safeAddress,
35 | isCreated: true,
36 | isDeployed: false,
37 | });
38 |
39 | return userPayload;
40 | }
41 |
42 | describe('GET /users/:safeAddress/email - Getting the user email', () => {
43 | let payload;
44 | let privateKey;
45 |
46 | beforeEach(() => {
47 | const response = prepareUser({ username: 'doggy' }, true);
48 | payload = response.payload;
49 | privateKey = response.privateKey;
50 | });
51 |
52 | afterEach(async () => {
53 | return await User.destroy({
54 | where: {
55 | username: payload.data.username,
56 | },
57 | });
58 | });
59 |
60 | it('should successfully respond when we try again', async () => {
61 | // Create a user
62 | await request(app)
63 | .put('/api/users')
64 | .send(payload)
65 | .set('Accept', 'application/json')
66 | .expect(httpStatus.CREATED);
67 |
68 | // Get the email
69 | mockGraphUsers(payload.address, payload.data.safeAddress);
70 | const signature = getSignature(
71 | [payload.address, payload.data.safeAddress],
72 | privateKey,
73 | );
74 | await request(app)
75 | .post(`/api/users/${payload.data.safeAddress}/email`)
76 | .send({
77 | address: payload.address,
78 | signature: signature,
79 | })
80 | .set('Accept', 'application/json')
81 | .expect(httpStatus.OK)
82 | .expect(({ body }) => {
83 | if (body.data.email !== payload.data.email) {
84 | throw new Error('Wrong email returned');
85 | }
86 | });
87 |
88 | // Get the email again
89 | mockGraphUsers(payload.address, payload.data.safeAddress);
90 | await request(app)
91 | .post(`/api/users/${payload.data.safeAddress}/email`)
92 | .send({
93 | address: payload.address,
94 | signature: signature,
95 | })
96 | .set('Accept', 'application/json')
97 | .expect(httpStatus.OK)
98 | .expect(({ body }) => {
99 | if (body.data.email !== payload.data.email) {
100 | throw new Error('Wrong email returned');
101 | }
102 | });
103 | });
104 |
105 | it('should return an error when signature is valid but entry was not found', async () => {
106 | // Create a user and not register
107 | const account = web3.eth.accounts.create();
108 | const address = account.address;
109 | const privateKey = account.privateKey;
110 | const safeAddress = randomChecksumAddress();
111 | const signature = getSignature([address, safeAddress], privateKey);
112 |
113 | // Get the email
114 | mockGraphUsers(address, safeAddress);
115 | await request(app)
116 | .post(`/api/users/${safeAddress}/email`)
117 | .send({
118 | address: address,
119 | signature: signature,
120 | })
121 | .set('Accept', 'application/json')
122 | .expect(httpStatus.NOT_FOUND);
123 | });
124 | });
125 |
--------------------------------------------------------------------------------
/test/users-profile-migration-consent.test.js:
--------------------------------------------------------------------------------
1 | import httpStatus from 'http-status';
2 | import request from 'supertest';
3 |
4 | import web3 from './utils/web3';
5 | import { createUserPayload } from './utils/users';
6 | import { mockRelayerSafe, mockGraphUsers } from './utils/mocks';
7 | import { randomChecksumAddress, getSignature } from './utils/common';
8 |
9 | import User from '~/models/users';
10 | import app from '~';
11 |
12 | function prepareUser({ username = 'azalea' } = {}, returnPrivateKey = false) {
13 | const safeAddress = randomChecksumAddress();
14 | const nonce = new Date().getTime();
15 | const email = 'azalea@flower.com';
16 | const avatarUrl = 'https://storage.com/image-flower.jpg';
17 |
18 | const userPayload = createUserPayload(
19 | {
20 | nonce,
21 | safeAddress,
22 | username,
23 | email,
24 | avatarUrl,
25 | },
26 | returnPrivateKey,
27 | );
28 |
29 | mockRelayerSafe({
30 | address: returnPrivateKey
31 | ? userPayload.payload.address
32 | : userPayload.address,
33 | nonce,
34 | safeAddress,
35 | isCreated: true,
36 | isDeployed: false,
37 | });
38 |
39 | return userPayload;
40 | }
41 |
42 | describe('USER - Getting and updating the user profile migration consent', () => {
43 | let payload;
44 | let privateKey;
45 |
46 | beforeEach(() => {
47 | const response = prepareUser({ username: 'iris' }, true);
48 | payload = response.payload;
49 | privateKey = response.privateKey;
50 | });
51 |
52 | afterEach(async () => {
53 | return await User.destroy({
54 | where: {
55 | username: payload.data.username,
56 | },
57 | });
58 | });
59 |
60 | describe('POST /users/:safeAddress/get-profile-migration-consent - Getting the user profile migration consent', () => {
61 | it('should get false value by default', async () => {
62 | // Create a user
63 | await request(app)
64 | .put('/api/users')
65 | .send(payload)
66 | .set('Accept', 'application/json')
67 | .expect(httpStatus.CREATED);
68 |
69 | // Get the profile migration consent
70 | mockGraphUsers(payload.address, payload.data.safeAddress);
71 | const signature = getSignature(
72 | [payload.address, payload.data.safeAddress],
73 | privateKey,
74 | );
75 | await request(app)
76 | .post(
77 | `/api/users/${payload.data.safeAddress}/get-profile-migration-consent`,
78 | )
79 | .send({
80 | address: payload.address,
81 | signature: signature,
82 | })
83 | .set('Accept', 'application/json')
84 | .expect(httpStatus.OK)
85 | .expect(({ body }) => {
86 | if (body.data.profileMigrationConsent !== false) {
87 | throw new Error('Wrong value returned');
88 | }
89 | });
90 | });
91 |
92 | it('should return an error when signature is valid but entry was not found', async () => {
93 | // Create a user and not register
94 | const account = web3.eth.accounts.create();
95 | const address = account.address;
96 | const privateKey = account.privateKey;
97 | const safeAddress = randomChecksumAddress();
98 | const signature = getSignature([address, safeAddress], privateKey);
99 |
100 | // Get the profile migration consent
101 | mockGraphUsers(address, safeAddress);
102 | await request(app)
103 | .post(`/api/users/${safeAddress}/get-profile-migration-consent`)
104 | .send({
105 | address: address,
106 | signature: signature,
107 | })
108 | .set('Accept', 'application/json')
109 | .expect(httpStatus.NOT_FOUND);
110 | });
111 | });
112 | describe('POST /users/:safeAddress/update-profile-migration-consent - Updating the user profile migration consent', () => {
113 | const newProfileMigrationConsent = true;
114 |
115 | it('when user was already created should successfully respond and return the correct value when I update the field', async () => {
116 | await request(app)
117 | .put('/api/users')
118 | .send(payload)
119 | .set('Accept', 'application/json')
120 | .expect(httpStatus.CREATED);
121 |
122 | const signature = getSignature(
123 | [payload.address, payload.data.safeAddress, newProfileMigrationConsent],
124 | privateKey,
125 | );
126 | // Update payload values
127 | payload.signature = signature;
128 | mockGraphUsers(payload.address, payload.data.safeAddress);
129 | await request(app)
130 | .post(
131 | `/api/users/${payload.data.safeAddress}/update-profile-migration-consent`,
132 | )
133 | .send({
134 | address: payload.address,
135 | signature: payload.signature,
136 | data: {
137 | safeAddress: payload.data.safeAddress,
138 | profileMigrationConsent: newProfileMigrationConsent,
139 | },
140 | })
141 | .set('Accept', 'application/json')
142 | .expect(httpStatus.OK);
143 | const signature2 = getSignature(
144 | [payload.address, payload.data.safeAddress],
145 | privateKey,
146 | );
147 | mockGraphUsers(payload.address, payload.data.safeAddress);
148 | await request(app)
149 | .post(
150 | `/api/users/${payload.data.safeAddress}/get-profile-migration-consent`,
151 | )
152 | .send({
153 | address: payload.address,
154 | signature: signature2,
155 | })
156 | .set('Accept', 'application/json')
157 | .expect(httpStatus.OK)
158 | .expect(({ body }) => {
159 | if (
160 | body.data.profileMigrationConsent !== newProfileMigrationConsent
161 | ) {
162 | throw new Error('Wrong value returned');
163 | }
164 | });
165 | });
166 | describe('when user was not registered', () => {
167 | it('should fail', async () => {
168 | const signature = getSignature(
169 | [
170 | payload.address,
171 | payload.data.safeAddress,
172 | newProfileMigrationConsent,
173 | ],
174 | privateKey,
175 | );
176 | // Update payload values
177 | payload.signature = signature;
178 |
179 | mockGraphUsers(payload.address, payload.data.safeAddress);
180 | await request(app)
181 | .post(
182 | `/api/users/${payload.data.safeAddress}/update-profile-migration-consent`,
183 | )
184 | .send({
185 | address: payload.address,
186 | signature: payload.signature,
187 | data: {
188 | safeAddress: payload.data.safeAddress,
189 | profileMigrationConsent: newProfileMigrationConsent,
190 | },
191 | })
192 | .set('Accept', 'application/json')
193 | .expect(httpStatus.OK);
194 | const signature2 = getSignature(
195 | [payload.address, payload.data.safeAddress],
196 | privateKey,
197 | );
198 | mockGraphUsers(payload.address, payload.data.safeAddress);
199 | await request(app)
200 | .post(
201 | `/api/users/${payload.data.safeAddress}/get-profile-migration-consent`,
202 | )
203 | .send({
204 | address: payload.address,
205 | signature: signature2,
206 | })
207 | .set('Accept', 'application/json')
208 | .expect(httpStatus.NOT_FOUND)
209 | });
210 | });
211 | });
212 | });
213 |
--------------------------------------------------------------------------------
/test/users-safe-verification.test.js:
--------------------------------------------------------------------------------
1 | import httpStatus from 'http-status';
2 | import request from 'supertest';
3 |
4 | import web3 from './utils/web3';
5 | import { mockRelayerSafe, mockGraphUsers } from './utils/mocks';
6 | import { randomChecksumAddress, getSignature } from './utils/common';
7 |
8 | import app from '~';
9 |
10 | describe('PUT /users - Safe verification', () => {
11 | let address;
12 | let nonce;
13 | let privateKey;
14 | let safeAddress;
15 | let signature;
16 | let username;
17 | let email;
18 |
19 | beforeEach(() => {
20 | const account = web3.eth.accounts.create();
21 |
22 | address = account.address;
23 | privateKey = account.privateKey;
24 | safeAddress = randomChecksumAddress();
25 | nonce = new Date().getTime();
26 | username = 'donkey' + Math.round(Math.random() * 1000);
27 | email = 'dk@kong.com';
28 |
29 | signature = getSignature(
30 | [address, nonce, safeAddress, username],
31 | privateKey,
32 | );
33 | });
34 |
35 | describe('when trying to hijack someones Safe', () => {
36 | it('should return an error when we do not get the Safe state right', async () => {
37 | // We send a nonce, even though the Safe is already deployed ...
38 | mockRelayerSafe({
39 | address,
40 | nonce,
41 | safeAddress,
42 | isCreated: true,
43 | isDeployed: true,
44 | });
45 |
46 | return await request(app)
47 | .put('/api/users')
48 | .send({
49 | address,
50 | nonce,
51 | signature,
52 | data: {
53 | safeAddress,
54 | username,
55 | email,
56 | },
57 | })
58 | .set('Accept', 'application/json')
59 | .expect(httpStatus.BAD_REQUEST);
60 | });
61 |
62 | it('should return an error when we cant guess the right nonce', async () => {
63 | const victimAddress = randomChecksumAddress();
64 | const victimSafeAddress = randomChecksumAddress();
65 | const attackerNonce = 123;
66 |
67 | const signature = getSignature(
68 | [address, attackerNonce, victimSafeAddress, username],
69 | privateKey,
70 | );
71 |
72 | // We try to hijack someone elses safe address
73 | mockRelayerSafe({
74 | address: victimAddress,
75 | nonce,
76 | safeAddress: victimSafeAddress,
77 | isCreated: true,
78 | isDeployed: false,
79 | });
80 |
81 | // .. but receive this instead
82 | mockRelayerSafe({
83 | address,
84 | nonce: attackerNonce,
85 | safeAddress: randomChecksumAddress(),
86 | isCreated: false,
87 | isDeployed: false,
88 | });
89 |
90 | return await request(app)
91 | .put('/api/users')
92 | .send({
93 | address,
94 | nonce: attackerNonce,
95 | signature,
96 | data: {
97 | safeAddress: victimSafeAddress,
98 | username,
99 | email,
100 | },
101 | })
102 | .set('Accept', 'application/json')
103 | .expect(httpStatus.BAD_REQUEST);
104 | });
105 |
106 | it('should return an error when owner is wrong', async () => {
107 | const victimAddress = randomChecksumAddress();
108 | const victimSafeAddress = randomChecksumAddress();
109 |
110 | const signature = getSignature(
111 | [address, 0, victimSafeAddress, username],
112 | privateKey,
113 | );
114 |
115 | mockRelayerSafe({
116 | address: victimAddress,
117 | nonce,
118 | safeAddress: victimSafeAddress,
119 | isCreated: true,
120 | isDeployed: true,
121 | });
122 |
123 | return await request(app)
124 | .put('/api/users')
125 | .send({
126 | address,
127 | signature,
128 | data: {
129 | safeAddress: victimSafeAddress,
130 | username,
131 | email,
132 | },
133 | })
134 | .set('Accept', 'application/json')
135 | .expect(httpStatus.BAD_REQUEST);
136 | });
137 | });
138 | });
139 |
140 | describe('POST /users/:safeAddress - Safe verification', () => {
141 | let address;
142 | let safeAddress;
143 | let privateKey;
144 | let username;
145 | let email;
146 |
147 | beforeEach(() => {
148 | const account = web3.eth.accounts.create();
149 |
150 | address = account.address;
151 | safeAddress = randomChecksumAddress();
152 | privateKey = account.privateKey;
153 | username = 'donkey' + Math.round(Math.random() * 1000);
154 | email = 'dk@kong.com';
155 | });
156 |
157 | describe('when trying to hijack someones Safe', () => {
158 | it('should return an error when owner is wrong', async () => {
159 | const victimSafeAddress = randomChecksumAddress();
160 |
161 | const signature = getSignature(
162 | [address, victimSafeAddress, username],
163 | privateKey,
164 | );
165 |
166 | mockGraphUsers(address, safeAddress);
167 | return await request(app)
168 | .post(`/api/users/${victimSafeAddress}`)
169 | .send({
170 | address,
171 | signature,
172 | data: {
173 | safeAddress: victimSafeAddress,
174 | username,
175 | email,
176 | },
177 | })
178 | .set('Accept', 'application/json')
179 | .expect(httpStatus.BAD_REQUEST);
180 | });
181 | });
182 | });
183 |
184 | describe('POST /users/:safeAddress/email - Safe verification', () => {
185 | let address;
186 | let safeAddress;
187 | let privateKey;
188 |
189 | beforeEach(() => {
190 | const account = web3.eth.accounts.create();
191 |
192 | address = account.address;
193 | safeAddress = randomChecksumAddress();
194 | privateKey = account.privateKey;
195 | });
196 |
197 | describe('when trying to hijack someones Safe', () => {
198 | it('should return an error when owner is wrong', async () => {
199 | const victimSafeAddress = randomChecksumAddress();
200 |
201 | const signature = getSignature([address, victimSafeAddress], privateKey);
202 |
203 | mockGraphUsers(address, safeAddress);
204 | return await request(app)
205 | .post(`/api/users/${victimSafeAddress}/email`)
206 | .send({
207 | address,
208 | signature,
209 | })
210 | .set('Accept', 'application/json')
211 | .expect(httpStatus.BAD_REQUEST);
212 | });
213 | });
214 | });
215 |
--------------------------------------------------------------------------------
/test/utils/common.js:
--------------------------------------------------------------------------------
1 | import web3 from './web3';
2 |
3 | export function randomChecksumAddress() {
4 | return web3.utils.toChecksumAddress(web3.utils.randomHex(20));
5 | }
6 |
7 | export function randomTransactionHash() {
8 | return web3.utils.randomHex(32);
9 | }
10 |
11 | export function getSignature(fields, privateKey) {
12 | const data = fields.join('');
13 | const { signature } = web3.eth.accounts.sign(data, privateKey);
14 | return signature;
15 | }
16 |
--------------------------------------------------------------------------------
/test/utils/mocks.js:
--------------------------------------------------------------------------------
1 | import httpStatus from 'http-status';
2 | import nock from 'nock';
3 |
4 | import web3 from './web3';
5 | import graphSafesMockData from '../data/graph-safes.json';
6 |
7 | export function mockGraphSafes() {
8 | const mockedQuery =
9 | 'id outgoing { canSendToAddress userAddress } incoming { canSendToAddress userAddress } balances { token { id owner { id } } }';
10 |
11 | // Mock paginated safes query
12 | nock(process.env.GRAPH_NODE_ENDPOINT)
13 | .post(`/subgraphs/name/${process.env.SUBGRAPH_NAME}`, {
14 | query: `{ safes( first: 500, skip: 0) { ${mockedQuery} } }`,
15 | })
16 | .reply(httpStatus.OK, graphSafesMockData);
17 |
18 | nock(process.env.GRAPH_NODE_ENDPOINT)
19 | .post(`/subgraphs/name/${process.env.SUBGRAPH_NAME}`, {
20 | query: `{ safes( first: 500, skip: 500) { ${mockedQuery} } }`,
21 | })
22 | .reply(httpStatus.OK, {
23 | data: {
24 | safes: [],
25 | },
26 | });
27 | }
28 |
29 | export function mockGraphUsers(address, safeAddress) {
30 | nock(process.env.GRAPH_NODE_ENDPOINT)
31 | .post(`/subgraphs/name/${process.env.SUBGRAPH_NAME}`, {
32 | query: `{ user(id: "${address.toLowerCase()}") { safeAddresses } }`,
33 | })
34 | .reply(httpStatus.OK, {
35 | data: {
36 | user: {
37 | safeAddresses: [safeAddress.toLowerCase()],
38 | },
39 | },
40 | });
41 | }
42 |
43 | export function mockRelayerSafe({
44 | address,
45 | nonce,
46 | safeAddress,
47 | isCreated,
48 | isDeployed,
49 | }) {
50 | if (isCreated) {
51 | nock(process.env.RELAY_SERVICE_ENDPOINT)
52 | .get(`/api/v2/safes/${safeAddress}/funded/`)
53 | .reply(httpStatus.OK, {
54 | blockNumber: null,
55 | txHash: isDeployed ? web3.utils.randomHex(32) : null,
56 | });
57 | } else {
58 | nock(process.env.RELAY_SERVICE_ENDPOINT)
59 | .get(`/api/v2/safes/${safeAddress}/funded/`)
60 | .reply(httpStatus.NOT_FOUND);
61 | }
62 |
63 | if (isCreated) {
64 | nock(process.env.RELAY_SERVICE_ENDPOINT)
65 | .post('/api/v3/safes/', {
66 | saltNonce: nonce,
67 | owners: [address],
68 | threshold: 1,
69 | })
70 | .reply(httpStatus.UNPROCESSABLE_ENTITY, {
71 | exception: `SafeAlreadyExistsException: Safe=${safeAddress} cannot be created, already exists`,
72 | });
73 | } else {
74 | nock(process.env.RELAY_SERVICE_ENDPOINT)
75 | .post('/api/v3/safes/', {
76 | saltNonce: nonce,
77 | owners: [address],
78 | threshold: 1,
79 | })
80 | .reply(httpStatus.CREATED);
81 | }
82 |
83 | nock(process.env.RELAY_SERVICE_ENDPOINT)
84 | .post('/api/v3/safes/predict/', {
85 | saltNonce: nonce,
86 | owners: [address],
87 | threshold: 1,
88 | })
89 | .reply(httpStatus.OK, {
90 | safe: safeAddress,
91 | });
92 |
93 | if (isCreated) {
94 | if (isDeployed) {
95 | nock(process.env.RELAY_SERVICE_ENDPOINT)
96 | .get(`/api/v1/safes/${safeAddress}/`)
97 | .reply(httpStatus.OK, {
98 | address: safeAddress,
99 | masterCopy: process.env.SAFE_ADDRESS,
100 | nonce: 0,
101 | threshold: 1,
102 | owners: [address],
103 | version: '1.1.1',
104 | });
105 | } else {
106 | nock(process.env.RELAY_SERVICE_ENDPOINT)
107 | .get(`/api/v1/safes/${safeAddress}/`)
108 | .reply(httpStatus.UNPROCESSABLE_ENTITY, {
109 | exception: `"SafeNotDeployed: Safe with address=${safeAddress} not deployed"`,
110 | });
111 | }
112 | } else {
113 | nock(process.env.RELAY_SERVICE_ENDPOINT)
114 | .get(`/api/v1/safes/${safeAddress}`)
115 | .reply(httpStatus.NOT_FOUND);
116 | }
117 | }
118 |
--------------------------------------------------------------------------------
/test/utils/transfers.js:
--------------------------------------------------------------------------------
1 | import web3 from './web3';
2 | import { getSignature } from './common';
3 |
4 | export function createTransferPayload({
5 | from,
6 | to,
7 | transactionHash,
8 | paymentNote,
9 | }) {
10 | const { address, privateKey } = web3.eth.accounts.create();
11 | const signature = getSignature([from, to, transactionHash], privateKey);
12 |
13 | return {
14 | address,
15 | signature,
16 | data: {
17 | from,
18 | to,
19 | transactionHash,
20 | paymentNote,
21 | },
22 | };
23 | }
24 |
--------------------------------------------------------------------------------
/test/utils/users.js:
--------------------------------------------------------------------------------
1 | import web3 from './web3';
2 | import { getSignature } from './common';
3 |
4 | export function createUserPayload(
5 | { nonce, safeAddress, username, email, avatarUrl },
6 | returnPrivateKey = false,
7 | ) {
8 | const { address, privateKey } = web3.eth.accounts.create();
9 | const signature = getSignature(
10 | [address, nonce, safeAddress, username],
11 | privateKey,
12 | );
13 |
14 | if (returnPrivateKey) {
15 | const payload = {
16 | address,
17 | nonce,
18 | signature,
19 | data: {
20 | safeAddress,
21 | username,
22 | email,
23 | avatarUrl,
24 | },
25 | };
26 | return { payload, privateKey };
27 | } else {
28 | return {
29 | address,
30 | nonce,
31 | signature,
32 | data: {
33 | safeAddress,
34 | username,
35 | email,
36 | avatarUrl,
37 | },
38 | };
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/test/utils/web3.js:
--------------------------------------------------------------------------------
1 | import Web3 from 'web3';
2 |
3 | const web3 = new Web3();
4 |
5 | export default web3;
6 |
--------------------------------------------------------------------------------