├── .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 |
16 | 17 | 18 | License 19 | 20 | 21 | 22 | CI Status 23 | 24 | 25 | 26 | chat 27 | 28 | 29 | 30 | Follow Circles 31 | 32 |
33 | 34 |
35 |

36 | 37 | API Docs 38 | 39 | | 40 | 41 | Handbook 42 | 43 | | 44 | 45 | Releases 46 | 47 | | 48 | 49 | Contributing 50 | 51 |

52 |
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 | --------------------------------------------------------------------------------