├── .dockerignore ├── .editorconfig ├── .env.example ├── .github ├── dependabot.yml └── workflows │ ├── create-sentry-release.yml │ ├── lint.yml │ └── test.yml ├── .gitignore ├── .nvmrc ├── LICENCE ├── README.md ├── data └── verifiedTokens.json ├── jest.config.ts ├── package.json ├── src ├── api.ts ├── helpers │ ├── mysql.ts │ ├── schema.sql │ ├── snapshot.ts │ └── utils.ts ├── index.ts ├── lib │ ├── ai │ │ ├── summary.ts │ │ └── textToSpeech.ts │ ├── cache.ts │ ├── cacheRefresher.ts │ ├── domain.ts │ ├── metrics │ │ ├── digitalOcean.ts │ │ └── index.ts │ ├── moderationList.ts │ ├── nftClaimer │ │ ├── deploy.ts │ │ ├── deployAbi.json │ │ ├── deployImplementationAbi.json │ │ ├── mint.ts │ │ ├── spaceCollectionImplementationAbi.json │ │ ├── spaceFactoryAbi.json │ │ └── utils.ts │ ├── queue.ts │ ├── storage │ │ ├── aws.ts │ │ ├── file.ts │ │ └── types.ts │ └── votesReport.ts ├── sentryTunnel.ts └── webhook.ts ├── test ├── .env.test ├── e2e │ ├── __snapshots__ │ │ └── moderation.test.ts.snap │ ├── moderation.test.ts │ ├── nftClaimer.test.ts │ └── votesReport.test.ts ├── fixtures │ ├── hub-proposal-0x07387077920ce65b805bd0ba913a02ecfe63d22cac3dbaed3d97c23afd053fe2.json │ ├── hub-proposal-0x0da0673d17298e8f52c88385959952d21c2d0ae2fff2f0fea9df02ca0590cb6a.json │ ├── hub-proposal-0x1e5fdb5c87867a94c1c7f27025d62851ea47f6072f2296ca53a48fce1b87cdef.json │ ├── hub-proposal-0x79ae5f9eb3c710179cfbf706fa451459ddd18d4b0bce37c22aae601128efe927.json │ ├── hub-proposal-0xafe3a0426d4e6c645e869707f1b581765698d80c8d3e9cd37d7d3bf5e6f894e7.json │ ├── hub-proposal-0xbb1b4f1f866fda9c1c19ff31bc32c98f92d70f2055a3ba26a502377cf2d1e743.json │ ├── hub-proposal-0xd37c87edb3cbd78d58a78056b4facb00df739fdf3a16b168305e9cfdd00b3ab5.json │ ├── hub-proposal-0xe5e335af87dc10206e9f0de469f64901407837d659db6703cb3ea1437056a577.json │ ├── hub-votes-0x07387077920ce65b805bd0ba913a02ecfe63d22cac3dbaed3d97c23afd053fe2.json │ ├── hub-votes-0x0da0673d17298e8f52c88385959952d21c2d0ae2fff2f0fea9df02ca0590cb6a.json │ ├── hub-votes-0x1e5fdb5c87867a94c1c7f27025d62851ea47f6072f2296ca53a48fce1b87cdef.json │ ├── hub-votes-0x79ae5f9eb3c710179cfbf706fa451459ddd18d4b0bce37c22aae601128efe927.json │ ├── hub-votes-0xafe3a0426d4e6c645e869707f1b581765698d80c8d3e9cd37d7d3bf5e6f894e7.json │ ├── hub-votes-0xbb1b4f1f866fda9c1c19ff31bc32c98f92d70f2055a3ba26a502377cf2d1e743.json │ ├── hub-votes-0xd37c87edb3cbd78d58a78056b4facb00df739fdf3a16b168305e9cfdd00b3ab5.json │ ├── hub-votes-0xe5e335af87dc10206e9f0de469f64901407837d659db6703cb3ea1437056a577.json │ ├── icon.png │ ├── moderation.ts │ ├── snapshot-votes-report-0x07387077920ce65b805bd0ba913a02ecfe63d22cac3dbaed3d97c23afd053fe2.csv │ ├── snapshot-votes-report-0x0da0673d17298e8f52c88385959952d21c2d0ae2fff2f0fea9df02ca0590cb6a.csv │ ├── snapshot-votes-report-0x1e5fdb5c87867a94c1c7f27025d62851ea47f6072f2296ca53a48fce1b87cdef.csv │ ├── snapshot-votes-report-0x79ae5f9eb3c710179cfbf706fa451459ddd18d4b0bce37c22aae601128efe927.csv │ ├── snapshot-votes-report-0xafe3a0426d4e6c645e869707f1b581765698d80c8d3e9cd37d7d3bf5e6f894e7.csv │ ├── snapshot-votes-report-0xbb1b4f1f866fda9c1c19ff31bc32c98f92d70f2055a3ba26a502377cf2d1e743.csv │ ├── snapshot-votes-report-0xd37c87edb3cbd78d58a78056b4facb00df739fdf3a16b168305e9cfdd00b3ab5.csv │ ├── snapshot-votes-report-0xe5e335af87dc10206e9f0de469f64901407837d659db6703cb3ea1437056a577.csv │ └── verifiedTokens.json ├── integration │ ├── helpers │ │ └── utils.test.ts │ └── lib │ │ ├── nftClaimer │ │ └── utils.test.ts │ │ └── storage │ │ └── aws.test.ts └── unit │ └── lib │ ├── __snapshots__ │ └── moderationList.test.ts.snap │ ├── cache.test.ts │ ├── moderationList.test.ts │ ├── nftClaimer │ ├── deploy.test.ts │ ├── mint.test.ts │ └── utils.test.ts │ └── votesReport.test.ts ├── tsconfig.json └── yarn.lock /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = LF 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | HUB_URL=https://hub.snapshot.org 2 | BROVIDER_URL=https://rpc.snapshot.org 3 | HUB_NETWORK=1 4 | DATABASE_URL= 5 | AWS_ACCESS_KEY_ID= 6 | AWS_REGION= 7 | AWS_SECRET_ACCESS_KEY= 8 | AWS_BUCKET_NAME= 9 | WEBHOOK_AUTH_TOKEN= 10 | STORAGE_ENGINE=file 11 | VOTE_REPORT_SUBDIR=votes 12 | AI_SUMMARY_SUBDIR=ai-summary 13 | AI_TTS_SUBDIR=ai-tts 14 | OPENAI_API_KEY= 15 | NFT_CLAIMER_PRIVATE_KEY= 16 | NFT_CLAIMER_NETWORK= 17 | NFT_CLAIMER_DEPLOY_VERIFYING_CONTRACT= 18 | NFT_CLAIMER_DEPLOY_IMPLEMENTATION_ADDRESS= 19 | NFT_CLAIMER_DEPLOY_INITIALIZE_SELECTOR= 20 | NFT_CLAIMER_SUBGRAPH_URL= 21 | KEYCARD_API_KEY= 22 | SENTRY_DSN= 23 | SENTRY_TRACE_SAMPLE_RATE= 24 | TUNNEL_SENTRY_DSN= 25 | DIGITAL_OCEAN_TOKEN= 26 | METRICS_AUTHORIZATION= 27 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: 'npm' 4 | directory: '/' 5 | schedule: 6 | interval: 'weekly' 7 | allow: 8 | - dependency-name: '@snapshot-labs/*' 9 | -------------------------------------------------------------------------------- /.github/workflows/create-sentry-release.yml: -------------------------------------------------------------------------------- 1 | name: Create a Sentry release 2 | 3 | on: 4 | push: 5 | branches: 6 | - 'main' 7 | 8 | jobs: 9 | create-sentry-release: 10 | strategy: 11 | matrix: 12 | target: ['18'] 13 | uses: snapshot-labs/actions/.github/workflows/create-sentry-release.yml@main 14 | with: 15 | project: snapshot-sidekick 16 | target: ${{ matrix.target }} 17 | secrets: inherit 18 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: [push] 4 | 5 | jobs: 6 | lint: 7 | strategy: 8 | matrix: 9 | target: ['18'] 10 | uses: snapshot-labs/actions/.github/workflows/lint.yml@main 11 | secrets: inherit 12 | with: 13 | target: ${{ matrix.target }} 14 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: [push] 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-20.04 8 | env: 9 | DATABASE_URL: 'mysql://root:root@127.0.0.1:3306/sidekick_test' 10 | steps: 11 | - uses: actions/checkout@v3 12 | - uses: actions/setup-node@v3 13 | with: 14 | node-version: '18' 15 | cache: 'yarn' 16 | - name: Set up MySQL 17 | run: | 18 | sudo /etc/init.d/mysql start 19 | mysql -e 'CREATE DATABASE sidekick_test;' -uroot -proot 20 | mysql -uroot -proot sidekick_test < src/helpers/schema.sql 21 | mysql -uroot -proot -e "ALTER USER 'root'@'localhost' IDENTIFIED WITH mysql_native_password BY 'root';" 22 | mysql -uroot -proot -e "FLUSH PRIVILEGES;" 23 | - run: yarn install 24 | - name: Run all tests 25 | run: yarn test 26 | env: 27 | AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} 28 | AWS_REGION: ${{ secrets.AWS_REGION }} 29 | AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} 30 | AWS_BUCKET_NAME: ${{ secrets.AWS_BUCKET_NAME }} 31 | - name: Upload coverage reports to Codecov 32 | uses: codecov/codecov-action@v3 33 | with: 34 | token: ${{ secrets.CODECOV_TOKEN }} 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | dist 4 | build 5 | .env 6 | coverage 7 | tmp 8 | 9 | # Remove some common IDE working directories 10 | .idea 11 | .vscode 12 | *.log 13 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 18 2 | -------------------------------------------------------------------------------- /LICENCE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Snapshot Labs 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Snapshot/Sidekick [![codecov](https://codecov.io/gh/snapshot-labs/snapshot-sidekick/branch/main/graph/badge.svg?token=Tb16ITll42)](https://codecov.io/gh/snapshot-labs/snapshot-sidekick) 2 | 3 | Sidekick is the service serving: 4 | 5 | - All proposal's votes CSV report 6 | - Moderation list 7 | - NFT Claimer trusted backend server 8 | 9 | --- 10 | 11 | ## Project Setup 12 | 13 | ### Requirements 14 | 15 | node ">=16.0.0 <17.0.0" 16 | 17 | ### Dependencies 18 | 19 | Install the dependencies 20 | 21 | ```bash 22 | yarn 23 | ``` 24 | 25 | _This project does not require a database, but requires a [storage engine](#storage-engine)_ 26 | 27 | ### Configuration 28 | 29 | Copy `.env.example`, rename it to `.env` and edit the hub API url in the `.env` file if needed 30 | 31 | ```bash 32 | HUB_URL=https://hub.snapshot.org 33 | ``` 34 | 35 | If you are using AWS as storage engine, set all the required `AWS_` config keys, and set `STORAGE_ENGINE` to `aws`. 36 | 37 | ### Storage engine 38 | 39 | This script is shipped with 2 storage engine. 40 | 41 | You can set the cache engine by toggling the `STORAGE_ENGINE` environment variable. 42 | 43 | | `STORAGE_ENGINE` | Description | Cache save path | 44 | | ---------------- | ----------- | --------------------------------- | 45 | | `aws` | Amazon S3 | `public/` | 46 | | `file` (default) | Local file | `tmp/` (relative to project root) | 47 | 48 | You can additionally specify a sub directory by setting `VOTE_REPORT_SUBDIR` 49 | (By default, all votes report will be nested in the `votes` directory). 50 | 51 | ### Compiles and hot-reloads for development 52 | 53 | ```bash 54 | yarn dev 55 | ``` 56 | 57 | ## Tests 58 | 59 | All tests are run with their own environment, using `/test/.env.test` 60 | 61 | ### Setup 62 | 63 | ``` 64 | mysql -e 'CREATE DATABASE sidekick_test;' -uroot -proot 65 | mysql -uroot -proot sidekick_test < src/helpers/schema.sql 66 | mysql -uroot -proot -e "ALTER USER 'root'@'localhost' IDENTIFIED WITH mysql_native_password BY 'root';" 67 | mysql -uroot -proot -e "FLUSH PRIVILEGES;" 68 | ``` 69 | 70 | ### Usage 71 | 72 | ``` 73 | yarn test:unit to run unit tests 74 | yarn test:e2e to run e2e tests 75 | ``` 76 | 77 | To run all tests, and generate test coverage: 78 | 79 | ``` 80 | yarn test 81 | ``` 82 | 83 | If you have added any E2E tests requiring snapshots update, run: 84 | 85 | ```bash 86 | yarn test:e2e:update-snapshot 87 | ``` 88 | 89 | ## Linting, typecheck 90 | 91 | ```bash 92 | yarn lint 93 | yarn typecheck 94 | ``` 95 | 96 | ## Usage 97 | 98 | ### Votes CSV report 99 | 100 | Generate and serve votes CSV report for active and closed proposals. 101 | 102 | > NOTE: CSV files are generated only once, then cached, making this service a cache middleware between snapshot-hub and UI 103 | 104 | #### Fetch a cache file 105 | 106 | Send a `POST` request with a proposal ID 107 | 108 | ```bash 109 | curl -X POST localhost:3005/api/votes/[PROPOSAL-ID] 110 | ``` 111 | 112 | When cached, this request will respond with a stream to a CSV file. 113 | 114 | When votes report can be cached, but does not exist yet, a cache generation task will be queued. 115 | This enable cache to be generated on-demand. 116 | A JSON-RPC success with status code `202` will then be returned, with the progress percentage as `result` message. 117 | 118 | ``` 119 | { 120 | "jsonrpc":"2.0", 121 | "result":"15.45", 122 | "id":"0x5280241b4ccc9b7c5088e657a714d28fa89bd5305a1ff0abf0736438c446ae98" 123 | } 124 | ``` 125 | 126 | - CSV reports will automatically be generated for closed proposals, triggered by Snapshot's webhook. 127 | - CSV reports for active proposals will be generated only when requested manually via the API endpoint (`/votes/:id`), and will be refreshed every 15min thereafter. 128 | 129 | NOTES: cache file for active proposals may not always contains all the votes, as new incoming votes are 130 | appended to the cache file asynchronously in the background. 131 | 132 | #### Generate a cache file 133 | 134 | Send a `POST` request with a body following the [Webhook event object](https://docs.snapshot.org/tools/webhooks). 135 | 136 | ```bash 137 | curl -X POST localhost:3005/webhook \ 138 | -H "Authenticate: WEBHOOK_AUTH_TOKEN" \ 139 | -H "Content-Type: application/json" \ 140 | -d '{"id": "proposal/[PROPOSAL-ID]", "event": "proposal/end"}' 141 | ``` 142 | 143 | On success, will respond with a success [JSON-RPC 2.0](https://www.jsonrpc.org/specification) message 144 | 145 | > This endpoint has been designed to receive events from snapshot webhook service. 146 | 147 | Do not forget to set `WEBHOOK_AUTH_TOKEN` in the `.env` file 148 | 149 | ### Static moderation list 150 | 151 | Return a curated list of moderated data. 152 | 153 | #### Retrieve the list 154 | 155 | Send a `GET` request 156 | 157 | ```bash 158 | curl localhost:3005/api/moderation 159 | ``` 160 | 161 | You can also choose to filter the list, with the `?list=` query params. 162 | Valid values are: 163 | 164 | - `flaggedLinks` 165 | - `flaggedIps` 166 | - `flaggedAddresses` 167 | - `verifiedTokens` 168 | 169 | You can pass multiple list, separated by a comma. 170 | 171 | Data are sourced from the json files with the same name, located in this repo `/data` directory, and a remote read-only SQL database. 172 | 173 | ### NFT Claimer trusted backend 174 | 175 | Validate offchain data, and return a payload 176 | 177 | #### Get global data 178 | 179 | Retrieve global data from the smart contract. 180 | 181 | Send a `GET` request to `/api/nft-claimer` 182 | 183 | ```bash 184 | curl -X GET localhost:3005/api/nft-claimer 185 | ``` 186 | 187 | ##### Example payload 188 | 189 | ```json 190 | { 191 | "snapshotFee": 5 192 | } 193 | ``` 194 | 195 | #### Sign deploy 196 | 197 | Sign and return the payload for the SpaceCollectionFactory contract, in order to deploy a new SpaceCollection contract 198 | 199 | Send a `POST` request with: 200 | 201 | | `keyname` | Type | Description | Example | 202 | | --------------- | -------------- | ------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | 203 | | `address` | Wallet address | The sender wallet address | `0x00000000000000000000000000000000000000000000000000000000000004d2` | 204 | | `id` | `string` | A space ID | `fabien.eth` | 205 | | `salt` | `string` | A string representation of a BigInt number | `72536493147621360896130495100276306361343381736075662552878320684807833746288` | 206 | | `maxSupply` | `number` | The maximum number of mintable NFTs for each proposal | `100` | 207 | | `mintPrice` | `string` | A string representation a a BigInt number, for the price in wei | `100000000000000000` | 208 | | `spaceTreasury` | Wallet address | The wallet address receiving the funds | `0x00000000000000000000000000000000000000000000000000000000000004d2` | 209 | | `proposerFee` | `number` | A number between 0 and 100, for the percentage of the mint price reversed to the proposal creator | `5` | 210 | 211 | ```bash 212 | curl -X POST localhost:3005/api/nft-claimer/deploy -H "Content-Type: application/json" -d '{"id": "fabien.eth", "address": "00000000000000000000000000000000000000000000000000000000000004d2", "salt": "123454678", "maxSupply": 100, "mintPrice": 10000, "spaceTreasury": "00000000000000000000000000000000000000000000000000000000000004d2", "proposerFee": 10}' 213 | ``` 214 | 215 | If the given `address` is the space controller, and the space has not setup NFT Claimer yet, this endpoint will return a `payload` object, with all parameters required for sending the transaction to the SpaceCollectionFactory contract 216 | 217 | ##### Example payload 218 | 219 | ```json 220 | { 221 | "initializer": "0x977b0efb00000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000140000000000000000000000000000000000000000000000000000000000000018000000000000000000000000000000000000000000000000000000000000000640000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000091fd2c8d24767db4ece7069aa27832ffaf8590f300000000000000000000000091fd2c8d24767db4ece7069aa27832ffaf8590f300000000000000000000000000000000000000000000000000000000000000075465737444414f000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003302e3100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000007656e732e65746800000000000000000000000000000000000000000000000000", 222 | "salt": "123454678", 223 | "abi": "function deployProxy(address implementation, bytes initializer, uint256 salt, uint8 v, bytes32 r, bytes32 s)", 224 | "implementation": "0x33505720a7921d23E6b02EB69623Ed6A008Ca511", 225 | "signature": { 226 | "r": "0xac72b099abc370f7dadca09110907fd0856d1e343b64dcb13bc4a55fa00fc8de", 227 | "s": "0x5c9846f3b2f0d4054857a02644aa7fd311c906057c7b5d0f47a8517743a3f5cd", 228 | "_vs": "0xdc9846f3b2f0d4054857a02644aa7fd311c906057c7b5d0f47a8517743a3f5cd", 229 | "recoveryParam": 1, 230 | "v": 28, 231 | "yParityAndS": "0xdc9846f3b2f0d4054857a02644aa7fd311c906057c7b5d0f47a8517743a3f5cd", 232 | "compact": "0xac72b099abc370f7dadca09110907fd0856d1e343b64dcb13bc4a55fa00fc8dedc9846f3b2f0d4054857a02644aa7fd311c906057c7b5d0f47a8517743a3f5cd" 233 | } 234 | } 235 | ``` 236 | 237 | #### Sign mint 238 | 239 | Sign and return the payload for the SpaceCollection contract, in order to mint a NFT 240 | 241 | Send a `POST` request with: 242 | 243 | | `keyname` | Type | Description | Example | 244 | | ---------------- | -------------- | ------------------------------------------ | ------------------------------------------------------------------------------- | 245 | | `id` | `string` | The proposal ID | `0x1abb90a506a352e51d587b0ee8c387c0b129ea018aa77345fe7b5c2defa7d150` | 246 | | `address` | Wallet address | The sender wallet address | `0x00000000000000000000000000000000000000000000000000000000000004d2` | 247 | | `salt` | `string` | A string representation of a BigInt number | `72536493147621360896130495100276306361343381736075662552878320684807833746288` | 248 | | `proposalAuthor` | Wallet address | The proposal author's wallet address | `0x1abb90a506a352e51d587b0ee8c387c0b129ea018aa77345fe7b5c2defa7d150` | 249 | 250 | ```bash 251 | curl -X POST localhost:3005/api/nft-claimer/mint -H "Content-Type: application/json" -d '{"id": "0x28535f56f29a9b085be88e3896da573c61095a14f092ce72afea3c83f4feefe0", "address": "0x91FD2c8d24767db4Ece7069AA27832ffaf8590f3", "salt": "1020303343345", "proposalAuthor": "0x16645967f660AC05EA542D3DE2f46E41b86436d9"}' 252 | ``` 253 | 254 | If given proposal's space has enabled NFT claimer, and there are still mintable NFT left, this endpoint will return a `payload` object, with all parameters required for sending the transaction to the SpaceCollection contract 255 | 256 | ##### Example payload 257 | 258 | ```json 259 | { 260 | "salt": "123454678", 261 | "contractAddress": "0x33505720a7921d23E6b02EB69623Ed6A008Ca511", 262 | "proposer": "0x1abb90a506a352e51d587b0ee8c387c0b129ea018aa77345fe7b5c2defa7d150", 263 | "recipient": "0x1abb90a506a352e51d587b0ee8c387c0b129ea018aa77345fe7b5c2defa7d150", 264 | "spaceId": "fabien.eth", 265 | "abi": "function mint(address proposer, uint256 proposalId, uint256 salt, uint8 v, bytes32 r, bytes32 s)", 266 | "proposalId": "72536493147621360896130495100276306361343381736075662552878320684807833746288", 267 | "signature": { 268 | "r": "0xac72b099abc370f7dadca09110907fd0856d1e343b64dcb13bc4a55fa00fc8de", 269 | "s": "0x5c9846f3b2f0d4054857a02644aa7fd311c906057c7b5d0f47a8517743a3f5cd", 270 | "_vs": "0xdc9846f3b2f0d4054857a02644aa7fd311c906057c7b5d0f47a8517743a3f5cd", 271 | "recoveryParam": 1, 272 | "v": 28, 273 | "yParityAndS": "0xdc9846f3b2f0d4054857a02644aa7fd311c906057c7b5d0f47a8517743a3f5cd", 274 | "compact": "0xac72b099abc370f7dadca09110907fd0856d1e343b64dcb13bc4a55fa00fc8dedc9846f3b2f0d4054857a02644aa7fd311c906057c7b5d0f47a8517743a3f5cd" 275 | } 276 | } 277 | ``` 278 | 279 | > **NOTE**: The returned `proposalId` in the payload is a number representation 280 | 281 | ### Sentry tunnel 282 | 283 | #### Problem 284 | 285 | Sentry javascript tracker may be blocked by some ad-blocker. See [reference](https://docs.sentry.io/platforms/javascript/troubleshooting/#dealing-with-ad-blockers). 286 | 287 | The recommended workaround is to tunnel all the sentry traffic through a customized backend. 288 | 289 | #### Configuration 290 | 291 | Set the `TUNNEL_SENTRY_DSN` env variable to the same as the one defined on your front end app. 292 | This will ensure that this tunnel only accept and filters request from this specific DSN. 293 | 294 | #### Solution 295 | 296 | This endpoint expose a `POST` route, to tunnel all sentry requests. 297 | 298 | It is designed to accept request directly from the sentry SDK, and not to be used alone. 299 | We can still test it manually by sending the following curl request (replace the `dsn` value by the one you set in `TUNNEL_SENTRY_DSN`) 300 | 301 | #### Test request 302 | 303 | ```bash 304 | curl 'http://localhost:3005/sentry' \ 305 | --data-raw $'{"sent_at":"2023-07-09T08:33:20.789Z","sdk":{"name":"sentry.javascript.vue","version":"7.55.2"},"dsn":"https://d70c3273a4674febbfbd6e767b597290@o4505452248563712.ingest.sentry.io/4505453376372736"}\n{"type":"session"}\n{"sid":"581f36ab63e747de98eb05e0cf820818","init":true,"started":"2023-07-09T08:33:20.788Z","timestamp":"2023-07-09T08:33:20.788Z","status":"ok","errors":0,"attrs":{"release":"snapshot@0.1.4","environment":"production","user_agent":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36"}}' \ 306 | --compressed 307 | ``` 308 | 309 | The request should return a `200` status code. 310 | 311 | ### Errors 312 | 313 | All endpoints will respond with a [JSON-RPC 2.0](https://www.jsonrpc.org/specification) error response on error: 314 | 315 | ```bash 316 | { 317 | "jsonrpc": "2.0", 318 | "error":{ 319 | "code": CODE, 320 | "message": MESSAGE 321 | }, 322 | "id": ID 323 | } 324 | ``` 325 | 326 | | Description | `CODE` | `MESSAGE` | 327 | | ----------------------------------- | ------ | -------------------- | 328 | | When the record does not exist | 404 | RECORD_NOT_FOUND | 329 | | When the file is pending generation | 202 | PENDING_GENERATION | 330 | | Other/Unknown/Server Error | -32603 | INTERNAL_ERROR | 331 | | Other error | 500 | Depends on the error | 332 | 333 | ## Build for production 334 | 335 | ```bash 336 | yarn build 337 | yarn start 338 | ``` 339 | 340 | ## License 341 | 342 | [MIT](LICENCE) 343 | -------------------------------------------------------------------------------- /data/verifiedTokens.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Snapshot Labs Default", 3 | "timestamp": "2023-05-17T00:00:00.000Z", 4 | "version": { 5 | "major": 0, 6 | "minor": 0, 7 | "patch": 0 8 | }, 9 | "tags": {}, 10 | "logoURI": "", 11 | "keywords": ["snapshot", "default"], 12 | "tokens": [ 13 | { 14 | "name": "Wrapped Ether", 15 | "address": "0xB4FBF271143F4FBf7B91A5ded31805e42b2208d6", 16 | "symbol": "WETH", 17 | "decimals": 18, 18 | "chainId": 5, 19 | "logoURI": "" 20 | } 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * For a detailed explanation regarding each configuration property and type check, visit: 3 | * https://jestjs.io/docs/configuration 4 | */ 5 | 6 | export default { 7 | clearMocks: true, 8 | collectCoverage: true, 9 | coverageThreshold: { 10 | global: { 11 | lines: 50 12 | } 13 | }, 14 | collectCoverageFrom: ['./src/**'], 15 | coverageDirectory: 'coverage', 16 | coverageProvider: 'v8', 17 | 18 | // An array of regexp pattern strings used to skip coverage collection 19 | coveragePathIgnorePatterns: ['/node_modules/', '/dist/', '/test/fixtures/'], 20 | 21 | preset: 'ts-jest', 22 | testEnvironment: 'jest-environment-node-single-context', 23 | setupFiles: ['dotenv/config'], 24 | //setupFilesAfterEnv: ['src/setupTests.ts'], 25 | moduleFileExtensions: ['js', 'ts'], 26 | testPathIgnorePatterns: ['dist/'], 27 | verbose: true 28 | }; 29 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "snapshot-sidekick", 3 | "version": "0.0.1", 4 | "main": "index.js", 5 | "repository": "snapshot-labs/snapshot-sidekick", 6 | "license": "MIT", 7 | "scripts": { 8 | "lint": "eslint src/ test/ data/ --ext .ts,.json", 9 | "lint:fix": "yarn lint --fix", 10 | "typecheck": "tsc --noEmit", 11 | "build": "tsc -p tsconfig.json && copyfiles data/*.json data/*.txt public/* dist/", 12 | "dev": "nodemon src/index.ts -e js,ts", 13 | "start": "node dist/src/index.js", 14 | "start:test": "dotenv -e test/.env.test yarn dev", 15 | "test": "PORT=3003 start-server-and-test 'yarn start:test' http://localhost:3003 'dotenv -e test/.env.test jest --runInBand'", 16 | "test:unit": "dotenv -e test/.env.test jest test/unit/", 17 | "test:integration": "dotenv -e test/.env.test jest test/integration/", 18 | "test:e2e": "PORT=3003 start-server-and-test 'yarn start:test' http://localhost:3003 'dotenv -e test/.env.test jest --runInBand --collectCoverage=false test/e2e/'", 19 | "test:e2e:update-snapshot": "PORT=3003 start-server-and-test 'yarn start:test' http://localhost:3003 'dotenv -e test/.env.test jest --runInBand --collectCoverage=false --updateSnapshot test/e2e/'" 20 | }, 21 | "eslintConfig": { 22 | "extends": "@snapshot-labs" 23 | }, 24 | "engines": { 25 | "node": "^18.0.0" 26 | }, 27 | "prettier": "@snapshot-labs/prettier-config", 28 | "dependencies": { 29 | "@apollo/client": "^3.9.2", 30 | "@aws-sdk/client-s3": "^3.367.0", 31 | "@ethersproject/abi": "^5.7.0", 32 | "@ethersproject/address": "^5.7.0", 33 | "@ethersproject/bignumber": "^5.7.0", 34 | "@ethersproject/bytes": "^5.7.0", 35 | "@ethersproject/contracts": "^5.7.0", 36 | "@ethersproject/transactions": "^5.7.0", 37 | "@ethersproject/wallet": "^5.7.0", 38 | "@snapshot-labs/snapshot-metrics": "^1.4.1", 39 | "@snapshot-labs/snapshot-sentry": "^1.5.5", 40 | "@snapshot-labs/snapshot.js": "^0.11.16", 41 | "bluebird": "^3.7.2", 42 | "body-parser": "^1.20.2", 43 | "compression": "^1.7.4", 44 | "connection-string": "^4.4.0", 45 | "cors": "^2.8.5", 46 | "express": "^4.18.2", 47 | "graphql": "^16.7.1", 48 | "multiformats": "^9", 49 | "mysql": "^2.18.1", 50 | "node-fetch": "^2.7.0", 51 | "openai": "^4.29.2", 52 | "remove-markdown": "^0.5.0" 53 | }, 54 | "devDependencies": { 55 | "@snapshot-labs/eslint-config": "^0.1.0-beta.15", 56 | "@snapshot-labs/prettier-config": "^0.1.0-beta.7", 57 | "@types/bluebird": "^3.5.38", 58 | "@types/compression": "^1.7.2", 59 | "@types/cors": "^2.8.13", 60 | "@types/express": "^4.17.17", 61 | "@types/jest": "^29.5.3", 62 | "@types/mysql": "^2.15.21", 63 | "@types/node": "^20.4.8", 64 | "@types/node-fetch": "^2.6.4", 65 | "@types/remove-markdown": "^0.3.4", 66 | "@types/supertest": "^2.0.12", 67 | "@typescript-eslint/eslint-plugin": "^6.7.2", 68 | "copyfiles": "^2.4.1", 69 | "dotenv": "^16.3.1", 70 | "dotenv-cli": "^7.2.1", 71 | "eslint": "^8.47.0", 72 | "eslint-plugin-prettier": "5", 73 | "jest": "^29.6.2", 74 | "jest-environment-node-single-context": "^29.1.0", 75 | "nodemon": "^3.0.1", 76 | "prettier": "^3.0.1", 77 | "start-server-and-test": "^2.0.0", 78 | "supertest": "^6.3.3", 79 | "ts-jest": "^29.1.1", 80 | "ts-node": "^10.9.1", 81 | "typescript": "^5.1.6" 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/api.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import { capture } from '@snapshot-labs/snapshot-sentry'; 3 | import { rpcError, rpcSuccess, storageEngine } from './helpers/utils'; 4 | import getModerationList from './lib/moderationList'; 5 | import VotesReport from './lib/votesReport'; 6 | import mintPayload from './lib/nftClaimer/mint'; 7 | import deployPayload from './lib/nftClaimer/deploy'; 8 | import { queue, getProgress } from './lib/queue'; 9 | import { snapshotFee } from './lib/nftClaimer/utils'; 10 | import AiSummary from './lib/ai/summary'; 11 | import AiTextToSpeech from './lib/ai/textToSpeech'; 12 | import { getDomain } from './lib/domain'; 13 | 14 | const router = express.Router(); 15 | 16 | router.all('/votes/:id', async (req, res) => { 17 | const { id } = req.params; 18 | const votesReport = new VotesReport(id, storageEngine(process.env.VOTE_REPORT_SUBDIR)); 19 | 20 | try { 21 | const file = await votesReport.getCache(); 22 | 23 | if (file) { 24 | res.header('Content-Type', 'text/csv'); 25 | res.attachment(votesReport.filename); 26 | return res.end(file); 27 | } 28 | 29 | try { 30 | await votesReport.isCacheable(); 31 | queue(votesReport); 32 | return rpcSuccess(res.status(202), getProgress(id).toString(), id); 33 | } catch (e: any) { 34 | capture(e); 35 | rpcError(res, e, id); 36 | } 37 | } catch (e) { 38 | capture(e); 39 | return rpcError(res, 'INTERNAL_ERROR', id); 40 | } 41 | }); 42 | 43 | router.post('/ai/summary/:id', async (req, res) => { 44 | const { id } = req.params; 45 | const aiSummary = new AiSummary(id, storageEngine(process.env.AI_SUMMARY_SUBDIR)); 46 | 47 | try { 48 | const cachedSummary = await aiSummary.getCache(); 49 | 50 | let summary = ''; 51 | 52 | if (!cachedSummary) { 53 | summary = (await aiSummary.createCache()).toString(); 54 | } else { 55 | summary = cachedSummary.toString(); 56 | } 57 | 58 | return rpcSuccess(res.status(200), summary, id); 59 | } catch (e: any) { 60 | capture(e); 61 | return rpcError(res, e.message || 'INTERNAL_ERROR', id); 62 | } 63 | }); 64 | 65 | router.post('/ai/tts/:id', async (req, res) => { 66 | const { id } = req.params; 67 | const aiTextTpSpeech = new AiTextToSpeech(id, storageEngine(process.env.AI_TTS_SUBDIR)); 68 | 69 | try { 70 | const cachedAudio = await aiTextTpSpeech.getCache(); 71 | 72 | let audio: Buffer; 73 | 74 | if (!cachedAudio) { 75 | try { 76 | audio = (await aiTextTpSpeech.createCache()) as Buffer; 77 | } catch (e: any) { 78 | capture(e); 79 | return rpcError(res, e, id); 80 | } 81 | } else { 82 | audio = cachedAudio as Buffer; 83 | } 84 | 85 | res.header('Content-Type', 'audio/mpeg'); 86 | res.attachment(aiTextTpSpeech.filename); 87 | return res.end(audio); 88 | } catch (e: any) { 89 | capture(e); 90 | return rpcError(res, e.message || 'INTERNAL_ERROR', id); 91 | } 92 | }); 93 | 94 | router.get('/moderation', async (req, res) => { 95 | const { list } = req.query; 96 | 97 | try { 98 | res.json(await getModerationList(list ? (list as string).split(',') : undefined)); 99 | } catch (e) { 100 | capture(e); 101 | return rpcError(res, 'INTERNAL_ERROR', ''); 102 | } 103 | }); 104 | 105 | router.get('/domains/:domain', async (req, res) => { 106 | const { domain } = req.params; 107 | 108 | try { 109 | res.json({ domain, space_id: getDomain(domain) }); 110 | } catch (e) { 111 | capture(e); 112 | return rpcError(res, 'INTERNAL_ERROR', ''); 113 | } 114 | }); 115 | 116 | router.get('/nft-claimer', async (req, res) => { 117 | try { 118 | return res.json({ snapshotFee: await snapshotFee() }); 119 | } catch (e: any) { 120 | capture(e); 121 | return rpcError(res, e, ''); 122 | } 123 | }); 124 | 125 | router.post('/nft-claimer/deploy', async (req, res) => { 126 | const { address, id, salt, maxSupply, mintPrice, spaceTreasury, proposerFee } = req.body; 127 | try { 128 | return res.json( 129 | await deployPayload({ 130 | spaceOwner: address, 131 | id, 132 | maxSupply, 133 | mintPrice, 134 | proposerFee, 135 | salt, 136 | spaceTreasury 137 | }) 138 | ); 139 | } catch (e: any) { 140 | capture(e, { body: req.body }); 141 | return rpcError(res, e, salt); 142 | } 143 | }); 144 | 145 | router.post('/nft-claimer/mint', async (req, res) => { 146 | const { proposalAuthor, address, id, salt } = req.body; 147 | try { 148 | return res.json(await mintPayload({ proposalAuthor, recipient: address, id, salt })); 149 | } catch (e: any) { 150 | capture(e, { body: req.body }); 151 | return rpcError(res, e, salt); 152 | } 153 | }); 154 | 155 | router.get('/proxy/:url', async (req, res) => { 156 | const { url } = req.params; 157 | 158 | try { 159 | const response = await fetch(url); 160 | 161 | return res.json(await response.json()); 162 | } catch (e: any) { 163 | res.status(500).json({ 164 | error: { 165 | code: 500, 166 | message: 'failed to fetch URL' 167 | } 168 | }); 169 | } 170 | }); 171 | 172 | export default router; 173 | -------------------------------------------------------------------------------- /src/helpers/mysql.ts: -------------------------------------------------------------------------------- 1 | import mysql from 'mysql'; 2 | // @ts-ignore 3 | import Pool from 'mysql/lib/Pool'; 4 | // @ts-ignore 5 | import Connection from 'mysql/lib/Connection'; 6 | import bluebird from 'bluebird'; 7 | import { ConnectionString } from 'connection-string'; 8 | 9 | type values = string | number | boolean | null; 10 | export type SqlRow = Record; 11 | type SqlQueryArgs = values | Record; 12 | 13 | interface PromisedPool { 14 | queryAsync: (query: string, args?: SqlQueryArgs | SqlQueryArgs[]) => Promise; 15 | endAsync: () => Promise; 16 | } 17 | 18 | const config = new ConnectionString(process.env.DATABASE_URL || ''); 19 | bluebird.promisifyAll([Pool, Connection]); 20 | 21 | const db: PromisedPool = mysql.createPool({ 22 | ...config, 23 | host: config.hosts?.[0].name, 24 | port: config.hosts?.[0].port, 25 | connectionLimit: parseInt(process.env.CONNECTION_LIMIT || '10'), 26 | multipleStatements: true, 27 | connectTimeout: 60e3, 28 | acquireTimeout: 60e3, 29 | timeout: 60e3, 30 | charset: 'utf8mb4', 31 | database: config.path?.[0] 32 | }) as mysql.Pool & PromisedPool; 33 | 34 | export default db; 35 | -------------------------------------------------------------------------------- /src/helpers/schema.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE moderation ( 2 | `action` VARCHAR(256) NOT NULL, 3 | `type` VARCHAR(256) NOT NULL, 4 | `value` VARCHAR(256) NOT NULL, 5 | `created` INT(11) NOT NULL, 6 | PRIMARY KEY (`type`, `value`), 7 | INDEX `action` (`action`), 8 | INDEX `created` (`created`) 9 | ); 10 | -------------------------------------------------------------------------------- /src/helpers/snapshot.ts: -------------------------------------------------------------------------------- 1 | import { gql, ApolloClient, InMemoryCache, createHttpLink } from '@apollo/client/core'; 2 | import { setContext } from '@apollo/client/link/context'; 3 | import { fetchWithKeepAlive } from './utils'; 4 | 5 | export type Proposal = { 6 | id: string; 7 | state: string; 8 | type: string; 9 | choices: string[]; 10 | space: Space; 11 | votes: number; 12 | author: string; 13 | title: string; 14 | body: string; 15 | discussion: string; 16 | privacy: string; 17 | }; 18 | 19 | export type Vote = { 20 | ipfs: string; 21 | voter: string; 22 | choice: Record | number | number[]; 23 | vp: number; 24 | reason: string; 25 | created: number; 26 | }; 27 | 28 | export type Space = { 29 | id: string; 30 | network: string; 31 | name: string; 32 | }; 33 | 34 | export type Network = { 35 | id: string; 36 | premium: boolean; 37 | }; 38 | 39 | const httpLink = createHttpLink({ 40 | uri: `${process.env.HUB_URL || 'https://hub.snapshot.org'}/graphql`, 41 | fetch: fetchWithKeepAlive 42 | }); 43 | 44 | const authLink = setContext((_, { headers }) => { 45 | const apiHeaders: Record = {}; 46 | const apiKey = process.env.KEYCARD_API_KEY; 47 | 48 | if (apiKey && apiKey.length > 0) { 49 | apiHeaders['x-api-key'] = apiKey; 50 | } 51 | 52 | return { 53 | headers: { 54 | ...headers, 55 | ...apiHeaders 56 | } 57 | }; 58 | }); 59 | 60 | const client = new ApolloClient({ 61 | link: authLink.concat(httpLink), 62 | cache: new InMemoryCache({ 63 | addTypename: false 64 | }), 65 | defaultOptions: { 66 | query: { 67 | fetchPolicy: 'no-cache' 68 | } 69 | } 70 | }); 71 | 72 | const PROPOSAL_QUERY = gql` 73 | query Proposal($id: String!) { 74 | proposal(id: $id) { 75 | id 76 | state 77 | type 78 | choices 79 | votes 80 | title 81 | body 82 | discussion 83 | author 84 | privacy 85 | space { 86 | id 87 | network 88 | name 89 | } 90 | } 91 | } 92 | `; 93 | 94 | const VOTES_QUERY = gql` 95 | query Votes( 96 | $id: String! 97 | $first: Int 98 | $skip: Int 99 | $orderBy: String 100 | $orderDirection: OrderDirection 101 | $created_gte: Int 102 | ) { 103 | votes( 104 | first: $first 105 | skip: $skip 106 | where: { proposal: $id, created_gte: $created_gte } 107 | orderBy: $orderBy 108 | orderDirection: $orderDirection 109 | ) { 110 | ipfs 111 | voter 112 | choice 113 | vp 114 | reason 115 | created 116 | } 117 | } 118 | `; 119 | 120 | const VOTE_QUERY = gql` 121 | query Votes($voter: String!, $proposalId: String!) { 122 | votes(first: 1, where: { voter: $voter, proposal: $proposalId }) { 123 | id 124 | } 125 | } 126 | `; 127 | 128 | const SPACE_QUERY = gql` 129 | query Space($id: String!) { 130 | space(id: $id) { 131 | id 132 | network 133 | } 134 | } 135 | `; 136 | 137 | const NETWORKS_QUERY = gql` 138 | query networks { 139 | networks { 140 | id 141 | premium 142 | } 143 | } 144 | `; 145 | 146 | export async function fetchProposal(id: string) { 147 | const { 148 | data: { proposal } 149 | }: { data: { proposal: Proposal | null } } = await client.query({ 150 | query: PROPOSAL_QUERY, 151 | variables: { 152 | id 153 | } 154 | }); 155 | 156 | return proposal; 157 | } 158 | 159 | export async function fetchVotes( 160 | id: string, 161 | { first = 1000, skip = 0, orderBy = 'created_gte', orderDirection = 'asc', created_gte = 0 } = {} 162 | ) { 163 | const { 164 | data: { votes } 165 | }: { data: { votes: Vote[] } } = await client.query({ 166 | query: VOTES_QUERY, 167 | variables: { 168 | id, 169 | orderBy, 170 | orderDirection, 171 | first, 172 | skip, 173 | created_gte 174 | } 175 | }); 176 | 177 | return votes; 178 | } 179 | 180 | export async function fetchVote(voter: string, proposalId: string) { 181 | const { 182 | data: { votes } 183 | }: { data: { votes: Vote[] } } = await client.query({ 184 | query: VOTE_QUERY, 185 | variables: { 186 | voter, 187 | proposalId 188 | } 189 | }); 190 | 191 | return votes[0]; 192 | } 193 | 194 | export async function fetchSpace(id: string) { 195 | const { 196 | data: { space } 197 | }: { data: { space: Space | null } } = await client.query({ 198 | query: SPACE_QUERY, 199 | variables: { 200 | id 201 | } 202 | }); 203 | 204 | return space; 205 | } 206 | 207 | export async function fetchNetworks() { 208 | const { 209 | data: { networks } 210 | }: { data: { networks: Network[] } } = await client.query({ 211 | query: NETWORKS_QUERY 212 | }); 213 | 214 | return networks; 215 | } 216 | -------------------------------------------------------------------------------- /src/helpers/utils.ts: -------------------------------------------------------------------------------- 1 | import http from 'node:http'; 2 | import https from 'node:https'; 3 | import FileStorageEngine from '../lib/storage/file'; 4 | import AwsStorageEngine from '../lib/storage/aws'; 5 | import type { Response } from 'express'; 6 | 7 | const ERROR_CODES: Record = { 8 | 'Invalid Request': -32600, 9 | RECORD_NOT_FOUND: 404, 10 | UNAUTHORIZED: 401 11 | }; 12 | 13 | export function rpcSuccess(res: Response, result: string, id: string | number) { 14 | res.json({ 15 | jsonrpc: '2.0', 16 | result, 17 | id 18 | }); 19 | } 20 | 21 | export function rpcError(res: Response, e: Error | string, id: string | number) { 22 | const errorMessage = e instanceof Error ? e.message : e; 23 | const errorCode = ERROR_CODES[errorMessage] ? ERROR_CODES[errorMessage] : -32603; 24 | 25 | res.status(errorCode > 0 ? errorCode : 500).json({ 26 | jsonrpc: '2.0', 27 | error: { 28 | code: errorCode, 29 | message: errorMessage 30 | }, 31 | id 32 | }); 33 | } 34 | 35 | export async function sleep(time: number) { 36 | return new Promise(resolve => { 37 | setTimeout(resolve, time); 38 | }); 39 | } 40 | 41 | export function storageEngine(subDir?: string) { 42 | if (process.env.STORAGE_ENGINE === 'aws') { 43 | return new AwsStorageEngine(subDir); 44 | } else { 45 | return new FileStorageEngine(subDir); 46 | } 47 | } 48 | 49 | const agentOptions = { keepAlive: true }; 50 | const httpAgent = new http.Agent(agentOptions); 51 | const httpsAgent = new https.Agent(agentOptions); 52 | 53 | function agent(url: string) { 54 | return new URL(url).protocol === 'http:' ? httpAgent : httpsAgent; 55 | } 56 | 57 | export const fetchWithKeepAlive = (uri: any, options: any = {}) => { 58 | return fetch(uri, { agent: agent(uri), ...options }); 59 | }; 60 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import 'dotenv/config'; 2 | import express from 'express'; 3 | import compression from 'compression'; 4 | import cors from 'cors'; 5 | import { initLogger, fallbackLogger } from '@snapshot-labs/snapshot-sentry'; 6 | import api from './api'; 7 | import webhook from './webhook'; 8 | import sentryTunnel from './sentryTunnel'; 9 | import './lib/queue'; 10 | import { name, version } from '../package.json'; 11 | import { rpcError } from './helpers/utils'; 12 | import initMetrics from './lib/metrics'; 13 | import initCacheRefresher from './lib/cacheRefresher'; 14 | import { initDomainsRefresher } from './lib/domain'; 15 | 16 | const app = express(); 17 | const PORT = process.env.PORT || 3005; 18 | 19 | initLogger(app); 20 | initMetrics(app); 21 | initCacheRefresher(); 22 | initDomainsRefresher(); 23 | 24 | app.disable('x-powered-by'); 25 | app.use(express.json({ limit: '4mb' })); 26 | app.use(cors({ maxAge: 86400 })); 27 | app.use(compression()); 28 | app.use('/api', api); 29 | app.use('/', webhook); 30 | app.use('/', sentryTunnel); 31 | 32 | app.get('/', (req, res) => { 33 | const commit = process.env.COMMIT_HASH || ''; 34 | const v = commit ? `${version}#${commit.substring(0, 7)}` : version; 35 | return res.json({ 36 | name, 37 | version: v 38 | }); 39 | }); 40 | 41 | fallbackLogger(app); 42 | 43 | app.use((_, res) => { 44 | rpcError(res, 'RECORD_NOT_FOUND', ''); 45 | }); 46 | 47 | app.listen(PORT, () => console.log(`[http] Start server at http://localhost:${PORT}`)); 48 | -------------------------------------------------------------------------------- /src/lib/ai/summary.ts: -------------------------------------------------------------------------------- 1 | import OpenAI from 'openai'; 2 | import { fetchProposal, Proposal } from '../../helpers/snapshot'; 3 | import { IStorage } from '../storage/types'; 4 | import Cache from '../cache'; 5 | 6 | class Summary extends Cache { 7 | proposal?: Proposal | null; 8 | openAi: OpenAI; 9 | 10 | constructor(id: string, storage: IStorage) { 11 | super(id, storage); 12 | this.filename = `snapshot-proposal-ai-summary-${this.id}.txt`; 13 | this.openAi = new OpenAI({ apiKey: process.env.OPENAI_API_KEY || 'Missing key' }); 14 | } 15 | 16 | async isCacheable() { 17 | this.proposal = await fetchProposal(this.id); 18 | 19 | if (!this.proposal) { 20 | throw new Error('RECORD_NOT_FOUND'); 21 | } 22 | 23 | return true; 24 | } 25 | 26 | getContent = async () => { 27 | this.isCacheable(); 28 | 29 | try { 30 | const { body, title, space } = this.proposal!; 31 | 32 | const completion = await this.openAi.chat.completions.create({ 33 | messages: [ 34 | { 35 | role: 'system', 36 | content: `The following is a governance proposal of '${space.name}'. Generate me a summary in 350 characters max. Here's the title of the proposal: '${title}'. Here's the content of the proposal: '${body}'. Do not repeat the title. Do not format the answer (no markdown, no HTML).` 37 | } 38 | ], 39 | model: 'gpt-4o' 40 | }); 41 | 42 | if (completion.choices.length === 0) { 43 | throw new Error('EMPTY_OPENAI_CHOICES'); 44 | } 45 | const content = completion.choices[0].message.content; 46 | 47 | if (!content) { 48 | throw new Error('EMPTY_OPENAI_RESPONSE'); 49 | } 50 | 51 | return content; 52 | } catch (e: any) { 53 | throw e.error?.code ? new Error(e.error?.code.toUpperCase()) : e; 54 | } 55 | }; 56 | } 57 | 58 | export default Summary; 59 | -------------------------------------------------------------------------------- /src/lib/ai/textToSpeech.ts: -------------------------------------------------------------------------------- 1 | import OpenAI from 'openai'; 2 | import removeMd from 'remove-markdown'; 3 | import Cache from '../cache'; 4 | import { fetchProposal, Proposal } from '../../helpers/snapshot'; 5 | import { IStorage } from '../storage/types'; 6 | 7 | const MIN_BODY_LENGTH = 1; 8 | const MAX_BODY_LENGTH = 4096; 9 | 10 | export default class TextToSpeech extends Cache { 11 | proposal?: Proposal | null; 12 | openAi: OpenAI; 13 | 14 | constructor(id: string, storage: IStorage) { 15 | super(id, storage); 16 | this.filename = `snapshot-proposal-ai-tts-${this.id}.mp3`; 17 | this.openAi = new OpenAI({ apiKey: process.env.OPENAI_API_KEY || 'Missing key' }); 18 | } 19 | 20 | async isCacheable() { 21 | this.proposal = await fetchProposal(this.id); 22 | 23 | if (!this.proposal) { 24 | throw new Error('RECORD_NOT_FOUND'); 25 | } 26 | 27 | return true; 28 | } 29 | 30 | getContent = async () => { 31 | this.isCacheable(); 32 | const body = removeMd(this.proposal!.body); 33 | 34 | if (body.length < MIN_BODY_LENGTH || body.length > MAX_BODY_LENGTH) { 35 | throw new Error('UNSUPPORTED_PROPOSAL'); 36 | } 37 | 38 | try { 39 | const mp3 = await this.openAi.audio.speech.create({ 40 | model: 'tts-1', 41 | voice: 'alloy', 42 | input: body 43 | }); 44 | 45 | return Buffer.from(await mp3.arrayBuffer()); 46 | } catch (e: any) { 47 | throw e.error?.code ? new Error(e.error?.code.toUpperCase()) : e; 48 | } 49 | }; 50 | } 51 | -------------------------------------------------------------------------------- /src/lib/cache.ts: -------------------------------------------------------------------------------- 1 | import { IStorage } from './storage/types'; 2 | import { cacheHitCount } from './metrics'; 3 | 4 | export default class Cache { 5 | id: string; 6 | filename: string; 7 | storage: IStorage; 8 | generationProgress: number; 9 | 10 | constructor(id: string, storage: IStorage) { 11 | this.id = id; 12 | this.filename = `${id}.cache`; 13 | this.storage = storage; 14 | this.generationProgress = 0; 15 | } 16 | 17 | async getContent(): Promise { 18 | return ''; 19 | } 20 | 21 | async getCache() { 22 | const cache = await this.storage.get(this.filename); 23 | 24 | cacheHitCount.inc({ status: !cache ? 'MISS' : 'HIT', type: this.constructor.name }); 25 | 26 | return cache; 27 | } 28 | 29 | async isCacheable() { 30 | return true; 31 | } 32 | 33 | afterCreateCache() {} 34 | 35 | async createCache() { 36 | await this.isCacheable(); 37 | const content = await this.getContent(); 38 | 39 | console.log(`[votes-report] File cache ready to be saved`); 40 | 41 | this.storage.set(this.filename, content); 42 | this.afterCreateCache(); 43 | 44 | return content; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/lib/cacheRefresher.ts: -------------------------------------------------------------------------------- 1 | import { capture } from '@snapshot-labs/snapshot-sentry'; 2 | import VotesReport from './votesReport'; 3 | import { sleep, storageEngine } from '../helpers/utils'; 4 | import { queue } from './queue'; 5 | 6 | const REFRESH_INTERVAL = 15 * 60 * 1e3; 7 | const INDEX_FILENAME = 'snapshot-active-proposals-list.csv'; 8 | const storage = storageEngine(process.env.VOTE_REPORT_SUBDIR); 9 | 10 | async function processItems(): Promise { 11 | const list = await getIndex(); 12 | 13 | list.forEach((id: string) => queue(new VotesReport(id, storage))); 14 | 15 | return list.length; 16 | } 17 | 18 | export async function getIndex(): Promise { 19 | const cachedList = await storage.get(INDEX_FILENAME); 20 | return cachedList ? JSON.parse(cachedList.toString()) : []; 21 | } 22 | 23 | export async function setIndex(list: string[]) { 24 | return storage.set(INDEX_FILENAME, JSON.stringify(list)); 25 | } 26 | 27 | export default async function run() { 28 | try { 29 | console.log(`[cache-refresh] Refreshing active proposals cache`); 30 | const count = await processItems(); 31 | console.log(`[cache-refresh] ${count} proposals queued for refresh`); 32 | } catch (e) { 33 | capture(e); 34 | } finally { 35 | await sleep(REFRESH_INTERVAL); 36 | await run(); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/lib/domain.ts: -------------------------------------------------------------------------------- 1 | import { capture } from '@snapshot-labs/snapshot-sentry'; 2 | import { sleep } from '../helpers/utils'; 3 | 4 | const LIST_URL = 5 | 'https://raw.githubusercontent.com/snapshot-labs/snapshot-spaces/master/spaces/domains.json'; 6 | 7 | const REFRESH_INTERVAL = 1000 * 60 * 5; // 5 minutes 8 | 9 | // Map of domain (vote.snapshot.org) to space ID (s:snapshot.eth/eth:0x0) 10 | let data = new Map(); 11 | 12 | export function getDomain(domain: string): string | null { 13 | return data.get(domain.toLowerCase()) ?? null; 14 | } 15 | 16 | export async function initDomainsRefresher() { 17 | try { 18 | console.log(`[domains-refresh] Refreshing domains list`); 19 | await refreshList(); 20 | console.log(`[domains-refresh] ${data.size} domains found`); 21 | } catch (e) { 22 | capture(e); 23 | } finally { 24 | await sleep(REFRESH_INTERVAL); 25 | await initDomainsRefresher(); 26 | } 27 | } 28 | 29 | async function refreshList() { 30 | const response = await fetch(LIST_URL, { 31 | headers: { 32 | 'content-type': 'application/json' 33 | } 34 | }); 35 | 36 | const body: Record = await response.json(); 37 | 38 | data = new Map(Object.entries(body).map(([domain, spaceId]) => [domain, `s:${spaceId}`])); 39 | } 40 | -------------------------------------------------------------------------------- /src/lib/metrics/digitalOcean.ts: -------------------------------------------------------------------------------- 1 | import { capture } from '@snapshot-labs/snapshot-sentry'; 2 | import { fetchWithKeepAlive } from '../../helpers/utils'; 3 | 4 | const BASE_URL = 'https://api.digitalocean.com/v2'; 5 | const TTL = 60 * 1000; // 1 minute 6 | 7 | export default class DigitalOcean { 8 | key?: string; 9 | cache: Record = {}; 10 | expirationTime = 0; 11 | 12 | constructor() { 13 | this.key = process.env.DIGITAL_OCEAN_TOKEN; 14 | 15 | if (!this.key) { 16 | throw new Error('MISSING_KEY'); 17 | } 18 | } 19 | 20 | async apps() { 21 | try { 22 | const results = await this.#fetch(`apps`); 23 | const apps: Record = {}; 24 | 25 | for (const app of results.apps) { 26 | apps[app.spec.name] = { 27 | tier: app.spec.services[0].instance_size_slug, 28 | instance_count: app.spec.services[0].instance_count, 29 | region: app.region.slug, 30 | country: app.region.flag, 31 | last_active_deploy: app.last_deployment_active_at 32 | }; 33 | } 34 | 35 | return apps; 36 | } catch (e: any) { 37 | capture(e); 38 | return false; 39 | } 40 | } 41 | 42 | async appInstances() { 43 | try { 44 | const results = await this.#fetch(`apps/tiers/instance_sizes`); 45 | const instances: Record = {}; 46 | 47 | for (const datum of results.instance_sizes) { 48 | instances[datum.slug] = datum; 49 | } 50 | 51 | return instances; 52 | } catch (e: any) { 53 | capture(e); 54 | return false; 55 | } 56 | } 57 | 58 | async balances() { 59 | try { 60 | return await this.#fetch(`customers/my/balance`); 61 | } catch (e: any) { 62 | capture(e); 63 | return false; 64 | } 65 | } 66 | 67 | async #fetch(path: string) { 68 | const cacheKey = path; 69 | const cachedEntry = this.cache[cacheKey]; 70 | const now = Date.now(); 71 | if (cachedEntry && this.expirationTime > now) { 72 | return cachedEntry; 73 | } 74 | 75 | if (this.expirationTime < now) { 76 | this.cache = {}; 77 | this.expirationTime = now + TTL; 78 | } 79 | 80 | const url = `${BASE_URL}/${path}`; 81 | const response = await fetchWithKeepAlive(url, { 82 | headers: { Authorization: `Bearer ${this.key}` } 83 | }); 84 | 85 | const result = await response.json(); 86 | 87 | if (response.status !== 200 || result.error) { 88 | throw new Error(result.error || result.message); 89 | } 90 | 91 | this.cache[cacheKey] = result; 92 | 93 | return result; 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/lib/metrics/index.ts: -------------------------------------------------------------------------------- 1 | import init, { client } from '@snapshot-labs/snapshot-metrics'; 2 | import networks from '@snapshot-labs/snapshot.js/src/networks.json'; 3 | import snapshot from '@snapshot-labs/snapshot.js'; 4 | import { Wallet } from '@ethersproject/wallet'; 5 | import { capture } from '@snapshot-labs/snapshot-sentry'; 6 | import { size as queueSize } from '../queue'; 7 | import getModerationList from '../moderationList'; 8 | import DigitalOcean from './digitalOcean'; 9 | import type { Express } from 'express'; 10 | import db from '../../helpers/mysql'; 11 | import { fetchNetworks } from '../../helpers/snapshot'; 12 | 13 | export default function initMetrics(app: Express) { 14 | init(app, { 15 | normalizedPath: [['^/api/votes/.*', '/api/votes/#id']], 16 | whitelistedPath: [ 17 | /^\/$/, 18 | /^\/api\/votes\/.*$/, 19 | /^\/api\/(nft-claimer)(\/(deploy|mint))?$/, 20 | /^\/api\/moderation$/, 21 | /^\/(webhook|sentry)$/ 22 | ], 23 | errorHandler: capture, 24 | db 25 | }); 26 | 27 | run(); 28 | } 29 | 30 | new client.Gauge({ 31 | name: 'queue_size', 32 | help: 'Number of items in the cache queue', 33 | async collect() { 34 | this.set(queueSize()); 35 | } 36 | }); 37 | 38 | new client.Gauge({ 39 | name: 'moderation_items_per_type_count', 40 | help: 'Number of items per moderation list', 41 | labelNames: ['type'], 42 | async collect() { 43 | const list = await getModerationList(); 44 | for (const type in list) { 45 | const data = list[type] as any; 46 | this.set({ type }, Array.isArray(data) ? data.length : data.tokens.length); 47 | } 48 | } 49 | }); 50 | 51 | export const timeQueueProcess = new client.Histogram({ 52 | name: 'queue_process_duration_seconds', 53 | help: 'Duration in seconds of each queue process', 54 | labelNames: ['name'], 55 | buckets: [3, 7, 10, 15, 30, 60, 120] 56 | }); 57 | 58 | try { 59 | const digitalOcean = new DigitalOcean(); 60 | 61 | new client.Gauge({ 62 | name: 'do_instances_per_app_count', 63 | help: 'Number of digital ocean instances per app', 64 | labelNames: ['name', 'tier', 'region', 'country', 'monthly_cost'], 65 | async collect() { 66 | const apps = await digitalOcean.apps(); 67 | 68 | if (!apps) { 69 | return; 70 | } 71 | 72 | for (const name in apps) { 73 | const { instance_count, tier, region, country } = apps[name]; 74 | this.set( 75 | { 76 | name, 77 | tier, 78 | region, 79 | country 80 | }, 81 | instance_count 82 | ); 83 | } 84 | } 85 | }); 86 | 87 | new client.Gauge({ 88 | name: 'do_app_cost', 89 | help: 'Monthly cost of each digital ocean app in USD', 90 | labelNames: ['name'], 91 | async collect() { 92 | const apps = await digitalOcean.apps(); 93 | const instances = await digitalOcean.appInstances(); 94 | 95 | if (!apps || !instances) { 96 | return; 97 | } 98 | 99 | for (const name in apps) { 100 | const instanceType = instances[apps[name].tier]; 101 | this.set( 102 | { 103 | name 104 | }, 105 | parseFloat(instanceType.usd_per_month) * apps[name].instance_count 106 | ); 107 | } 108 | } 109 | }); 110 | 111 | new client.Gauge({ 112 | name: 'do_app_last_active_deploy_timestamp', 113 | help: 'Last successful deployment timestamp per app', 114 | labelNames: ['name'], 115 | async collect() { 116 | const apps = await digitalOcean.apps(); 117 | 118 | if (!apps) { 119 | return; 120 | } 121 | 122 | for (const name in apps) { 123 | this.set({ name }, +Date.parse(apps[name].last_active_deploy)); 124 | } 125 | } 126 | }); 127 | 128 | new client.Gauge({ 129 | name: 'do_account_balance', 130 | help: 'DigitalOcean account balance in USD', 131 | async collect() { 132 | const balances = await digitalOcean.balances(); 133 | 134 | if (!balances) { 135 | return; 136 | } 137 | 138 | this.set(balances.account_balance); 139 | } 140 | }); 141 | 142 | new client.Gauge({ 143 | name: 'do_account_month_to_date_usage', 144 | help: 'DigitalOcean account month to date usage in USD', 145 | async collect() { 146 | const balances = await digitalOcean.balances(); 147 | 148 | if (!balances) { 149 | return; 150 | } 151 | 152 | this.set(balances.month_to_date_usage); 153 | } 154 | }); 155 | } catch (e: any) { 156 | if (e.message !== 'MISSING_KEY') { 157 | capture(e); 158 | } 159 | } 160 | 161 | export const cacheHitCount = new client.Counter({ 162 | name: 'cache_hit_count', 163 | help: 'Number of hit/miss of the cache engine', 164 | labelNames: ['status', 'type'] 165 | }); 166 | 167 | const providersResponseCode = new client.Gauge({ 168 | name: 'provider_response_code', 169 | help: 'Response code of each provider request', 170 | labelNames: ['network'] 171 | }); 172 | 173 | const providersTiming = new client.Gauge({ 174 | name: 'provider_duration_seconds', 175 | help: 'Duration in seconds of each provider request', 176 | labelNames: ['network', 'status'] 177 | }); 178 | 179 | const providersFullArchiveNodeAvailability = new client.Gauge({ 180 | name: 'provider_full_archive_node_availability', 181 | help: 'Availability of full archive node for each provider', 182 | labelNames: ['network'] 183 | }); 184 | 185 | const abi = ['function getEthBalance(address addr) view returns (uint256 balance)']; 186 | const wallet = new Wallet(process.env.NFT_CLAIMER_PRIVATE_KEY as string); 187 | let networkPivot = 0; 188 | let premiumNetworks: any[] = []; 189 | 190 | async function refreshPremiumNetworks() { 191 | const remoteNetworks = await fetchNetworks(); 192 | const premiumNetworkIds = remoteNetworks 193 | .filter((network: any) => network.premium) 194 | .map((network: any) => network.id); 195 | 196 | premiumNetworks = premiumNetworkIds 197 | .map((networkId: string) => networks[networkId as keyof typeof networks]) 198 | .filter(Boolean); 199 | } 200 | 201 | async function refreshProviderTiming(network: any) { 202 | const { key, multicall } = network; 203 | const end = providersTiming.startTimer({ network: key }); 204 | let status = 0; 205 | 206 | try { 207 | const provider = snapshot.utils.getProvider(key); 208 | await snapshot.utils.multicall( 209 | key, 210 | provider, 211 | abi, 212 | [wallet.address].map(adr => [multicall, 'getEthBalance', [adr]]), 213 | { 214 | blockTag: 'latest' 215 | } 216 | ); 217 | status = 1; 218 | providersResponseCode.set({ network: key }, 200); 219 | } catch (e: any) { 220 | providersResponseCode.set({ network: key }, parseInt(e?.error?.status || 0)); 221 | } finally { 222 | end({ status }); 223 | } 224 | } 225 | 226 | async function refreshFullArchiveNodeChecker(network: any) { 227 | const { key, start, multicall } = network; 228 | try { 229 | const provider = snapshot.utils.getProvider(key); 230 | 231 | await provider.getBalance(multicall, start); 232 | providersFullArchiveNodeAvailability.set({ network: key }, 1); 233 | } catch (e: any) { 234 | providersFullArchiveNodeAvailability.set({ network: key }, 0); 235 | } 236 | } 237 | 238 | async function run() { 239 | if (networkPivot === 0) { 240 | await refreshPremiumNetworks(); 241 | } 242 | 243 | const network = premiumNetworks[networkPivot]; 244 | 245 | refreshProviderTiming(network); 246 | refreshFullArchiveNodeChecker(network); 247 | 248 | networkPivot++; 249 | if (networkPivot > premiumNetworks.length - 1) networkPivot = 0; 250 | 251 | await snapshot.utils.sleep(5e3); 252 | run(); 253 | } 254 | -------------------------------------------------------------------------------- /src/lib/moderationList.ts: -------------------------------------------------------------------------------- 1 | import { readFileSync } from 'fs'; 2 | import path from 'path'; 3 | import db from '../helpers/mysql'; 4 | 5 | type MODERATION_LIST = Record; 6 | 7 | const CACHE_PATH = path.resolve(__dirname, `../../${process.env.MODERATION_LIST_PATH || 'data'}`); 8 | const FIELDS = new Map>([ 9 | ['flaggedLinks', { action: 'flag', type: 'link' }], 10 | ['flaggedIps', { action: 'flag', type: 'ip' }], 11 | ['flaggedAddresses', { action: 'flag', type: 'address' }], 12 | ['verifiedTokens', { file: 'verifiedTokens.json' }] 13 | ]); 14 | 15 | export function readFile(filename: string) { 16 | return parseFileContent( 17 | readFileSync(path.join(CACHE_PATH, filename), { encoding: 'utf8' }), 18 | filename.split('.')[1] 19 | ); 20 | } 21 | 22 | function parseFileContent(content: string, parser: string): MODERATION_LIST[keyof MODERATION_LIST] { 23 | switch (parser) { 24 | case 'txt': 25 | return content.split('\n').filter(value => value !== ''); 26 | case 'json': 27 | return JSON.parse(content); 28 | default: 29 | throw new Error('Invalid file type'); 30 | } 31 | } 32 | 33 | export default async function getModerationList(fields = Array.from(FIELDS.keys())) { 34 | const list: Partial = {}; 35 | const reverseMapping: Record = {}; 36 | const queryWhereStatement: string[] = []; 37 | let queryWhereArgs: string[] = []; 38 | 39 | fields.forEach(field => { 40 | if (FIELDS.has(field)) { 41 | const args = FIELDS.get(field) as Record; 42 | 43 | if (!args.file) { 44 | list[field] = []; 45 | reverseMapping[`${args.action}-${args.type}`] = field; 46 | 47 | queryWhereStatement.push(`(action = ? AND type = ?)`); 48 | queryWhereArgs = queryWhereArgs.concat([args.action, args.type]); 49 | } else { 50 | list[field] = readFile(args.file); 51 | } 52 | } 53 | }); 54 | 55 | if (queryWhereStatement.length > 0) { 56 | const dbResults = await db.queryAsync( 57 | `SELECT * FROM moderation WHERE ${queryWhereStatement.join(' OR ')}`, 58 | queryWhereArgs 59 | ); 60 | 61 | dbResults.forEach(row => { 62 | (list[reverseMapping[`${row.action}-${row.type}`]] as string[]).push(row.value as string); 63 | }); 64 | } 65 | 66 | return list; 67 | } 68 | -------------------------------------------------------------------------------- /src/lib/nftClaimer/deploy.ts: -------------------------------------------------------------------------------- 1 | import { getAddress } from '@ethersproject/address'; 2 | import { splitSignature } from '@ethersproject/bytes'; 3 | import { FormatTypes, Interface } from '@ethersproject/abi'; 4 | import { fetchSpace } from '../../helpers/snapshot'; 5 | import { signer, validateDeployInput, validateSpace } from './utils'; 6 | import spaceCollectionAbi from './spaceCollectionImplementationAbi.json'; 7 | import spaceFactoryAbi from './spaceFactoryAbi.json'; 8 | 9 | const DeployType = { 10 | Deploy: [ 11 | { name: 'implementation', type: 'address' }, 12 | { name: 'initializer', type: 'bytes' }, 13 | { name: 'salt', type: 'uint256' } 14 | ] 15 | }; 16 | 17 | const VERIFYING_CONTRACT = getAddress(process.env.NFT_CLAIMER_DEPLOY_VERIFYING_CONTRACT as string); 18 | const IMPLEMENTATION_ADDRESS = getAddress( 19 | process.env.NFT_CLAIMER_DEPLOY_IMPLEMENTATION_ADDRESS as string 20 | ); 21 | const NFT_CLAIMER_NETWORK = process.env.NFT_CLAIMER_NETWORK; 22 | const INITIALIZE_SELECTOR = process.env.NFT_CLAIMER_DEPLOY_INITIALIZE_SELECTOR; 23 | 24 | export default async function payload(input: { 25 | spaceOwner: string; 26 | id: string; 27 | maxSupply: string; 28 | mintPrice: string; 29 | proposerFee: string; 30 | salt: string; 31 | spaceTreasury: string; 32 | }) { 33 | const params = await validateDeployInput(input); 34 | 35 | const space = await fetchSpace(params.id); 36 | await validateSpace(params.spaceOwner, space); 37 | 38 | const initializer = getInitializer({ 39 | spaceOwner: params.spaceOwner, 40 | spaceId: space?.id as string, 41 | maxSupply: params.maxSupply, 42 | mintPrice: params.mintPrice, 43 | proposerFee: params.proposerFee, 44 | spaceTreasury: params.spaceTreasury 45 | }); 46 | const result = { 47 | initializer, 48 | salt: params.salt, 49 | abi: new Interface(spaceFactoryAbi).getFunction('deployProxy').format(FormatTypes.full), 50 | verifyingContract: VERIFYING_CONTRACT, 51 | implementation: IMPLEMENTATION_ADDRESS, 52 | signature: await generateSignature(IMPLEMENTATION_ADDRESS, initializer, params.salt) 53 | }; 54 | 55 | console.debug('Signer', signer.address); 56 | console.debug('Payload', result); 57 | 58 | return result; 59 | } 60 | 61 | function getInitializer(args: { 62 | spaceId: string; 63 | maxSupply: number; 64 | mintPrice: string; 65 | proposerFee: number; 66 | spaceTreasury: string; 67 | spaceOwner: string; 68 | }) { 69 | const params = [ 70 | args.spaceId, 71 | '0.1', 72 | args.maxSupply, 73 | BigInt(args.mintPrice), 74 | args.proposerFee, 75 | getAddress(args.spaceTreasury), 76 | getAddress(args.spaceOwner) 77 | ]; 78 | 79 | // This encodeFunctionData should ignore the last 4 params compared to 80 | // the smart contract version 81 | // NOTE Do not forget to remove the last 4 params in the ABI when copy/pasting 82 | // from the smart contract 83 | const initializer = new Interface(spaceCollectionAbi).encodeFunctionData('initialize', params); 84 | const result = `${INITIALIZE_SELECTOR}${initializer.slice(10)}`; 85 | 86 | console.debug('Initializer params', params); 87 | 88 | return result; 89 | } 90 | 91 | async function generateSignature(implementation: string, initializer: string, salt: string) { 92 | const params = { 93 | domain: { 94 | name: 'SpaceCollectionFactory', 95 | version: '0.1', 96 | chainId: NFT_CLAIMER_NETWORK, 97 | verifyingContract: VERIFYING_CONTRACT 98 | }, 99 | types: DeployType, 100 | value: { 101 | implementation, 102 | initializer, 103 | salt: BigInt(salt) 104 | } 105 | }; 106 | 107 | return splitSignature(await signer._signTypedData(params.domain, params.types, params.value)); 108 | } 109 | -------------------------------------------------------------------------------- /src/lib/nftClaimer/deployAbi.json: -------------------------------------------------------------------------------- 1 | [ 2 | { "inputs": [], "name": "CallerIsNotSnapshot", "type": "error" }, 3 | { "inputs": [], "name": "CallerIsNotTreasury", "type": "error" }, 4 | { 5 | "inputs": [{ "internalType": "uint8", "name": "proposerFee", "type": "uint8" }], 6 | "name": "InvalidFee", 7 | "type": "error" 8 | }, 9 | { "inputs": [], "name": "InvalidSignature", "type": "error" }, 10 | { "inputs": [], "name": "MaxSupplyReached", "type": "error" }, 11 | { "inputs": [], "name": "SaltAlreadyUsed", "type": "error" }, 12 | { 13 | "anonymous": false, 14 | "inputs": [ 15 | { "indexed": false, "internalType": "address", "name": "previousAdmin", "type": "address" }, 16 | { "indexed": false, "internalType": "address", "name": "newAdmin", "type": "address" } 17 | ], 18 | "name": "AdminChanged", 19 | "type": "event" 20 | }, 21 | { 22 | "anonymous": false, 23 | "inputs": [ 24 | { "indexed": true, "internalType": "address", "name": "account", "type": "address" }, 25 | { "indexed": true, "internalType": "address", "name": "operator", "type": "address" }, 26 | { "indexed": false, "internalType": "bool", "name": "approved", "type": "bool" } 27 | ], 28 | "name": "ApprovalForAll", 29 | "type": "event" 30 | }, 31 | { 32 | "anonymous": false, 33 | "inputs": [{ "indexed": true, "internalType": "address", "name": "beacon", "type": "address" }], 34 | "name": "BeaconUpgraded", 35 | "type": "event" 36 | }, 37 | { 38 | "anonymous": false, 39 | "inputs": [{ "indexed": false, "internalType": "uint8", "name": "version", "type": "uint8" }], 40 | "name": "Initialized", 41 | "type": "event" 42 | }, 43 | { 44 | "anonymous": false, 45 | "inputs": [ 46 | { "indexed": false, "internalType": "uint128", "name": "maxSupply", "type": "uint128" } 47 | ], 48 | "name": "MaxSupplyUpdated", 49 | "type": "event" 50 | }, 51 | { 52 | "anonymous": false, 53 | "inputs": [ 54 | { "indexed": false, "internalType": "uint256", "name": "mintPrice", "type": "uint256" } 55 | ], 56 | "name": "MintPriceUpdated", 57 | "type": "event" 58 | }, 59 | { 60 | "anonymous": false, 61 | "inputs": [ 62 | { "indexed": true, "internalType": "address", "name": "previousOwner", "type": "address" }, 63 | { "indexed": true, "internalType": "address", "name": "newOwner", "type": "address" } 64 | ], 65 | "name": "OwnershipTransferred", 66 | "type": "event" 67 | }, 68 | { 69 | "anonymous": false, 70 | "inputs": [ 71 | { "indexed": false, "internalType": "uint8", "name": "proposerFee", "type": "uint8" } 72 | ], 73 | "name": "ProposerFeeUpdated", 74 | "type": "event" 75 | }, 76 | { 77 | "anonymous": false, 78 | "inputs": [ 79 | { "indexed": false, "internalType": "uint8", "name": "snapshotFee", "type": "uint8" } 80 | ], 81 | "name": "SnapshotFeeUpdated", 82 | "type": "event" 83 | }, 84 | { 85 | "anonymous": false, 86 | "inputs": [ 87 | { "indexed": false, "internalType": "address", "name": "snapshotOwner", "type": "address" } 88 | ], 89 | "name": "SnapshotOwnerUpdated", 90 | "type": "event" 91 | }, 92 | { 93 | "anonymous": false, 94 | "inputs": [ 95 | { "indexed": false, "internalType": "address", "name": "snapshotTreasury", "type": "address" } 96 | ], 97 | "name": "SnapshotTreasuryUpdated", 98 | "type": "event" 99 | }, 100 | { 101 | "anonymous": false, 102 | "inputs": [ 103 | { "indexed": false, "internalType": "string", "name": "spaceId", "type": "string" }, 104 | { "indexed": false, "internalType": "uint128", "name": "maxSupply", "type": "uint128" }, 105 | { "indexed": false, "internalType": "uint256", "name": "mintPrice", "type": "uint256" }, 106 | { "indexed": false, "internalType": "uint8", "name": "proposerFee", "type": "uint8" }, 107 | { "indexed": false, "internalType": "address", "name": "spaceTreasury", "type": "address" }, 108 | { "indexed": false, "internalType": "address", "name": "spaceOwner", "type": "address" }, 109 | { "indexed": false, "internalType": "uint8", "name": "snapshotFee", "type": "uint8" }, 110 | { "indexed": false, "internalType": "address", "name": "trustedBackend", "type": "address" }, 111 | { "indexed": false, "internalType": "address", "name": "snapshotOwner", "type": "address" }, 112 | { "indexed": false, "internalType": "address", "name": "snapshotTreasury", "type": "address" } 113 | ], 114 | "name": "SpaceCollectionCreated", 115 | "type": "event" 116 | }, 117 | { 118 | "anonymous": false, 119 | "inputs": [ 120 | { "indexed": true, "internalType": "address", "name": "operator", "type": "address" }, 121 | { "indexed": true, "internalType": "address", "name": "from", "type": "address" }, 122 | { "indexed": true, "internalType": "address", "name": "to", "type": "address" }, 123 | { "indexed": false, "internalType": "uint256[]", "name": "ids", "type": "uint256[]" }, 124 | { "indexed": false, "internalType": "uint256[]", "name": "values", "type": "uint256[]" } 125 | ], 126 | "name": "TransferBatch", 127 | "type": "event" 128 | }, 129 | { 130 | "anonymous": false, 131 | "inputs": [ 132 | { "indexed": true, "internalType": "address", "name": "operator", "type": "address" }, 133 | { "indexed": true, "internalType": "address", "name": "from", "type": "address" }, 134 | { "indexed": true, "internalType": "address", "name": "to", "type": "address" }, 135 | { "indexed": false, "internalType": "uint256", "name": "id", "type": "uint256" }, 136 | { "indexed": false, "internalType": "uint256", "name": "value", "type": "uint256" } 137 | ], 138 | "name": "TransferSingle", 139 | "type": "event" 140 | }, 141 | { 142 | "anonymous": false, 143 | "inputs": [ 144 | { "indexed": false, "internalType": "string", "name": "value", "type": "string" }, 145 | { "indexed": true, "internalType": "uint256", "name": "id", "type": "uint256" } 146 | ], 147 | "name": "URI", 148 | "type": "event" 149 | }, 150 | { 151 | "anonymous": false, 152 | "inputs": [ 153 | { "indexed": true, "internalType": "address", "name": "implementation", "type": "address" } 154 | ], 155 | "name": "Upgraded", 156 | "type": "event" 157 | }, 158 | { 159 | "inputs": [ 160 | { "internalType": "address", "name": "account", "type": "address" }, 161 | { "internalType": "uint256", "name": "id", "type": "uint256" } 162 | ], 163 | "name": "balanceOf", 164 | "outputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }], 165 | "stateMutability": "view", 166 | "type": "function" 167 | }, 168 | { 169 | "inputs": [ 170 | { "internalType": "address[]", "name": "accounts", "type": "address[]" }, 171 | { "internalType": "uint256[]", "name": "ids", "type": "uint256[]" } 172 | ], 173 | "name": "balanceOfBatch", 174 | "outputs": [{ "internalType": "uint256[]", "name": "", "type": "uint256[]" }], 175 | "stateMutability": "view", 176 | "type": "function" 177 | }, 178 | { 179 | "inputs": [], 180 | "name": "fees", 181 | "outputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }], 182 | "stateMutability": "view", 183 | "type": "function" 184 | }, 185 | { 186 | "inputs": [ 187 | { "internalType": "string", "name": "name", "type": "string" }, 188 | { "internalType": "string", "name": "version", "type": "string" }, 189 | { "internalType": "uint128", "name": "_maxSupply", "type": "uint128" }, 190 | { "internalType": "uint256", "name": "_mintPrice", "type": "uint256" }, 191 | { "internalType": "uint8", "name": "_proposerFee", "type": "uint8" }, 192 | { "internalType": "address", "name": "_spaceTreasury", "type": "address" }, 193 | { "internalType": "address", "name": "_spaceOwner", "type": "address" } 194 | ], 195 | "name": "initialize", 196 | "outputs": [], 197 | "stateMutability": "nonpayable", 198 | "type": "function" 199 | }, 200 | { 201 | "inputs": [ 202 | { "internalType": "address", "name": "account", "type": "address" }, 203 | { "internalType": "address", "name": "operator", "type": "address" } 204 | ], 205 | "name": "isApprovedForAll", 206 | "outputs": [{ "internalType": "bool", "name": "", "type": "bool" }], 207 | "stateMutability": "view", 208 | "type": "function" 209 | }, 210 | { 211 | "inputs": [], 212 | "name": "maxSupply", 213 | "outputs": [{ "internalType": "uint128", "name": "", "type": "uint128" }], 214 | "stateMutability": "view", 215 | "type": "function" 216 | }, 217 | { 218 | "inputs": [ 219 | { "internalType": "address", "name": "proposer", "type": "address" }, 220 | { "internalType": "uint256", "name": "proposalId", "type": "uint256" }, 221 | { "internalType": "uint256", "name": "salt", "type": "uint256" }, 222 | { "internalType": "uint8", "name": "v", "type": "uint8" }, 223 | { "internalType": "bytes32", "name": "r", "type": "bytes32" }, 224 | { "internalType": "bytes32", "name": "s", "type": "bytes32" } 225 | ], 226 | "name": "mint", 227 | "outputs": [], 228 | "stateMutability": "nonpayable", 229 | "type": "function" 230 | }, 231 | { 232 | "inputs": [ 233 | { "internalType": "address[]", "name": "proposers", "type": "address[]" }, 234 | { "internalType": "uint256[]", "name": "proposalIds", "type": "uint256[]" }, 235 | { "internalType": "uint256", "name": "salt", "type": "uint256" }, 236 | { "internalType": "uint8", "name": "v", "type": "uint8" }, 237 | { "internalType": "bytes32", "name": "r", "type": "bytes32" }, 238 | { "internalType": "bytes32", "name": "s", "type": "bytes32" } 239 | ], 240 | "name": "mintBatch", 241 | "outputs": [], 242 | "stateMutability": "nonpayable", 243 | "type": "function" 244 | }, 245 | { 246 | "inputs": [], 247 | "name": "mintPrice", 248 | "outputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }], 249 | "stateMutability": "view", 250 | "type": "function" 251 | }, 252 | { 253 | "inputs": [{ "internalType": "uint256", "name": "proposalId", "type": "uint256" }], 254 | "name": "mintPrices", 255 | "outputs": [{ "internalType": "uint256", "name": "price", "type": "uint256" }], 256 | "stateMutability": "view", 257 | "type": "function" 258 | }, 259 | { 260 | "inputs": [], 261 | "name": "owner", 262 | "outputs": [{ "internalType": "address", "name": "", "type": "address" }], 263 | "stateMutability": "view", 264 | "type": "function" 265 | }, 266 | { 267 | "inputs": [], 268 | "name": "proxiableUUID", 269 | "outputs": [{ "internalType": "bytes32", "name": "", "type": "bytes32" }], 270 | "stateMutability": "view", 271 | "type": "function" 272 | }, 273 | { 274 | "inputs": [], 275 | "name": "renounceOwnership", 276 | "outputs": [], 277 | "stateMutability": "nonpayable", 278 | "type": "function" 279 | }, 280 | { 281 | "inputs": [ 282 | { "internalType": "address", "name": "from", "type": "address" }, 283 | { "internalType": "address", "name": "to", "type": "address" }, 284 | { "internalType": "uint256[]", "name": "ids", "type": "uint256[]" }, 285 | { "internalType": "uint256[]", "name": "amounts", "type": "uint256[]" }, 286 | { "internalType": "bytes", "name": "data", "type": "bytes" } 287 | ], 288 | "name": "safeBatchTransferFrom", 289 | "outputs": [], 290 | "stateMutability": "nonpayable", 291 | "type": "function" 292 | }, 293 | { 294 | "inputs": [ 295 | { "internalType": "address", "name": "from", "type": "address" }, 296 | { "internalType": "address", "name": "to", "type": "address" }, 297 | { "internalType": "uint256", "name": "id", "type": "uint256" }, 298 | { "internalType": "uint256", "name": "amount", "type": "uint256" }, 299 | { "internalType": "bytes", "name": "data", "type": "bytes" } 300 | ], 301 | "name": "safeTransferFrom", 302 | "outputs": [], 303 | "stateMutability": "nonpayable", 304 | "type": "function" 305 | }, 306 | { 307 | "inputs": [ 308 | { "internalType": "address", "name": "operator", "type": "address" }, 309 | { "internalType": "bool", "name": "approved", "type": "bool" } 310 | ], 311 | "name": "setApprovalForAll", 312 | "outputs": [], 313 | "stateMutability": "nonpayable", 314 | "type": "function" 315 | }, 316 | { 317 | "inputs": [{ "internalType": "uint128", "name": "_maxSupply", "type": "uint128" }], 318 | "name": "setMaxSupply", 319 | "outputs": [], 320 | "stateMutability": "nonpayable", 321 | "type": "function" 322 | }, 323 | { 324 | "inputs": [{ "internalType": "uint256", "name": "_mintPrice", "type": "uint256" }], 325 | "name": "setMintPrice", 326 | "outputs": [], 327 | "stateMutability": "nonpayable", 328 | "type": "function" 329 | }, 330 | { 331 | "inputs": [{ "internalType": "uint8", "name": "_proposerFee", "type": "uint8" }], 332 | "name": "setProposerFee", 333 | "outputs": [], 334 | "stateMutability": "nonpayable", 335 | "type": "function" 336 | }, 337 | { 338 | "inputs": [{ "internalType": "uint8", "name": "_snapshotFee", "type": "uint8" }], 339 | "name": "setSnapshotFee", 340 | "outputs": [], 341 | "stateMutability": "nonpayable", 342 | "type": "function" 343 | }, 344 | { 345 | "inputs": [{ "internalType": "address", "name": "_snapshotOwner", "type": "address" }], 346 | "name": "setSnapshotOwner", 347 | "outputs": [], 348 | "stateMutability": "nonpayable", 349 | "type": "function" 350 | }, 351 | { 352 | "inputs": [{ "internalType": "address", "name": "_snapshotTreasury", "type": "address" }], 353 | "name": "setSnapshotTreasury", 354 | "outputs": [], 355 | "stateMutability": "nonpayable", 356 | "type": "function" 357 | }, 358 | { 359 | "inputs": [], 360 | "name": "snapshotBalance", 361 | "outputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }], 362 | "stateMutability": "view", 363 | "type": "function" 364 | }, 365 | { 366 | "inputs": [], 367 | "name": "snapshotClaim", 368 | "outputs": [], 369 | "stateMutability": "nonpayable", 370 | "type": "function" 371 | }, 372 | { 373 | "inputs": [], 374 | "name": "snapshotOwner", 375 | "outputs": [{ "internalType": "address", "name": "", "type": "address" }], 376 | "stateMutability": "view", 377 | "type": "function" 378 | }, 379 | { 380 | "inputs": [], 381 | "name": "snapshotTreasury", 382 | "outputs": [{ "internalType": "address", "name": "", "type": "address" }], 383 | "stateMutability": "view", 384 | "type": "function" 385 | }, 386 | { 387 | "inputs": [], 388 | "name": "spaceBalance", 389 | "outputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }], 390 | "stateMutability": "view", 391 | "type": "function" 392 | }, 393 | { 394 | "inputs": [], 395 | "name": "spaceClaim", 396 | "outputs": [], 397 | "stateMutability": "nonpayable", 398 | "type": "function" 399 | }, 400 | { 401 | "inputs": [], 402 | "name": "spaceTreasury", 403 | "outputs": [{ "internalType": "address", "name": "", "type": "address" }], 404 | "stateMutability": "view", 405 | "type": "function" 406 | }, 407 | { 408 | "inputs": [{ "internalType": "uint256", "name": "proposalId", "type": "uint256" }], 409 | "name": "supplies", 410 | "outputs": [{ "internalType": "uint256", "name": "supply", "type": "uint256" }], 411 | "stateMutability": "view", 412 | "type": "function" 413 | }, 414 | { 415 | "inputs": [{ "internalType": "bytes4", "name": "interfaceId", "type": "bytes4" }], 416 | "name": "supportsInterface", 417 | "outputs": [{ "internalType": "bool", "name": "", "type": "bool" }], 418 | "stateMutability": "view", 419 | "type": "function" 420 | }, 421 | { 422 | "inputs": [{ "internalType": "address", "name": "newOwner", "type": "address" }], 423 | "name": "transferOwnership", 424 | "outputs": [], 425 | "stateMutability": "nonpayable", 426 | "type": "function" 427 | }, 428 | { 429 | "inputs": [], 430 | "name": "trustedBackend", 431 | "outputs": [{ "internalType": "address", "name": "", "type": "address" }], 432 | "stateMutability": "view", 433 | "type": "function" 434 | }, 435 | { 436 | "inputs": [{ "internalType": "address", "name": "newImplementation", "type": "address" }], 437 | "name": "upgradeTo", 438 | "outputs": [], 439 | "stateMutability": "nonpayable", 440 | "type": "function" 441 | }, 442 | { 443 | "inputs": [ 444 | { "internalType": "address", "name": "newImplementation", "type": "address" }, 445 | { "internalType": "bytes", "name": "data", "type": "bytes" } 446 | ], 447 | "name": "upgradeToAndCall", 448 | "outputs": [], 449 | "stateMutability": "payable", 450 | "type": "function" 451 | }, 452 | { 453 | "inputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }], 454 | "name": "uri", 455 | "outputs": [{ "internalType": "string", "name": "", "type": "string" }], 456 | "stateMutability": "view", 457 | "type": "function" 458 | } 459 | ] 460 | -------------------------------------------------------------------------------- /src/lib/nftClaimer/deployImplementationAbi.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "inputs": [ 4 | { "internalType": "string", "name": "name", "type": "string" }, 5 | { "internalType": "string", "name": "version", "type": "string" }, 6 | { "internalType": "uint128", "name": "_maxSupply", "type": "uint128" }, 7 | { "internalType": "uint256", "name": "_mintPrice", "type": "uint256" }, 8 | { "internalType": "uint8", "name": "_proposerFee", "type": "uint8" }, 9 | { "internalType": "address", "name": "_spaceTreasury", "type": "address" }, 10 | { "internalType": "address", "name": "_spaceOwner", "type": "address" } 11 | ], 12 | "name": "initialize", 13 | "outputs": [], 14 | "stateMutability": "nonpayable", 15 | "type": "function" 16 | } 17 | ] 18 | -------------------------------------------------------------------------------- /src/lib/nftClaimer/mint.ts: -------------------------------------------------------------------------------- 1 | import { splitSignature } from '@ethersproject/bytes'; 2 | import { fetchProposal, Space, Proposal } from '../../helpers/snapshot'; 3 | import { 4 | validateProposal, 5 | getProposalContract, 6 | signer, 7 | numberizeProposalId, 8 | validateMintInput, 9 | mintingAllowed, 10 | hasVoted, 11 | hasMinted 12 | } from './utils'; 13 | import abi from './spaceCollectionImplementationAbi.json'; 14 | import { FormatTypes, Interface } from '@ethersproject/abi'; 15 | 16 | const MintType = { 17 | Mint: [ 18 | { name: 'proposer', type: 'address' }, 19 | { name: 'recipient', type: 'address' }, 20 | { name: 'proposalId', type: 'uint256' }, 21 | { name: 'salt', type: 'uint256' } 22 | ] 23 | }; 24 | 25 | const NFT_CLAIMER_NETWORK = process.env.NFT_CLAIMER_NETWORK; 26 | 27 | export default async function payload(input: { 28 | proposalAuthor: string; 29 | recipient: string; 30 | id: string; 31 | salt: string; 32 | }) { 33 | const params = await validateMintInput(input); 34 | 35 | const proposal = await fetchProposal(params.id); 36 | validateProposal(proposal, params.proposalAuthor); 37 | const spaceId = proposal?.space.id as string; 38 | 39 | const verifyingContract = await getProposalContract(spaceId); 40 | if (!mintingAllowed(proposal?.space as Space)) { 41 | throw new Error('Space has closed minting'); 42 | } 43 | 44 | if (!(await hasVoted(params.recipient, proposal as Proposal))) { 45 | throw new Error('Minting is open only for voters'); 46 | } 47 | 48 | if (await hasMinted(params.recipient, proposal as Proposal)) { 49 | throw new Error('You can only mint once per vote'); 50 | } 51 | 52 | const message = { 53 | proposer: params.proposalAuthor, 54 | recipient: params.recipient, 55 | proposalId: numberizeProposalId(params.id), 56 | salt: BigInt(params.salt) 57 | }; 58 | 59 | return { 60 | signature: await generateSignature(verifyingContract, spaceId, message), 61 | contractAddress: verifyingContract, 62 | spaceId: proposal?.space.id, 63 | ...message, 64 | salt: params.salt, 65 | abi: new Interface(abi).getFunction('mint').format(FormatTypes.full) 66 | }; 67 | } 68 | 69 | async function generateSignature( 70 | verifyingContract: string, 71 | domain: string, 72 | message: Record 73 | ) { 74 | return splitSignature( 75 | await signer._signTypedData( 76 | { 77 | name: domain, 78 | version: '0.1', 79 | chainId: NFT_CLAIMER_NETWORK, 80 | verifyingContract 81 | }, 82 | MintType, 83 | message 84 | ) 85 | ); 86 | } 87 | -------------------------------------------------------------------------------- /src/lib/nftClaimer/spaceCollectionImplementationAbi.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "inputs": [ 4 | { "internalType": "string", "name": "name", "type": "string" }, 5 | { "internalType": "string", "name": "version", "type": "string" }, 6 | { "internalType": "uint128", "name": "_maxSupply", "type": "uint128" }, 7 | { "internalType": "uint256", "name": "_mintPrice", "type": "uint256" }, 8 | { "internalType": "uint8", "name": "_proposerFee", "type": "uint8" }, 9 | { "internalType": "address", "name": "_spaceTreasury", "type": "address" }, 10 | { "internalType": "address", "name": "_spaceOwner", "type": "address" } 11 | ], 12 | "name": "initialize", 13 | "outputs": [], 14 | "stateMutability": "nonpayable", 15 | "type": "function" 16 | }, 17 | { 18 | "inputs": [ 19 | { "internalType": "address", "name": "proposer", "type": "address" }, 20 | { "internalType": "uint256", "name": "proposalId", "type": "uint256" }, 21 | { "internalType": "uint256", "name": "salt", "type": "uint256" }, 22 | { "internalType": "uint8", "name": "v", "type": "uint8" }, 23 | { "internalType": "bytes32", "name": "r", "type": "bytes32" }, 24 | { "internalType": "bytes32", "name": "s", "type": "bytes32" } 25 | ], 26 | "name": "mint", 27 | "outputs": [], 28 | "stateMutability": "nonpayable", 29 | "type": "function" 30 | }, 31 | { 32 | "inputs": [ 33 | { "internalType": "address[]", "name": "proposers", "type": "address[]" }, 34 | { "internalType": "uint256[]", "name": "proposalIds", "type": "uint256[]" }, 35 | { "internalType": "uint256", "name": "salt", "type": "uint256" }, 36 | { "internalType": "uint8", "name": "v", "type": "uint8" }, 37 | { "internalType": "bytes32", "name": "r", "type": "bytes32" }, 38 | { "internalType": "bytes32", "name": "s", "type": "bytes32" } 39 | ], 40 | "name": "mintBatch", 41 | "outputs": [], 42 | "stateMutability": "nonpayable", 43 | "type": "function" 44 | } 45 | ] 46 | -------------------------------------------------------------------------------- /src/lib/nftClaimer/spaceFactoryAbi.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "inputs": [ 4 | { "internalType": "address", "name": "implementation", "type": "address" }, 5 | { "internalType": "bytes", "name": "initializer", "type": "bytes" }, 6 | { "internalType": "uint256", "name": "salt", "type": "uint256" }, 7 | { "internalType": "uint8", "name": "v", "type": "uint8" }, 8 | { "internalType": "bytes32", "name": "r", "type": "bytes32" }, 9 | { "internalType": "bytes32", "name": "s", "type": "bytes32" } 10 | ], 11 | "name": "deployProxy", 12 | "outputs": [], 13 | "stateMutability": "nonpayable", 14 | "type": "function" 15 | }, 16 | 17 | { 18 | "inputs": [], 19 | "name": "snapshotFee", 20 | "outputs": [{ "internalType": "uint8", "name": "", "type": "uint8" }], 21 | "stateMutability": "view", 22 | "type": "function" 23 | }, 24 | 25 | { 26 | "inputs": [ 27 | { "internalType": "uint8", "name": "_snapshotFee", "type": "uint8" }, 28 | { "internalType": "address", "name": "_snapshotOwner", "type": "address" }, 29 | { "internalType": "address", "name": "_snapshotTreasury", "type": "address" }, 30 | { "internalType": "address", "name": "_verifiedSigner", "type": "address" } 31 | ], 32 | "name": "updateFactorySettings", 33 | "outputs": [], 34 | "stateMutability": "nonpayable", 35 | "type": "function" 36 | } 37 | ] 38 | -------------------------------------------------------------------------------- /src/lib/nftClaimer/utils.ts: -------------------------------------------------------------------------------- 1 | import { gql, ApolloClient, InMemoryCache, HttpLink } from '@apollo/client/core'; 2 | import snapshot from '@snapshot-labs/snapshot.js'; 3 | import { CID } from 'multiformats/cid'; 4 | import { Wallet } from '@ethersproject/wallet'; 5 | import { Contract } from '@ethersproject/contracts'; 6 | import { getAddress, isAddress } from '@ethersproject/address'; 7 | import { BigNumber } from '@ethersproject/bignumber'; 8 | import { capture } from '@snapshot-labs/snapshot-sentry'; 9 | import { fetchVote, type Proposal, type Space } from '../../helpers/snapshot'; 10 | import { fetchWithKeepAlive } from '../../helpers/utils'; 11 | 12 | const requiredEnvKeys = [ 13 | 'NFT_CLAIMER_PRIVATE_KEY', 14 | 'NFT_CLAIMER_NETWORK', 15 | 'NFT_CLAIMER_DEPLOY_VERIFYING_CONTRACT', 16 | 'NFT_CLAIMER_DEPLOY_IMPLEMENTATION_ADDRESS', 17 | 'NFT_CLAIMER_DEPLOY_INITIALIZE_SELECTOR', 18 | 'NFT_CLAIMER_SUBGRAPH_URL' 19 | ]; 20 | 21 | const missingEnvKeys: string[] = []; 22 | requiredEnvKeys.forEach(key => { 23 | if (!process.env[key]) { 24 | missingEnvKeys.push(key); 25 | } 26 | }); 27 | const broviderUrl = process.env.BROVIDER_URL || 'https://rpc.snapshot.org'; 28 | 29 | if (missingEnvKeys.length > 0) { 30 | throw new Error( 31 | `NFT Claimer not configured properly, missing env keys: ${missingEnvKeys.join(', ')}` 32 | ); 33 | } 34 | 35 | const hardcodedHubNetwork = process.env.HUB_URL === 'https://hub.snapshot.org' ? '1' : '5'; 36 | const HUB_NETWORK = process.env.HUB_NETWORK || hardcodedHubNetwork; 37 | const DEPLOY_CONTRACT = getAddress(process.env.NFT_CLAIMER_DEPLOY_VERIFYING_CONTRACT as string); 38 | const NFT_CLAIMER_NETWORK = parseInt(process.env.NFT_CLAIMER_NETWORK as string); 39 | 40 | export const signer = new Wallet(process.env.NFT_CLAIMER_PRIVATE_KEY as string); 41 | 42 | export async function mintingAllowed(space: Space) { 43 | return (await getSpaceCollection(space.id)).enabled; 44 | } 45 | 46 | export async function hasVoted(address: string, proposal: Proposal) { 47 | const vote = await fetchVote(address, proposal.id); 48 | return vote !== undefined; 49 | } 50 | 51 | export async function hasMinted(address: string, proposal: Proposal) { 52 | const mint = await getMint(address, proposal.id); 53 | return mint !== undefined; 54 | } 55 | 56 | export async function validateSpace(address: string, space: Space | null) { 57 | if (!space) { 58 | throw new Error('RECORD_NOT_FOUND'); 59 | } 60 | 61 | if (NFT_CLAIMER_NETWORK !== 5 && !(await isSpaceOwner(space.id, address))) { 62 | throw new Error('Address is not the space owner'); 63 | } 64 | 65 | const contract = await getSpaceCollection(space.id); 66 | if (contract) { 67 | throw new Error(`SpaceCollection contract already exist (${contract.id})`); 68 | } 69 | } 70 | 71 | async function isSpaceOwner(spaceId: string, address: string) { 72 | const spaceController = await snapshot.utils.getSpaceController(spaceId, HUB_NETWORK, { 73 | broviderUrl 74 | }); 75 | return spaceController === getAddress(address); 76 | } 77 | 78 | export function validateProposal(proposal: Proposal | null, proposer: string) { 79 | if (!proposal) { 80 | throw new Error('RECORD_NOT_FOUND'); 81 | } 82 | 83 | if (getAddress(proposer) !== getAddress(proposal.author)) { 84 | throw new Error('Proposal author is not matching'); 85 | } 86 | 87 | if (!mintingAllowed(proposal.space)) { 88 | throw new Error('Space has not allowed minting'); 89 | } 90 | } 91 | 92 | export async function getProposalContract(spaceId: string) { 93 | const contract = await getSpaceCollection(spaceId); 94 | 95 | if (!contract) { 96 | throw new Error(`SpaceCollection contract is not found for space ${spaceId}`); 97 | } 98 | 99 | return contract.id; 100 | } 101 | 102 | const client = new ApolloClient({ 103 | link: new HttpLink({ uri: process.env.NFT_CLAIMER_SUBGRAPH_URL, fetch: fetchWithKeepAlive }), 104 | cache: new InMemoryCache({ 105 | addTypename: false 106 | }), 107 | defaultOptions: { 108 | query: { 109 | fetchPolicy: 'no-cache' 110 | } 111 | } 112 | }); 113 | 114 | const SPACE_COLLECTION_QUERY = gql` 115 | query SpaceCollections($spaceId: String) { 116 | spaceCollections(where: { spaceId: $spaceId }, first: 1) { 117 | id 118 | enabled 119 | } 120 | } 121 | `; 122 | 123 | type SpaceCollection = { 124 | id: string; 125 | enabled: boolean; 126 | }; 127 | 128 | export async function getSpaceCollection(spaceId: string) { 129 | const { 130 | data: { spaceCollections } 131 | }: { data: { spaceCollections: SpaceCollection[] } } = await client.query({ 132 | query: SPACE_COLLECTION_QUERY, 133 | variables: { 134 | spaceId 135 | } 136 | }); 137 | 138 | return spaceCollections[0]; 139 | } 140 | 141 | const MINT_COLLECTION_QUERY = gql` 142 | query Mints($voter: String, $proposalId: String) { 143 | mints(where: { minterAddress: $voter, proposal: $proposalId }, first: 1) { 144 | id 145 | } 146 | } 147 | `; 148 | 149 | type Mint = { 150 | id: string; 151 | }; 152 | 153 | export async function getMint(voter: string, proposalId: string) { 154 | const { 155 | data: { mints } 156 | }: { data: { mints: Mint[] } } = await client.query({ 157 | query: MINT_COLLECTION_QUERY, 158 | variables: { 159 | voter, 160 | proposalId 161 | } 162 | }); 163 | 164 | return mints[0]; 165 | } 166 | 167 | export function numberizeProposalId(id: string) { 168 | return BigNumber.from(id.startsWith('0x') ? id : CID.parse(id).bytes).toString(); 169 | } 170 | 171 | export function validateAddresses(addresses: Record) { 172 | Object.entries(addresses).forEach(([key, value]) => { 173 | if (!isAddress(value)) { 174 | throw new Error(`Value for ${key} is not a valid address (${value})`); 175 | } 176 | }); 177 | 178 | return true; 179 | } 180 | 181 | function validateNumbers(numbers: Record) { 182 | Object.entries(numbers).forEach(([key, value]) => { 183 | try { 184 | BigNumber.from(value).toString(); 185 | } catch (e: any) { 186 | throw new Error(`Value for ${key} is not a valid number (${value})`); 187 | } 188 | }); 189 | 190 | return true; 191 | } 192 | 193 | export async function validateProposerFee(fee: number) { 194 | if (fee < 0 || fee > 100) { 195 | throw new Error('proposerFee should be between 0 and 100'); 196 | } 197 | 198 | const sFee = await snapshotFee(); 199 | if (sFee + fee > 100) { 200 | throw new Error(`proposerFee should not be greater than ${100 - sFee}`); 201 | } 202 | 203 | return true; 204 | } 205 | 206 | export async function validateDeployInput(params: any) { 207 | validateAddresses({ spaceOwner: params.spaceOwner, spaceTreasury: params.spaceTreasury }); 208 | validateNumbers({ 209 | maxSupply: params.maxSupply, 210 | proposerFee: params.proposerFee, 211 | mintPrice: params.mintPrice, 212 | salt: params.salt 213 | }); 214 | await validateProposerFee(parseInt(params.proposerFee)); 215 | 216 | return { 217 | spaceOwner: getAddress(params.spaceOwner), 218 | spaceTreasury: getAddress(params.spaceTreasury), 219 | proposerFee: parseInt(params.proposerFee), 220 | maxSupply: parseInt(params.maxSupply), 221 | mintPrice: parseInt(params.mintPrice), 222 | ...params 223 | }; 224 | } 225 | 226 | export async function validateMintInput(params: any) { 227 | validateAddresses({ proposalAuthor: params.proposalAuthor, recipient: params.recipient }); 228 | validateNumbers({ 229 | salt: params.salt 230 | }); 231 | 232 | return { 233 | proposalAuthor: getAddress(params.proposalAuthor), 234 | recipient: getAddress(params.recipient), 235 | ...params 236 | }; 237 | } 238 | 239 | export async function snapshotFee(): Promise { 240 | try { 241 | const provider = snapshot.utils.getProvider(NFT_CLAIMER_NETWORK, { broviderUrl }); 242 | const contract = new Contract( 243 | DEPLOY_CONTRACT, 244 | ['function snapshotFee() public view returns (uint8)'], 245 | provider 246 | ); 247 | 248 | return contract.snapshotFee(); 249 | } catch (e: any) { 250 | capture(e); 251 | throw 'Unable to retrieve the snapshotFee'; 252 | } 253 | } 254 | -------------------------------------------------------------------------------- /src/lib/queue.ts: -------------------------------------------------------------------------------- 1 | import { sleep } from '../helpers/utils'; 2 | import { capture } from '@snapshot-labs/snapshot-sentry'; 3 | import Cache from './cache'; 4 | import { timeQueueProcess } from './metrics'; 5 | 6 | const MAX_CONCURRENT_PROCESSING_ITEMS = 10; 7 | const queues = new Map(); 8 | const processingItems = new Map(); 9 | 10 | async function processItem(cacheable: Cache) { 11 | console.log(`[queue] Processing queue item: ${cacheable}`); 12 | try { 13 | const end = timeQueueProcess.startTimer({ name: cacheable.constructor.name }); 14 | processingItems.set(cacheable.id, cacheable); 15 | await cacheable.createCache(); 16 | end(); 17 | } catch (e) { 18 | capture(e, { id: cacheable.id }); 19 | console.error(`[queue] Error while processing item`, e); 20 | } finally { 21 | queues.delete(cacheable.id); 22 | processingItems.delete(cacheable.id); 23 | } 24 | } 25 | 26 | export function queue(cacheable: Cache) { 27 | if (!queues.has(cacheable.id)) { 28 | queues.set(cacheable.id, cacheable); 29 | } 30 | 31 | return queues.size; 32 | } 33 | 34 | export function size() { 35 | return queues.size; 36 | } 37 | 38 | export function getProgress(id: string) { 39 | if (processingItems.has(id)) { 40 | return processingItems.get(id)?.generationProgress as number; 41 | } 42 | 43 | return 0; 44 | } 45 | 46 | async function run() { 47 | try { 48 | console.log(`[queue] Poll queue (found ${queues.size} items)`); 49 | queues.forEach(async (cacheable, id) => { 50 | if (processingItems.has(id)) { 51 | console.log( 52 | `[queue] Skip: ${cacheable} is currently being processed, progress: 53 | ${processingItems.get(id)?.generationProgress}%` 54 | ); 55 | return; 56 | } 57 | 58 | if (processingItems.size >= MAX_CONCURRENT_PROCESSING_ITEMS) { 59 | console.log(`[queue] Skip ${cacheable}: max concurrent processing items reached`); 60 | return; 61 | } 62 | 63 | processItem(cacheable); 64 | }); 65 | } catch (e) { 66 | capture(e); 67 | } finally { 68 | await sleep(parseInt(process.env.QUEUE_INTERVAL || '15000')); 69 | await run(); 70 | } 71 | } 72 | 73 | run(); 74 | -------------------------------------------------------------------------------- /src/lib/storage/aws.ts: -------------------------------------------------------------------------------- 1 | import { 2 | S3Client, 3 | GetObjectCommand, 4 | PutObjectCommand, 5 | DeleteObjectCommand 6 | } from '@aws-sdk/client-s3'; 7 | import { capture } from '@snapshot-labs/snapshot-sentry'; 8 | import type { IStorage } from './types'; 9 | import type { Readable } from 'stream'; 10 | 11 | const CACHE_PATH = 'public'; 12 | 13 | class Aws implements IStorage { 14 | client: S3Client; 15 | subDir?: string; 16 | 17 | constructor(subDir?: string) { 18 | const region = process.env.AWS_REGION; 19 | const accessKeyId = process.env.AWS_ACCESS_KEY_ID; 20 | const secretAccessKey = process.env.AWS_SECRET_ACCESS_KEY; 21 | 22 | if (!region || !accessKeyId || !secretAccessKey) { 23 | throw new Error('[storage:aws] AWS credentials missing'); 24 | } 25 | 26 | this.client = new S3Client({ endpoint: process.env.AWS_ENDPOINT }); 27 | this.subDir = subDir; 28 | } 29 | 30 | async set(key: string, value: string | Buffer) { 31 | try { 32 | const command = new PutObjectCommand({ 33 | Bucket: process.env.AWS_BUCKET_NAME, 34 | Key: this.path(key), 35 | Body: value, 36 | ContentType: 'text/csv; charset=utf-8' 37 | }); 38 | 39 | await this.client.send(command); 40 | console.log(`[storage:aws] File saved to ${this.path(key)}`); 41 | 42 | return true; 43 | } catch (e) { 44 | capture(e, { key, path: this.path(key) }); 45 | console.error('[storage:aws] File storage failed', e); 46 | throw new Error('Unable to access storage'); 47 | } 48 | } 49 | 50 | async get(key: string) { 51 | try { 52 | const command = new GetObjectCommand({ 53 | Bucket: process.env.AWS_BUCKET_NAME, 54 | Key: this.path(key) 55 | }); 56 | const response = await this.client.send(command); 57 | 58 | if (!response.Body) { 59 | return false; 60 | } 61 | 62 | const stream = response.Body as Readable; 63 | 64 | return new Promise((resolve, reject) => { 65 | const chunks: Buffer[] = []; 66 | stream.on('data', chunk => chunks.push(chunk)); 67 | stream.once('end', () => resolve(Buffer.concat(chunks))); 68 | stream.once('error', reject); 69 | }); 70 | } catch (e: any) { 71 | if (e['$metadata']?.httpStatusCode !== 404) { 72 | capture(e, { key, path: this.path(key) }); 73 | console.error('[storage:aws] File fetch failed', e); 74 | } 75 | 76 | return false; 77 | } 78 | } 79 | 80 | async delete(key: string) { 81 | try { 82 | const command = new DeleteObjectCommand({ 83 | Bucket: process.env.AWS_BUCKET_NAME, 84 | Key: this.path(key) 85 | }); 86 | await this.client.send(command); 87 | 88 | return true; 89 | } catch (e: any) { 90 | if (e['$metadata']?.httpStatusCode !== 404) { 91 | capture(e, { key, path: this.path(key) }); 92 | console.error('[storage:aws] File delete failed', e); 93 | } 94 | 95 | return false; 96 | } 97 | } 98 | 99 | path(key?: string) { 100 | return [CACHE_PATH, this.subDir?.replace(/^\/+|\/+$/, ''), key].filter(p => p).join('/'); 101 | } 102 | } 103 | 104 | export default Aws; 105 | -------------------------------------------------------------------------------- /src/lib/storage/file.ts: -------------------------------------------------------------------------------- 1 | import { writeFileSync, existsSync, mkdirSync, readFileSync, rmSync } from 'fs'; 2 | import { capture } from '@snapshot-labs/snapshot-sentry'; 3 | import type { IStorage } from './types'; 4 | 5 | const CACHE_PATH = `${__dirname}/../../../tmp`; 6 | 7 | class File implements IStorage { 8 | subDir?: string; 9 | 10 | constructor(subDir?: string) { 11 | this.subDir = subDir; 12 | 13 | if (!existsSync(this.path())) { 14 | mkdirSync(this.path(), { recursive: true }); 15 | } 16 | } 17 | 18 | async set(key: string, value: string | Buffer) { 19 | try { 20 | writeFileSync(this.path(key), value); 21 | console.log(`[storage:file] File saved to ${this.path(key)}`); 22 | 23 | return true; 24 | } catch (e) { 25 | capture(e, { key, path: this.path(key) }); 26 | console.error('[storage:file] File storage failed', e); 27 | throw e; 28 | } 29 | } 30 | 31 | async get(key: string) { 32 | try { 33 | if (!existsSync(this.path(key))) { 34 | return false; 35 | } 36 | 37 | console.log(`[storage:file] File fetched from ${this.path(key)}`); 38 | return readFileSync(this.path(key)); 39 | } catch (e) { 40 | capture(e, { key, path: this.path(key) }); 41 | console.error('[storage:file] Fetch file failed', e); 42 | return false; 43 | } 44 | } 45 | 46 | async delete(key: string) { 47 | try { 48 | if (!existsSync(this.path(key))) { 49 | return true; 50 | } 51 | 52 | rmSync(this.path(key)); 53 | 54 | console.log(`[storage:file] File ${this.path(key)} deleted`); 55 | return true; 56 | } catch (e) { 57 | capture(e, { key, path: this.path(key) }); 58 | console.error('[storage:file] Fetch deletion failed', e); 59 | return false; 60 | } 61 | } 62 | 63 | path(key?: string) { 64 | return [CACHE_PATH, this.subDir?.replace(/^\/+|\/+$/, ''), key].filter(p => p).join('/'); 65 | } 66 | } 67 | 68 | export default File; 69 | -------------------------------------------------------------------------------- /src/lib/storage/types.ts: -------------------------------------------------------------------------------- 1 | export interface IStorage { 2 | subDir?: string; 3 | 4 | set(key: string, value: string | Buffer): Promise; 5 | get(key: string): Promise; 6 | delete(key: string): Promise; 7 | } 8 | 9 | export interface IStorageConstructor { 10 | new (subDir?: string): IStorage; 11 | } 12 | -------------------------------------------------------------------------------- /src/lib/votesReport.ts: -------------------------------------------------------------------------------- 1 | import Cache from './cache'; 2 | import { fetchProposal, fetchVotes, Proposal, Vote } from '../helpers/snapshot'; 3 | import { getIndex, setIndex } from './cacheRefresher'; 4 | import type { IStorage } from './storage/types'; 5 | 6 | const CACHEABLE_PROPOSAL_STATE = ['closed', 'active']; 7 | 8 | class VotesReport extends Cache { 9 | proposal?: Proposal | null; 10 | 11 | constructor(id: string, storage: IStorage) { 12 | super(id, storage); 13 | this.filename = `snapshot-votes-report-${this.id}.csv`; 14 | } 15 | 16 | async isCacheable() { 17 | this.proposal = await fetchProposal(this.id); 18 | 19 | if (!this.proposal || !CACHEABLE_PROPOSAL_STATE.includes(this.proposal.state)) { 20 | return Promise.reject('RECORD_NOT_FOUND'); 21 | } 22 | 23 | return true; 24 | } 25 | 26 | async afterCreateCache(): Promise { 27 | const list = await getIndex(); 28 | 29 | if (this.proposal?.state === 'active' && !list.includes(this.id)) { 30 | setIndex([...list, this.id]); 31 | } else if (this.proposal?.state === 'closed' && list.includes(this.id)) { 32 | setIndex(list.filter(item => item !== this.id)); 33 | } 34 | } 35 | 36 | getContent = async () => { 37 | this.isCacheable(); 38 | const votes = await this.fetchAllVotes(); 39 | 40 | let content = ''; 41 | 42 | console.log(`[votes-report] Generating report for ${this.id}`); 43 | 44 | const headers = [ 45 | 'address', 46 | ['basic', 'single-choice'].includes(this.proposal!.type) 47 | ? 'choice' 48 | : this.proposal!.choices.map((_choice, index) => `choice.${index + 1}`), 49 | 'voting_power', 50 | 'timestamp', 51 | 'author_ipfs_hash', 52 | 'reason' 53 | ].flat(); 54 | 55 | content += headers.join(','); 56 | content += `\n${votes.map(vote => this.#formatCsvLine(vote)).join('\n')}`; 57 | 58 | console.log(`[votes-report] Report for ${this.id} ready with ${votes.length} items`); 59 | 60 | return content; 61 | }; 62 | 63 | fetchAllVotes = async () => { 64 | const votes = new Map(); 65 | let page = 0; 66 | let createdPivot = 0; 67 | const pageSize = 1000; 68 | let resultsSize = 0; 69 | const maxPage = 5; 70 | 71 | do { 72 | const newVotes = await fetchVotes(this.id, { 73 | first: pageSize, 74 | skip: page * pageSize, 75 | created_gte: createdPivot, 76 | orderBy: 'created', 77 | orderDirection: 'asc' 78 | }); 79 | resultsSize = newVotes.length; 80 | 81 | if (page === maxPage) { 82 | page = 0; 83 | createdPivot = newVotes[newVotes.length - 1].created; 84 | } else { 85 | page++; 86 | } 87 | 88 | for (const vote of newVotes) { 89 | votes.set(vote.ipfs, vote); 90 | } 91 | 92 | this.generationProgress = +((votes.size / this.proposal!.votes) * 100).toFixed(2); 93 | } while (resultsSize === pageSize); 94 | 95 | return Array.from(votes.values()); 96 | }; 97 | 98 | toString() { 99 | return `VotesReport#${this.id}`; 100 | } 101 | 102 | #formatCsvLine = (vote: Vote) => { 103 | return [ 104 | vote.voter, 105 | ...this.#getCsvChoices(vote), 106 | vote.vp, 107 | vote.created, 108 | vote.ipfs, 109 | `"${vote.reason.replace(/(\r\n|\n|\r)/gm, '')}"` 110 | ] 111 | .flat() 112 | .join(','); 113 | }; 114 | 115 | #getCsvChoices = (vote: Vote) => { 116 | const choices: Vote['choice'][] = Array.from({ 117 | length: ['basic', 'single-choice'].includes(this.proposal!.type) 118 | ? 1 119 | : this.proposal!.choices.length 120 | }); 121 | 122 | if (this.proposal!.privacy && this.proposal!.state !== 'closed') return choices; 123 | 124 | switch (this.proposal!.type) { 125 | case 'single-choice': 126 | case 'basic': 127 | if (typeof vote.choice === 'number') choices[0] = vote.choice; 128 | break; 129 | case 'approval': 130 | case 'ranked-choice': 131 | if (Array.isArray(vote.choice)) { 132 | vote.choice.forEach((value, index) => { 133 | choices[index] = value; 134 | }); 135 | } 136 | break; 137 | case 'quadratic': 138 | case 'weighted': 139 | if (typeof vote.choice === 'object') { 140 | for (const [key, value] of Object.entries(vote.choice)) { 141 | choices[+key - 1] = value; 142 | } 143 | } 144 | } 145 | 146 | return choices; 147 | }; 148 | } 149 | 150 | export default VotesReport; 151 | -------------------------------------------------------------------------------- /src/sentryTunnel.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import bodyParser from 'body-parser'; 3 | import { capture } from '@snapshot-labs/snapshot-sentry'; 4 | import { URL } from 'url'; 5 | import { rpcError, fetchWithKeepAlive } from './helpers/utils'; 6 | 7 | const router = express.Router(); 8 | 9 | router.post('/sentry', bodyParser.raw({ type: () => true, limit: '4mb' }), async (req, res) => { 10 | try { 11 | const { dsn, event_id } = JSON.parse(req.body.toString().split('\n')[0]); 12 | 13 | if (dsn !== process.env.TUNNEL_SENTRY_DSN) { 14 | return rpcError(res, 'UNAUTHORIZED', event_id); 15 | } 16 | 17 | const dnsUri = new URL(dsn); 18 | const sentryApiUrl = `https://${dnsUri.hostname}/api${dnsUri.pathname}/envelope/`; 19 | const response = await fetchWithKeepAlive(sentryApiUrl, { 20 | method: 'POST', 21 | body: req.body 22 | }); 23 | 24 | const status = response.status; 25 | if (status !== 200) { 26 | console.debug(await response.text()); 27 | } 28 | return res.sendStatus(status); 29 | } catch (e: any) { 30 | capture(e); 31 | rpcError(res, e, ''); 32 | } 33 | }); 34 | 35 | export default router; 36 | -------------------------------------------------------------------------------- /src/webhook.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import { rpcError, rpcSuccess, storageEngine } from './helpers/utils'; 3 | import { capture } from '@snapshot-labs/snapshot-sentry'; 4 | import VotesReport from './lib/votesReport'; 5 | import { queue } from './lib/queue'; 6 | 7 | const router = express.Router(); 8 | 9 | function processVotesReport(id: string, event: string) { 10 | if (event == 'proposal/end') { 11 | queue(new VotesReport(id, storageEngine(process.env.VOTE_REPORT_SUBDIR))); 12 | } 13 | } 14 | 15 | router.post('/webhook', (req, res) => { 16 | const body = req.body || {}; 17 | const event = body.event?.toString() ?? ''; 18 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 19 | const { type, id } = body.id?.toString().split('/'); 20 | 21 | if (req.headers['authentication'] !== `${process.env.WEBHOOK_AUTH_TOKEN ?? ''}`) { 22 | return rpcError(res, 'UNAUTHORIZED', id); 23 | } 24 | 25 | if (!event || !id) { 26 | return rpcError(res, 'Invalid Request', id); 27 | } 28 | 29 | try { 30 | processVotesReport(id, event); 31 | return rpcSuccess(res, 'Webhook received', id); 32 | } catch (e) { 33 | capture(e, { body }); 34 | return rpcError(res, 'INTERNAL_ERROR', id); 35 | } 36 | }); 37 | 38 | export default router; 39 | -------------------------------------------------------------------------------- /test/.env.test: -------------------------------------------------------------------------------- 1 | NODE_ENV=test 2 | HUB_URL=https://hub.snapshot.org 3 | DATABASE_URL=mysql://root:root@127.0.0.1:3306/sidekick_test 4 | MODERATION_LIST_PATH=test/fixtures 5 | NFT_CLAIMER_PRIVATE_KEY=00000000000000000000000000000000000000000000000000000000000004d2 6 | NFT_CLAIMER_NETWORK=5 7 | NFT_CLAIMER_DEPLOY_VERIFYING_CONTRACT=0x054a600d8B766c786270E25872236507D8459D8F 8 | NFT_CLAIMER_DEPLOY_IMPLEMENTATION_ADDRESS=0x33505720a7921d23E6b02EB69623Ed6A008Ca511 9 | NFT_CLAIMER_DEPLOY_INITIALIZE_SELECTOR=0x977b0efb 10 | NFT_CLAIMER_SUBGRAPH_URL='https://api.studio.thegraph.com/query/48277/nft-subgraph-goerli/version/latest' 11 | STORAGE_ENGINE=file 12 | OPENAI_API_KEY='' 13 | QUEUE_INTERVAL=2500 14 | # your own test AWS for credentials if you wish to test with AWS 15 | # AWS_ACCESS_KEY_ID= 16 | # AWS_REGION= 17 | # AWS_SECRET_ACCESS_KEY= 18 | # AWS_BUCKET_NAME= 19 | -------------------------------------------------------------------------------- /test/e2e/__snapshots__/moderation.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`GET /api/moderation ignores invalid field, and returns only flaggedLinks 1`] = ` 4 | { 5 | "flaggedLinks": [ 6 | "https://facebook.com", 7 | "https://gogle.com", 8 | ], 9 | } 10 | `; 11 | 12 | exports[`GET /api/moderation returns multiple list: flaggedLinks,flaggedIps 1`] = ` 13 | { 14 | "flaggedIps": [ 15 | "12ca17b49af2289436f303e0166030a21e525d266e209267433801a8fd4071a0", 16 | "19e36255972107d42b8cecb77ef5622e842e8a50778a6ed8dd1ce94732daca9e", 17 | ], 18 | "flaggedLinks": [ 19 | "https://facebook.com", 20 | "https://gogle.com", 21 | ], 22 | } 23 | `; 24 | 25 | exports[`GET /api/moderation when list params is empty returns all the list 1`] = ` 26 | { 27 | "flaggedAddresses": [ 28 | "0x0001", 29 | "0x0002", 30 | ], 31 | "flaggedIps": [ 32 | "12ca17b49af2289436f303e0166030a21e525d266e209267433801a8fd4071a0", 33 | "19e36255972107d42b8cecb77ef5622e842e8a50778a6ed8dd1ce94732daca9e", 34 | ], 35 | "flaggedLinks": [ 36 | "https://facebook.com", 37 | "https://gogle.com", 38 | ], 39 | "verifiedTokens": { 40 | "test": { 41 | "value": "a", 42 | }, 43 | }, 44 | } 45 | `; 46 | 47 | exports[`GET /api/moderation when list params is set returns only the selected flaggedLinks list 1`] = ` 48 | { 49 | "flaggedLinks": [ 50 | "https://facebook.com", 51 | "https://gogle.com", 52 | ], 53 | } 54 | `; 55 | 56 | exports[`GET /api/moderation when list params is set returns only the selected verifiedTokens list 1`] = ` 57 | { 58 | "verifiedTokens": { 59 | "test": { 60 | "value": "a", 61 | }, 62 | }, 63 | } 64 | `; 65 | -------------------------------------------------------------------------------- /test/e2e/moderation.test.ts: -------------------------------------------------------------------------------- 1 | import request from 'supertest'; 2 | import db from '../../src/helpers/mysql'; 3 | import { SqlFixtures as moderation } from '../fixtures/moderation'; 4 | 5 | const HOST = `http://localhost:${process.env.PORT || 3003}`; 6 | 7 | describe('GET /api/moderation', () => { 8 | beforeAll(async () => { 9 | const data = Object.values(moderation) 10 | .flat() 11 | .map(d => Object.values(d)); 12 | await db.queryAsync(`INSERT INTO moderation (action, type, value, created) VALUES ?`, [ 13 | data as any 14 | ]); 15 | }); 16 | 17 | afterAll(async () => { 18 | await db.queryAsync('DELETE FROM sidekick_test.moderation;'); 19 | await db.endAsync(); 20 | }); 21 | 22 | describe('when list params is empty', () => { 23 | it('returns all the list', async () => { 24 | const response = await request(HOST).get('/api/moderation'); 25 | 26 | expect(response.statusCode).toBe(200); 27 | expect(response.body).toMatchSnapshot(); 28 | }); 29 | }); 30 | 31 | describe('when list params is set', () => { 32 | it.each(['flaggedLinks', 'verifiedTokens'])( 33 | 'returns only the selected %s list', 34 | async field => { 35 | const response = await request(HOST).get(`/api/moderation?list=${field}`); 36 | 37 | expect(response.statusCode).toBe(200); 38 | expect(response.body).toMatchSnapshot(); 39 | } 40 | ); 41 | }); 42 | 43 | it('returns multiple list: flaggedLinks,flaggedIps', async () => { 44 | const response = await request(HOST).get(`/api/moderation?list=flaggedLinks,flaggedIps`); 45 | 46 | expect(response.statusCode).toBe(200); 47 | expect(response.body).toMatchSnapshot(); 48 | }); 49 | 50 | it('ignores invalid field, and returns only flaggedLinks', async () => { 51 | const response = await request(HOST).get(`/api/moderation?list=flaggedLinks,testInvalid`); 52 | 53 | expect(response.statusCode).toBe(200); 54 | expect(response.body).toMatchSnapshot(); 55 | }); 56 | }); 57 | -------------------------------------------------------------------------------- /test/e2e/nftClaimer.test.ts: -------------------------------------------------------------------------------- 1 | import request from 'supertest'; 2 | 3 | const HOST = `http://localhost:${process.env.PORT || 3003}`; 4 | 5 | describe('nftClaimer', () => { 6 | describe('GET /api/nft-claimer', () => { 7 | jest.retryTimes(3); 8 | 9 | it.skip('returns an object with the snapshotFee', async () => { 10 | const response = await request(HOST).get('/api/nft-claimer'); 11 | const fee = response.body.snapshotFee; 12 | 13 | expect(response.statusCode).toBe(200); 14 | expect(fee).toBeLessThan(100); 15 | expect(fee).toBeGreaterThanOrEqual(1); 16 | }); 17 | }); 18 | 19 | describe('on deploy', () => { 20 | describe('on valid input', () => { 21 | it.todo('returns a payload'); 22 | }); 23 | 24 | describe('on invalid input', () => { 25 | it.todo('returns a 401 error when the submitter is not the space controller'); 26 | it.todo('returns an error when the space does not exist'); 27 | }); 28 | }); 29 | 30 | describe('on mint', () => { 31 | describe('on valid input', () => { 32 | it.todo('returns a payload'); 33 | }); 34 | 35 | describe('on invalid input', () => { 36 | it.todo('returns an error when proposal does not exist'); 37 | }); 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /test/e2e/votesReport.test.ts: -------------------------------------------------------------------------------- 1 | import request from 'supertest'; 2 | import VotesReport from '../../src/lib/votesReport'; 3 | import { storageEngine, sleep } from '../../src/helpers/utils'; 4 | import { rmSync } from 'fs'; 5 | 6 | const HOST = `http://localhost:${process.env.PORT || 3003}`; 7 | 8 | describe('GET /api/votes/:id', () => { 9 | const id = '0x1e5fdb5c87867a94c1c7f27025d62851ea47f6072f2296ca53a48fce1b87cdef'; 10 | const storage = storageEngine(process.env.VOTE_REPORT_SUBDIR); 11 | 12 | afterEach(() => { 13 | rmSync(storage.path(), { recursive: true }); 14 | }); 15 | 16 | describe('when the cache exists', () => { 17 | const votesReport = new VotesReport(id, storage); 18 | 19 | beforeAll(async () => { 20 | try { 21 | await votesReport.createCache(); 22 | } catch (e) { 23 | console.error('Error while creating the cache'); 24 | } 25 | }); 26 | 27 | it('returns the cached file', async () => { 28 | const response = await request(HOST).post(`/api/votes/${id}`); 29 | 30 | expect(response.statusCode).toBe(200); 31 | expect(response.text).toEqual((await votesReport.getCache()).toString()); 32 | }); 33 | }); 34 | 35 | describe('when the cache does not exist', () => { 36 | describe('when the proposal exists', () => { 37 | it('returns a 202 status code, and creates the cache', async () => { 38 | const response = await request(HOST).post(`/api/votes/${id}`); 39 | 40 | expect(response.statusCode).toBe(202); 41 | 42 | const votesReport = new VotesReport(id, storage); 43 | expect(typeof (await votesReport.getCache())).not.toBe(false); 44 | await sleep(parseInt(process.env.QUEUE_INTERVAL || '15000')); 45 | }); 46 | }); 47 | 48 | describe('when proposal does not exist', () => { 49 | it('returns a 404 error', async () => { 50 | const response = await request(HOST).post('/api/votes/0x000'); 51 | 52 | expect(response.statusCode).toBe(404); 53 | }); 54 | }); 55 | }); 56 | }); 57 | -------------------------------------------------------------------------------- /test/fixtures/hub-proposal-0x07387077920ce65b805bd0ba913a02ecfe63d22cac3dbaed3d97c23afd053fe2.json: -------------------------------------------------------------------------------- 1 | { 2 | "state": "closed", 3 | "type": "quadratic", 4 | "id": "0x07387077920ce65b805bd0ba913a02ecfe63d22cac3dbaed3d97c23afd053fe2", 5 | "author": "", 6 | "votes": 549, 7 | "choices": ["YES", "NO", "ABSTAIN"], 8 | "space": { "id": "", "network": "", "settings": "" } 9 | } 10 | -------------------------------------------------------------------------------- /test/fixtures/hub-proposal-0x0da0673d17298e8f52c88385959952d21c2d0ae2fff2f0fea9df02ca0590cb6a.json: -------------------------------------------------------------------------------- 1 | { 2 | "state": "closed", 3 | "type": "ranked-choice", 4 | "id": "0x0da0673d17298e8f52c88385959952d21c2d0ae2fff2f0fea9df02ca0590cb6a", 5 | "author": "", 6 | "votes": 2, 7 | "privacy": "shutter", 8 | "choices": ["1", "2"], 9 | "space": { "id": "", "network": "", "settings": "" } 10 | } 11 | -------------------------------------------------------------------------------- /test/fixtures/hub-proposal-0x1e5fdb5c87867a94c1c7f27025d62851ea47f6072f2296ca53a48fce1b87cdef.json: -------------------------------------------------------------------------------- 1 | { 2 | "state": "closed", 3 | "type": "single-choice", 4 | "id": "0x1e5fdb5c87867a94c1c7f27025d62851ea47f6072f2296ca53a48fce1b87cdef", 5 | "author": "", 6 | "votes": 46, 7 | "choices": ["1", "2"], 8 | "space": { "id": "", "network": "", "settings": "" } 9 | } 10 | -------------------------------------------------------------------------------- /test/fixtures/hub-proposal-0x79ae5f9eb3c710179cfbf706fa451459ddd18d4b0bce37c22aae601128efe927.json: -------------------------------------------------------------------------------- 1 | { 2 | "state": "closed", 3 | "type": "weighted", 4 | "id": "0x79ae5f9eb3c710179cfbf706fa451459ddd18d4b0bce37c22aae601128efe927", 5 | "author": "", 6 | "votes": 1175, 7 | "choices": [ 8 | "Apeancestor", 9 | "badteeth", 10 | "Bernard", 11 | "BoredApeG", 12 | "boredelon", 13 | "BRYTE", 14 | "camol", 15 | "Crypto2", 16 | "CryptoLogically", 17 | "CypherGlaze", 18 | "Degentraland", 19 | "FunkyFred", 20 | "Gerry", 21 | "giacolmo.eth", 22 | "Heffjobbs", 23 | "herb", 24 | "hollanderadam", 25 | "Igor", 26 | "jerediscool", 27 | "j-mart ", 28 | "JtotheT", 29 | "JVB", 30 | "lior.eth", 31 | "ms_nfty", 32 | "NFTC", 33 | "Novocrypto", 34 | "oleg", 35 | "Pacey_eth", 36 | "Peter", 37 | "Pizza-HorseRace", 38 | "POSTGRID", 39 | "roosh", 40 | "TheFatherOfAllStorms", 41 | "wrongplace", 42 | "veratheape", 43 | "Vish.eth", 44 | "ZastrowBradley", 45 | "0xK", 46 | "8uddha", 47 | "8zal" 48 | ], 49 | "space": { "id": "", "network": "", "settings": "" } 50 | } 51 | -------------------------------------------------------------------------------- /test/fixtures/hub-proposal-0xafe3a0426d4e6c645e869707f1b581765698d80c8d3e9cd37d7d3bf5e6f894e7.json: -------------------------------------------------------------------------------- 1 | { 2 | "state": "closed", 3 | "type": "ranked-choice", 4 | "id": "0xafe3a0426d4e6c645e869707f1b581765698d80c8d3e9cd37d7d3bf5e6f894e7", 5 | "author": "", 6 | "votes": 219, 7 | "choices": [ 8 | "Brendan Donovan", 9 | "Brian Watson ", 10 | "Daniel Keller", 11 | "Danielle Clark", 12 | "David ", 13 | "Ely Sandvik ", 14 | "Erikson Herman", 15 | "James Tunningley/Ruben Amenyogbo", 16 | "Joshua Phelps", 17 | "keeks", 18 | "Lia Godoy", 19 | "Maya Bakhai", 20 | "Michael Gutierrez", 21 | "Nick George", 22 | "Nicole Tremaglio", 23 | "Sinikiwe Stephanie Dhliwayo " 24 | ], 25 | "space": { "id": "", "network": "", "settings": "" } 26 | } 27 | -------------------------------------------------------------------------------- /test/fixtures/hub-proposal-0xbb1b4f1f866fda9c1c19ff31bc32c98f92d70f2055a3ba26a502377cf2d1e743.json: -------------------------------------------------------------------------------- 1 | { 2 | "state": "active", 3 | "type": "ranked-choice", 4 | "id": "0xbb1b4f1f866fda9c1c19ff31bc32c98f92d70f2055a3ba26a502377cf2d1e743", 5 | "author": "", 6 | "votes": 2, 7 | "privacy": "shutter", 8 | "choices": ["1", "2"], 9 | "space": { "id": "", "network": "", "settings": "" } 10 | } 11 | -------------------------------------------------------------------------------- /test/fixtures/hub-proposal-0xd37c87edb3cbd78d58a78056b4facb00df739fdf3a16b168305e9cfdd00b3ab5.json: -------------------------------------------------------------------------------- 1 | { 2 | "state": "closed", 3 | "type": "ranked-choice", 4 | "id": "0xd37c87edb3cbd78d58a78056b4facb00df739fdf3a16b168305e9cfdd00b3ab5", 5 | "author": "", 6 | "votes": 2, 7 | "privacy": "shutter", 8 | "choices": ["1", "2"], 9 | "space": { "id": "", "network": "", "settings": "" } 10 | } 11 | -------------------------------------------------------------------------------- /test/fixtures/hub-proposal-0xe5e335af87dc10206e9f0de469f64901407837d659db6703cb3ea1437056a577.json: -------------------------------------------------------------------------------- 1 | { 2 | "state": "closed", 3 | "type": "approval", 4 | "id": "0xe5e335af87dc10206e9f0de469f64901407837d659db6703cb3ea1437056a577", 5 | "author": "", 6 | "votes": 243, 7 | "choices": [ 8 | "Dr.Hash“Wesley”", 9 | "0xpeas.eth", 10 | "Blockpunk", 11 | "Christian", 12 | "Dingaling", 13 | "Freelunchcapital", 14 | "Andrew Kang", 15 | "Yat Siu", 16 | "Icedcoffee", 17 | "Ray", 18 | "Zagabond" 19 | ], 20 | "space": { "id": "", "network": "", "settings": "" } 21 | } 22 | -------------------------------------------------------------------------------- /test/fixtures/hub-votes-0x0da0673d17298e8f52c88385959952d21c2d0ae2fff2f0fea9df02ca0590cb6a.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "ipfs": "bafkreicfll75wtsmsvjk2oksmvfrwxtdduf72hfygcouwnkjitrzkx34ci", 4 | "voter": "0x9c1b73E80D992389078a8ABc8B6039665C4846e7", 5 | "choice": ["1", "2"], 6 | "vp": 20.992980242869496, 7 | "reason": "", 8 | "created": 1681954050 9 | }, 10 | { 11 | "ipfs": "bafkreiho66x5sasd3h5bfs3on7bnoxiwn55x2gx4kcvq7el44mkqf257fi", 12 | "voter": "0x5fb1E3A4b8968f6D0B276514A22eDF3a88B3FdD6", 13 | "choice": ["2", "1"], 14 | "vp": 323.2730220659731, 15 | "reason": "", 16 | "created": 1681954081 17 | } 18 | ] 19 | -------------------------------------------------------------------------------- /test/fixtures/hub-votes-0x1e5fdb5c87867a94c1c7f27025d62851ea47f6072f2296ca53a48fce1b87cdef.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "ipfs": "bafkreicfll75wtsmsvjk2oksmvfrwxtdduf72hfygcouwnkjitrzkx34ci", 4 | "voter": "0x9c1b73E80D992389078a8ABc8B6039665C4846e7", 5 | "choice": 1, 6 | "vp": 20.992980242869496, 7 | "reason": "", 8 | "created": 1681954050 9 | }, 10 | { 11 | "ipfs": "bafkreiho66x5sasd3h5bfs3on7bnoxiwn55x2gx4kcvq7el44mkqf257fi", 12 | "voter": "0x5fb1E3A4b8968f6D0B276514A22eDF3a88B3FdD6", 13 | "choice": 1, 14 | "vp": 323.2730220659731, 15 | "reason": "", 16 | "created": 1681954081 17 | }, 18 | { 19 | "ipfs": "bafkreie2hie4f3lgv3vx3nocdq3v553qmbsgjnqcbc7ezcda4kk3h7yzua", 20 | "voter": "0x44c80295d194cf25BDc3454b4016966ac319E848", 21 | "choice": 1, 22 | "vp": 0.3, 23 | "reason": "", 24 | "created": 1681959291 25 | }, 26 | { 27 | "ipfs": "bafkreigigiii3euxs6vcxrocy3rexs63gurpgnjbwgeb52zpr5jsqlbo74", 28 | "voter": "0xa1ab28131CdFeca1eC167045C8DFb9d2Ae75Ad0C", 29 | "choice": 1, 30 | "vp": 817.3041281237311, 31 | "reason": "", 32 | "created": 1681960650 33 | }, 34 | { 35 | "ipfs": "bafkreifoyraw5pz7d2j22bgqepzvo6gx4kyxgxzx7riavtkl2uozhax4ve", 36 | "voter": "0xb6d17997Af674EF938BB3ead45bcbb54284A9a9F", 37 | "choice": 1, 38 | "vp": 4.876577470815307, 39 | "reason": "", 40 | "created": 1681960739 41 | }, 42 | { 43 | "ipfs": "bafkreig6gdyx5lkx6sxqxxfs5ab4n7recafwzmwhcgzysplh5j4aocxhf4", 44 | "voter": "0x843146d71d57af0E731F85dB1F094643776C8E91", 45 | "choice": 1, 46 | "vp": 1.0076503593247748, 47 | "reason": "", 48 | "created": 1681960828 49 | }, 50 | { 51 | "ipfs": "bafkreifrbmteh7tzfptlmf5sl6or7hw4jffugchd4aentjmupniwlfhtye", 52 | "voter": "0x7683c36e3067aD69e2ac06DDB21250EAF4e4Ffed", 53 | "choice": 1, 54 | "vp": 39.08821671397446, 55 | "reason": "", 56 | "created": 1681961100 57 | }, 58 | { 59 | "ipfs": "bafkreidnd5qbsblyihevuyvziccinghipzkgymrzkliq7o62sehh2riltu", 60 | "voter": "0xF860AaC8d4D61370FAED243C6C4C7c93dEe65679", 61 | "choice": 1, 62 | "vp": 0.00855293507004935, 63 | "reason": "", 64 | "created": 1681961562 65 | }, 66 | { 67 | "ipfs": "bafkreig4yznqgixw3r2wkfga7mgsn3ggoec4f3ks37yldcdmefxosx7tny", 68 | "voter": "0x9261FB273d1ACcBe2f15df1C254E7fCC971468aC", 69 | "choice": 1, 70 | "vp": 10.759693759553794, 71 | "reason": "", 72 | "created": 1681962445 73 | }, 74 | { 75 | "ipfs": "bafkreidelkqkm2s7vyt4uappt4df55t3a6pi7gr7rux3cmscconrc2tqrq", 76 | "voter": "0xb24869a014065b2255797f98DdF72c8244f6F476", 77 | "choice": 1, 78 | "vp": 1.1945708804510298, 79 | "reason": "", 80 | "created": 1681962867 81 | }, 82 | { 83 | "ipfs": "bafkreifmgrtoczi32b5b4oy7egfynuc5m6thcsycpkfcaqibmnd6mtamge", 84 | "voter": "0x4C9640a65af420A339cD2D5B2cE8D55ae83BED1e", 85 | "choice": 1, 86 | "vp": 9543.953905227729, 87 | "reason": "", 88 | "created": 1681965054 89 | }, 90 | { 91 | "ipfs": "bafkreigkuyfxej53rid5sa3v5ihimgjbxhjxesqfl7eckkpiutk7yjjte4", 92 | "voter": "0x4214C3DD58651fdC5d4a52aB69cB16433408A169", 93 | "choice": 1, 94 | "vp": 0.056, 95 | "reason": "", 96 | "created": 1681966678 97 | }, 98 | { 99 | "ipfs": "bafkreifc2yrjrl5nx54y7yzh3heidiqqzenbqvuvbvoasqeq7ev4wsxscq", 100 | "voter": "0xB5A65a7b0373e3d6385f5DE7F583c299982B865B", 101 | "choice": 1, 102 | "vp": 0.03526254356347258, 103 | "reason": "", 104 | "created": 1681968420 105 | }, 106 | { 107 | "ipfs": "bafkreidacjzj7g3r5umsatw2ltreq444e43oe4w56a5nxhcngcjbgkdpby", 108 | "voter": "0x5BBC261De4Ffd714585d75B242575A2C55e7d89f", 109 | "choice": 2, 110 | "vp": 0.2676423905614063, 111 | "reason": "", 112 | "created": 1681970107 113 | }, 114 | { 115 | "ipfs": "bafkreiexqahvhexvznntpp36rtcnow4fbwap2zuu4wjahfkh77uubnm3x4", 116 | "voter": "0x4895bd5Fc670D3c657064bed13A5180e9a3C2C39", 117 | "choice": 1, 118 | "vp": 0.363410151520198, 119 | "reason": "", 120 | "created": 1681971330 121 | }, 122 | { 123 | "ipfs": "bafkreihgagmyp2f2x3s5gr72g5tk3fkabkt7ukvhb7oc3wglwrtoamfzpe", 124 | "voter": "0xF943437C351aCeD31c5a5Eb224B107E99DCebaf6", 125 | "choice": 1, 126 | "vp": 0.008783680598275861, 127 | "reason": "", 128 | "created": 1681974497 129 | }, 130 | { 131 | "ipfs": "bafkreib7elmyi6rkjszq3exymo7dlogqqzo5fefsgmvoxlbgphbcsblp3i", 132 | "voter": "0xD41cEF4f79a2cF4b9Ceb670E64029A93a62b8FB6", 133 | "choice": 2, 134 | "vp": 0.047011016629204774, 135 | "reason": "", 136 | "created": 1681974851 137 | }, 138 | { 139 | "ipfs": "bafkreiadzxyi26lfvtf5h33ebyexd24lkps5urmvjpsuxpwannkgbqgsxy", 140 | "voter": "0x6c07619AdB26f83b81B1DC8e5AA4B71444F50053", 141 | "choice": 1, 142 | "vp": 1.0101487431893934, 143 | "reason": "", 144 | "created": 1681975703 145 | }, 146 | { 147 | "ipfs": "bafkreicfowvl6tad4u6olez335tw3lrj3kqfm2b37jipgfzvsoorkbglb4", 148 | "voter": "0xC1d02aBAdc2fed2619512ADeA1418Bd83490E66d", 149 | "choice": 1, 150 | "vp": 0.25336116942241244, 151 | "reason": "", 152 | "created": 1681977973 153 | }, 154 | { 155 | "ipfs": "bafkreiae6iwec7hbnrjctazlqo6wuw5tuphygnxt7aqtpm64wukpinjw3a", 156 | "voter": "0xd3AEb3A729EF9713847696E1363DAeB378474c06", 157 | "choice": 1, 158 | "vp": 0.007117064411653239, 159 | "reason": "", 160 | "created": 1681979081 161 | }, 162 | { 163 | "ipfs": "bafkreidywpmusf2mp374rgryu43z23aghfhw2xfs3nu6mbnnmsfqtjh6yq", 164 | "voter": "0x614168aa18fB121833Ec9E55f9a038D4bC2E2Dfe", 165 | "choice": 1, 166 | "vp": 0.04257576529766999, 167 | "reason": "", 168 | "created": 1681980827 169 | }, 170 | { 171 | "ipfs": "bafkreiahqnyrtm53kd4iydjodxfbc6iy7f5jskabaubg5ufl5izenwmnxm", 172 | "voter": "0x3A3495A75167C40C29F7f4cc8b844A418aB0b927", 173 | "choice": 1, 174 | "vp": 0.1187, 175 | "reason": "", 176 | "created": 1681981769 177 | }, 178 | { 179 | "ipfs": "bafkreighxdofakupv34s5bmmqfo2psn2uywjnghjw5mx4vee6zvyzuasce", 180 | "voter": "0x43Ed9D9120fb7e9E2de1A9dEb7BE2A8B75230DEf", 181 | "choice": 1, 182 | "vp": 1.0370890131067922, 183 | "reason": "", 184 | "created": 1681982003 185 | }, 186 | { 187 | "ipfs": "bafkreifavlrhpqophevdy2kjeor7bexo24bm2miwhyi4elmyole5qa4vqm", 188 | "voter": "0x241202A851dBb3cBE68a42Ac2c7f11aB2628a5dA", 189 | "choice": 1, 190 | "vp": 1, 191 | "reason": "", 192 | "created": 1681982459 193 | }, 194 | { 195 | "ipfs": "bafkreic67yn2cxo4ooxaltrco3cyzoujbrk2jdzh36pbfczwbmqg2ten7y", 196 | "voter": "0x6Dd540Cae53fFD24928595520bEc73cf08A5748d", 197 | "choice": 1, 198 | "vp": 0.008782925857173447, 199 | "reason": "", 200 | "created": 1681985862 201 | }, 202 | { 203 | "ipfs": "bafkreigmmthvsfvq66r33btcegrrg3jlmaez3jltryr5zbnk65bose6fee", 204 | "voter": "0x11f07f91C8965f4C1fdB37E9896751CF6CBa420c", 205 | "choice": 1, 206 | "vp": 0.00878217121334398, 207 | "reason": "", 208 | "created": 1681986298 209 | }, 210 | { 211 | "ipfs": "bafkreibjxwkoyxxrortdxl26lscnrturqvgw5ntccvqa4pxijk3lkeszl4", 212 | "voter": "0xA73adAAe3C6924977f9950212B688558c87b65bb", 213 | "choice": 1, 214 | "vp": 0.008781416666770745, 215 | "reason": "", 216 | "created": 1681986584 217 | }, 218 | { 219 | "ipfs": "bafkreienxpc4lkklcxszybrlnc6ch2dexaamjqoaerohuotpmr22zgpqb4", 220 | "voter": "0xB35b2d4366f8405036F28411c2d224C53EF8D828", 221 | "choice": 1, 222 | "vp": 0.008780662217437028, 223 | "reason": "", 224 | "created": 1681986977 225 | }, 226 | { 227 | "ipfs": "bafkreihiqnf2wak6wn24l7pcncpbum2egnldkhxnqlmqp57j5f2mx2cqdi", 228 | "voter": "0x5CD53625FBB43139155301fBC75E19C654633696", 229 | "choice": 1, 230 | "vp": 0.008779907865326123, 231 | "reason": "", 232 | "created": 1681987839 233 | }, 234 | { 235 | "ipfs": "bafkreie5d3s4dzvkcu6jpkhclyeoiai73nllgbuzii2gdxigmur57anlwq", 236 | "voter": "0xC1fF3BA04eC76EBc5554819197780008C52C6Ad4", 237 | "choice": 1, 238 | "vp": 0.008779153610421328, 239 | "reason": "", 240 | "created": 1681988247 241 | }, 242 | { 243 | "ipfs": "bafkreidygkhpkbzekjbl7kprfvep7hkdqdi6llmhfd77xfj7fsi7onmceq", 244 | "voter": "0x89B105cA091FE2fC6121259B4C69eB5eED590ab9", 245 | "choice": 1, 246 | "vp": 0.008778399452705936, 247 | "reason": "", 248 | "created": 1681988556 249 | }, 250 | { 251 | "ipfs": "bafkreidmva3qi4r26wgybo4zr5eulx2tsdmtpp5dkyfahzco2wprahi2vi", 252 | "voter": "0x92877E5da60931df93aaAa0709976bCCBCC10457", 253 | "choice": 1, 254 | "vp": 370.53966786102785, 255 | "reason": "", 256 | "created": 1681989124 257 | }, 258 | { 259 | "ipfs": "bafkreicivjdmeuyzsen5z44xsa53d52zdpcprv6htrky5ssdnk5shaxchy", 260 | "voter": "0x944F885101d75e559B3C67892a816274e67fC93E", 261 | "choice": 1, 262 | "vp": 102.17540817044627, 263 | "reason": "", 264 | "created": 1681990551 265 | }, 266 | { 267 | "ipfs": "bafkreihlkotx27nkgsaudeauqu247iaevj4efwe7usvkdslrxxevyivb54", 268 | "voter": "0xf69f3f81f9f99B183fDeAD8266F5DE10D91a2A36", 269 | "choice": 1, 270 | "vp": 0.596635891413412, 271 | "reason": "", 272 | "created": 1681997019 273 | }, 274 | { 275 | "ipfs": "bafkreic4zlczi7qpvtjqzppskw2gde7rism7hx67nhi45b2suwn3abvyla", 276 | "voter": "0x0b5CC691fBF7923da8a30DB4A8bb8c9F9CE9f7d5", 277 | "choice": 1, 278 | "vp": 1266.6242669607032, 279 | "reason": "", 280 | "created": 1681997081 281 | }, 282 | { 283 | "ipfs": "bafkreigjujlu2njxinfxx7xm47akv2qpvgwgcfs5kjv3gbzokvhkgcjdai", 284 | "voter": "0x4D11AAA69158595085D13D5e1a2b22c1d0D5F348", 285 | "choice": 1, 286 | "vp": 4.113974105568486, 287 | "reason": "", 288 | "created": 1681997257 289 | }, 290 | { 291 | "ipfs": "bafkreihsyljqnqxianweveqs55gp2ohcelc6jn2qyb3b2b2wteefmqc7qm", 292 | "voter": "0x40a82a3aC2bb08E82133E25cBC6Ca128dc7162aa", 293 | "choice": 1, 294 | "vp": 1.102770884550539, 295 | "reason": "", 296 | "created": 1681997819 297 | }, 298 | { 299 | "ipfs": "bafkreie77oi7v5cruoomb4mcmyjpl2pgcambu7mfhdr6cumep6awcjmibm", 300 | "voter": "0x4a27BfD91B30EfAf08706d2105e5D8A1ad09fF0C", 301 | "choice": 1, 302 | "vp": 0.8802996349326384, 303 | "reason": "", 304 | "created": 1681998881 305 | }, 306 | { 307 | "ipfs": "bafkreiaqa233efqbmrazhuafob7m6j3bekzrftiufssyghcw5hgh5t7tom", 308 | "voter": "0xBc9a6eA3E32B05e87f3e3e0DdbB23FC0C62c61f3", 309 | "choice": 1, 310 | "vp": 0.15, 311 | "reason": "", 312 | "created": 1681999125 313 | }, 314 | { 315 | "ipfs": "bafkreic7e6pnt5kvzhmbcuz3i7gz5frlwgsi5mb34zzovixppedhctdjwe", 316 | "voter": "0xf094C2406f91E946d8C4757A2F8c00D9Ec4D703E", 317 | "choice": 1, 318 | "vp": 0.14105291551394503, 319 | "reason": "", 320 | "created": 1681999487 321 | }, 322 | { 323 | "ipfs": "bafkreic3f6wv3xpgs23fvwjeacihokohyt5xewvtfgbe6vkmeb2q6a7asq", 324 | "voter": "0xf967817B892F82da16eE21fdd0667fcF9Cc9caf1", 325 | "choice": 1, 326 | "vp": 0.09029066083725476, 327 | "reason": "", 328 | "created": 1681999968 329 | }, 330 | { 331 | "ipfs": "bafkreidykcj2ix27k6em2tiyxnv5oybgkwcew5o5ioktxejx5vvlzx3jwy", 332 | "voter": "0x87430f80f12ce92eaADD51D26e26dA71F4dCa41e", 333 | "choice": 1, 334 | "vp": 4.994, 335 | "reason": "", 336 | "created": 1682013194 337 | }, 338 | { 339 | "ipfs": "bafkreietpxjvzxmfaoq4c5p5x44lsoeu3ybzrslpayzoiz47tlu6mi5bry", 340 | "voter": "0x96BE7245790Cf38ab589ce2a3a908C4c70be15bd", 341 | "choice": 1, 342 | "vp": 1131.2047418585335, 343 | "reason": "", 344 | "created": 1682019336 345 | }, 346 | { 347 | "ipfs": "bafkreia24ib6wv6ofskajn4fyedvmnak4txjalr7id6dferxttlvezw3ja", 348 | "voter": "0x6336808ABFc29A6168A15B72D6BEDC6562a3Cd82", 349 | "choice": 1, 350 | "vp": 420.575758599564, 351 | "reason": "", 352 | "created": 1682020613 353 | }, 354 | { 355 | "ipfs": "bafkreideeoh3ehrrscsgewa5nnli44mftnefvvpsm3ldpakqrravmohspy", 356 | "voter": "0xce6364C714bfDF453f01b0A5E6EBA500392976fF", 357 | "choice": 1, 358 | "vp": 7.135262207754166, 359 | "reason": "", 360 | "created": 1682030293 361 | }, 362 | { 363 | "ipfs": "bafkreiafjencjrmwj4b6otsebkkygh7sw76dcpjhdrbz2vi6jrc44eybn4", 364 | "voter": "0x6c35bBEa907A5E653199ba885a7D9abd5Cb3a2f3", 365 | "choice": 1, 366 | "vp": 0.2, 367 | "reason": "", 368 | "created": 1682040019 369 | } 370 | ] 371 | -------------------------------------------------------------------------------- /test/fixtures/hub-votes-0xbb1b4f1f866fda9c1c19ff31bc32c98f92d70f2055a3ba26a502377cf2d1e743.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "ipfs": "bafkreicfll75wtsmsvjk2oksmvfrwxtdduf72hfygcouwnkjitrzkx34ci", 4 | "voter": "0x9c1b73E80D992389078a8ABc8B6039665C4846e7", 5 | "choice": "0x070d3dc0a30ae5598a31f9cf8a6fac6bb0c2274292120754d610e47a84e2506b18f4ac00bf9efc9b5335364a26cf4b504186f951aac7486f17e81d1002e22cb500031bb37e2a28eb8b7317cb0c479764e37e60f7bde6798d80fc86d5975815cc2cec436b20a8a5250e0e8e31be96bc260a08abf047425f09a2f7833e91852d27fbb41348733f889b4ae640ecebb176a527bd2761bea5824ad5b578bd3e3bab0a35ff265a71a1228891d62881b84939f09b7f321cd0442a3e8b436bcc3c5f4ff4", 6 | "vp": 20.992980242869496, 7 | "reason": "", 8 | "created": 1681954050 9 | }, 10 | { 11 | "ipfs": "bafkreiho66x5sasd3h5bfs3on7bnoxiwn55x2gx4kcvq7el44mkqf257fi", 12 | "voter": "0x5fb1E3A4b8968f6D0B276514A22eDF3a88B3FdD6", 13 | "choice": "0x0e86cba392bac10acabcacb95a85d0a08b4d4e3b547a5fb1431bea5874c688ec0a42a171aa0d8695bd6d8d4cb52fede9d68e4ce3d94b1881dd65373c89e521d21511e1b7aef4ddcf94e7db6d5399d23f9de60fd11059f36f59184f1c583c908c06152bb611e2722c5d545aaedf4b6b3b6941d912a1a63c2e6bd57eba3e4ac130bf4e9b1715393eb61bbbbbb016744bc9d56d7f137b3551c1ab8da2df9bdfc5baa8aec604332ff96e0e8d3bc66f9ef1a07f479d643ab9827b1225a2f27f5c692e", 14 | "vp": 323.2730220659731, 15 | "reason": "", 16 | "created": 1681954081 17 | } 18 | ] 19 | -------------------------------------------------------------------------------- /test/fixtures/hub-votes-0xd37c87edb3cbd78d58a78056b4facb00df739fdf3a16b168305e9cfdd00b3ab5.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "ipfs": "bafkreicfll75wtsmsvjk2oksmvfrwxtdduf72hfygcouwnkjitrzkx34ci", 4 | "voter": "0x9c1b73E80D992389078a8ABc8B6039665C4846e7", 5 | "choice": "0x070d3dc0a30ae5598a31f9cf8a6fac6bb0c2274292120754d610e47a84e2506b18f4ac00bf9efc9b5335364a26cf4b504186f951aac7486f17e81d1002e22cb500031bb37e2a28eb8b7317cb0c479764e37e60f7bde6798d80fc86d5975815cc2cec436b20a8a5250e0e8e31be96bc260a08abf047425f09a2f7833e91852d27fbb41348733f889b4ae640ecebb176a527bd2761bea5824ad5b578bd3e3bab0a35ff265a71a1228891d62881b84939f09b7f321cd0442a3e8b436bcc3c5f4ff4", 6 | "vp": 20.992980242869496, 7 | "reason": "", 8 | "created": 1681954050 9 | }, 10 | { 11 | "ipfs": "bafkreiho66x5sasd3h5bfs3on7bnoxiwn55x2gx4kcvq7el44mkqf257fi", 12 | "voter": "0x5fb1E3A4b8968f6D0B276514A22eDF3a88B3FdD6", 13 | "choice": ["1", "2"], 14 | "vp": 323.2730220659731, 15 | "reason": "", 16 | "created": 1681954081 17 | } 18 | ] 19 | -------------------------------------------------------------------------------- /test/fixtures/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/snapshot-labs/snapshot-sidekick/d95629bd85d50a39fa6d29bf5f1da8a3fa913e98/test/fixtures/icon.png -------------------------------------------------------------------------------- /test/fixtures/moderation.ts: -------------------------------------------------------------------------------- 1 | // Mock return results from SQL 2 | export const SqlFixtures: Record = { 3 | flaggedLinks: [ 4 | { action: 'flag', type: 'link', value: 'https://gogle.com', created: 100 }, 5 | { action: 'flag', type: 'link', value: 'https://facebook.com', created: 100 } 6 | ], 7 | flaggedAddresses: [ 8 | { action: 'flag', type: 'address', value: '0x0001', created: 100 }, 9 | { action: 'flag', type: 'address', value: '0x0002', created: 100 } 10 | ], 11 | flaggedIps: [ 12 | { 13 | action: 'flag', 14 | type: 'ip', 15 | value: '12ca17b49af2289436f303e0166030a21e525d266e209267433801a8fd4071a0', 16 | created: 100 17 | }, 18 | { 19 | action: 'flag', 20 | type: 'ip', 21 | value: '19e36255972107d42b8cecb77ef5622e842e8a50778a6ed8dd1ce94732daca9e', 22 | created: 100 23 | } 24 | ] 25 | }; 26 | -------------------------------------------------------------------------------- /test/fixtures/snapshot-votes-report-0x0da0673d17298e8f52c88385959952d21c2d0ae2fff2f0fea9df02ca0590cb6a.csv: -------------------------------------------------------------------------------- 1 | address,choice.1,choice.2,voting_power,timestamp,author_ipfs_hash,reason 2 | 0x9c1b73E80D992389078a8ABc8B6039665C4846e7,1,2,20.992980242869496,1681954050,bafkreicfll75wtsmsvjk2oksmvfrwxtdduf72hfygcouwnkjitrzkx34ci,"" 3 | 0x5fb1E3A4b8968f6D0B276514A22eDF3a88B3FdD6,2,1,323.2730220659731,1681954081,bafkreiho66x5sasd3h5bfs3on7bnoxiwn55x2gx4kcvq7el44mkqf257fi,"" -------------------------------------------------------------------------------- /test/fixtures/snapshot-votes-report-0x1e5fdb5c87867a94c1c7f27025d62851ea47f6072f2296ca53a48fce1b87cdef.csv: -------------------------------------------------------------------------------- 1 | address,choice,voting_power,timestamp,author_ipfs_hash,reason 2 | 0x9c1b73E80D992389078a8ABc8B6039665C4846e7,1,20.992980242869496,1681954050,bafkreicfll75wtsmsvjk2oksmvfrwxtdduf72hfygcouwnkjitrzkx34ci,"" 3 | 0x5fb1E3A4b8968f6D0B276514A22eDF3a88B3FdD6,1,323.2730220659731,1681954081,bafkreiho66x5sasd3h5bfs3on7bnoxiwn55x2gx4kcvq7el44mkqf257fi,"" 4 | 0x44c80295d194cf25BDc3454b4016966ac319E848,1,0.3,1681959291,bafkreie2hie4f3lgv3vx3nocdq3v553qmbsgjnqcbc7ezcda4kk3h7yzua,"" 5 | 0xa1ab28131CdFeca1eC167045C8DFb9d2Ae75Ad0C,1,817.3041281237311,1681960650,bafkreigigiii3euxs6vcxrocy3rexs63gurpgnjbwgeb52zpr5jsqlbo74,"" 6 | 0xb6d17997Af674EF938BB3ead45bcbb54284A9a9F,1,4.876577470815307,1681960739,bafkreifoyraw5pz7d2j22bgqepzvo6gx4kyxgxzx7riavtkl2uozhax4ve,"" 7 | 0x843146d71d57af0E731F85dB1F094643776C8E91,1,1.0076503593247748,1681960828,bafkreig6gdyx5lkx6sxqxxfs5ab4n7recafwzmwhcgzysplh5j4aocxhf4,"" 8 | 0x7683c36e3067aD69e2ac06DDB21250EAF4e4Ffed,1,39.08821671397446,1681961100,bafkreifrbmteh7tzfptlmf5sl6or7hw4jffugchd4aentjmupniwlfhtye,"" 9 | 0xF860AaC8d4D61370FAED243C6C4C7c93dEe65679,1,0.00855293507004935,1681961562,bafkreidnd5qbsblyihevuyvziccinghipzkgymrzkliq7o62sehh2riltu,"" 10 | 0x9261FB273d1ACcBe2f15df1C254E7fCC971468aC,1,10.759693759553794,1681962445,bafkreig4yznqgixw3r2wkfga7mgsn3ggoec4f3ks37yldcdmefxosx7tny,"" 11 | 0xb24869a014065b2255797f98DdF72c8244f6F476,1,1.1945708804510298,1681962867,bafkreidelkqkm2s7vyt4uappt4df55t3a6pi7gr7rux3cmscconrc2tqrq,"" 12 | 0x4C9640a65af420A339cD2D5B2cE8D55ae83BED1e,1,9543.953905227729,1681965054,bafkreifmgrtoczi32b5b4oy7egfynuc5m6thcsycpkfcaqibmnd6mtamge,"" 13 | 0x4214C3DD58651fdC5d4a52aB69cB16433408A169,1,0.056,1681966678,bafkreigkuyfxej53rid5sa3v5ihimgjbxhjxesqfl7eckkpiutk7yjjte4,"" 14 | 0xB5A65a7b0373e3d6385f5DE7F583c299982B865B,1,0.03526254356347258,1681968420,bafkreifc2yrjrl5nx54y7yzh3heidiqqzenbqvuvbvoasqeq7ev4wsxscq,"" 15 | 0x5BBC261De4Ffd714585d75B242575A2C55e7d89f,2,0.2676423905614063,1681970107,bafkreidacjzj7g3r5umsatw2ltreq444e43oe4w56a5nxhcngcjbgkdpby,"" 16 | 0x4895bd5Fc670D3c657064bed13A5180e9a3C2C39,1,0.363410151520198,1681971330,bafkreiexqahvhexvznntpp36rtcnow4fbwap2zuu4wjahfkh77uubnm3x4,"" 17 | 0xF943437C351aCeD31c5a5Eb224B107E99DCebaf6,1,0.008783680598275861,1681974497,bafkreihgagmyp2f2x3s5gr72g5tk3fkabkt7ukvhb7oc3wglwrtoamfzpe,"" 18 | 0xD41cEF4f79a2cF4b9Ceb670E64029A93a62b8FB6,2,0.047011016629204774,1681974851,bafkreib7elmyi6rkjszq3exymo7dlogqqzo5fefsgmvoxlbgphbcsblp3i,"" 19 | 0x6c07619AdB26f83b81B1DC8e5AA4B71444F50053,1,1.0101487431893934,1681975703,bafkreiadzxyi26lfvtf5h33ebyexd24lkps5urmvjpsuxpwannkgbqgsxy,"" 20 | 0xC1d02aBAdc2fed2619512ADeA1418Bd83490E66d,1,0.25336116942241244,1681977973,bafkreicfowvl6tad4u6olez335tw3lrj3kqfm2b37jipgfzvsoorkbglb4,"" 21 | 0xd3AEb3A729EF9713847696E1363DAeB378474c06,1,0.007117064411653239,1681979081,bafkreiae6iwec7hbnrjctazlqo6wuw5tuphygnxt7aqtpm64wukpinjw3a,"" 22 | 0x614168aa18fB121833Ec9E55f9a038D4bC2E2Dfe,1,0.04257576529766999,1681980827,bafkreidywpmusf2mp374rgryu43z23aghfhw2xfs3nu6mbnnmsfqtjh6yq,"" 23 | 0x3A3495A75167C40C29F7f4cc8b844A418aB0b927,1,0.1187,1681981769,bafkreiahqnyrtm53kd4iydjodxfbc6iy7f5jskabaubg5ufl5izenwmnxm,"" 24 | 0x43Ed9D9120fb7e9E2de1A9dEb7BE2A8B75230DEf,1,1.0370890131067922,1681982003,bafkreighxdofakupv34s5bmmqfo2psn2uywjnghjw5mx4vee6zvyzuasce,"" 25 | 0x241202A851dBb3cBE68a42Ac2c7f11aB2628a5dA,1,1,1681982459,bafkreifavlrhpqophevdy2kjeor7bexo24bm2miwhyi4elmyole5qa4vqm,"" 26 | 0x6Dd540Cae53fFD24928595520bEc73cf08A5748d,1,0.008782925857173447,1681985862,bafkreic67yn2cxo4ooxaltrco3cyzoujbrk2jdzh36pbfczwbmqg2ten7y,"" 27 | 0x11f07f91C8965f4C1fdB37E9896751CF6CBa420c,1,0.00878217121334398,1681986298,bafkreigmmthvsfvq66r33btcegrrg3jlmaez3jltryr5zbnk65bose6fee,"" 28 | 0xA73adAAe3C6924977f9950212B688558c87b65bb,1,0.008781416666770745,1681986584,bafkreibjxwkoyxxrortdxl26lscnrturqvgw5ntccvqa4pxijk3lkeszl4,"" 29 | 0xB35b2d4366f8405036F28411c2d224C53EF8D828,1,0.008780662217437028,1681986977,bafkreienxpc4lkklcxszybrlnc6ch2dexaamjqoaerohuotpmr22zgpqb4,"" 30 | 0x5CD53625FBB43139155301fBC75E19C654633696,1,0.008779907865326123,1681987839,bafkreihiqnf2wak6wn24l7pcncpbum2egnldkhxnqlmqp57j5f2mx2cqdi,"" 31 | 0xC1fF3BA04eC76EBc5554819197780008C52C6Ad4,1,0.008779153610421328,1681988247,bafkreie5d3s4dzvkcu6jpkhclyeoiai73nllgbuzii2gdxigmur57anlwq,"" 32 | 0x89B105cA091FE2fC6121259B4C69eB5eED590ab9,1,0.008778399452705936,1681988556,bafkreidygkhpkbzekjbl7kprfvep7hkdqdi6llmhfd77xfj7fsi7onmceq,"" 33 | 0x92877E5da60931df93aaAa0709976bCCBCC10457,1,370.53966786102785,1681989124,bafkreidmva3qi4r26wgybo4zr5eulx2tsdmtpp5dkyfahzco2wprahi2vi,"" 34 | 0x944F885101d75e559B3C67892a816274e67fC93E,1,102.17540817044627,1681990551,bafkreicivjdmeuyzsen5z44xsa53d52zdpcprv6htrky5ssdnk5shaxchy,"" 35 | 0xf69f3f81f9f99B183fDeAD8266F5DE10D91a2A36,1,0.596635891413412,1681997019,bafkreihlkotx27nkgsaudeauqu247iaevj4efwe7usvkdslrxxevyivb54,"" 36 | 0x0b5CC691fBF7923da8a30DB4A8bb8c9F9CE9f7d5,1,1266.6242669607032,1681997081,bafkreic4zlczi7qpvtjqzppskw2gde7rism7hx67nhi45b2suwn3abvyla,"" 37 | 0x4D11AAA69158595085D13D5e1a2b22c1d0D5F348,1,4.113974105568486,1681997257,bafkreigjujlu2njxinfxx7xm47akv2qpvgwgcfs5kjv3gbzokvhkgcjdai,"" 38 | 0x40a82a3aC2bb08E82133E25cBC6Ca128dc7162aa,1,1.102770884550539,1681997819,bafkreihsyljqnqxianweveqs55gp2ohcelc6jn2qyb3b2b2wteefmqc7qm,"" 39 | 0x4a27BfD91B30EfAf08706d2105e5D8A1ad09fF0C,1,0.8802996349326384,1681998881,bafkreie77oi7v5cruoomb4mcmyjpl2pgcambu7mfhdr6cumep6awcjmibm,"" 40 | 0xBc9a6eA3E32B05e87f3e3e0DdbB23FC0C62c61f3,1,0.15,1681999125,bafkreiaqa233efqbmrazhuafob7m6j3bekzrftiufssyghcw5hgh5t7tom,"" 41 | 0xf094C2406f91E946d8C4757A2F8c00D9Ec4D703E,1,0.14105291551394503,1681999487,bafkreic7e6pnt5kvzhmbcuz3i7gz5frlwgsi5mb34zzovixppedhctdjwe,"" 42 | 0xf967817B892F82da16eE21fdd0667fcF9Cc9caf1,1,0.09029066083725476,1681999968,bafkreic3f6wv3xpgs23fvwjeacihokohyt5xewvtfgbe6vkmeb2q6a7asq,"" 43 | 0x87430f80f12ce92eaADD51D26e26dA71F4dCa41e,1,4.994,1682013194,bafkreidykcj2ix27k6em2tiyxnv5oybgkwcew5o5ioktxejx5vvlzx3jwy,"" 44 | 0x96BE7245790Cf38ab589ce2a3a908C4c70be15bd,1,1131.2047418585335,1682019336,bafkreietpxjvzxmfaoq4c5p5x44lsoeu3ybzrslpayzoiz47tlu6mi5bry,"" 45 | 0x6336808ABFc29A6168A15B72D6BEDC6562a3Cd82,1,420.575758599564,1682020613,bafkreia24ib6wv6ofskajn4fyedvmnak4txjalr7id6dferxttlvezw3ja,"" 46 | 0xce6364C714bfDF453f01b0A5E6EBA500392976fF,1,7.135262207754166,1682030293,bafkreideeoh3ehrrscsgewa5nnli44mftnefvvpsm3ldpakqrravmohspy,"" 47 | 0x6c35bBEa907A5E653199ba885a7D9abd5Cb3a2f3,1,0.2,1682040019,bafkreiafjencjrmwj4b6otsebkkygh7sw76dcpjhdrbz2vi6jrc44eybn4,"" -------------------------------------------------------------------------------- /test/fixtures/snapshot-votes-report-0xafe3a0426d4e6c645e869707f1b581765698d80c8d3e9cd37d7d3bf5e6f894e7.csv: -------------------------------------------------------------------------------- 1 | address,choice.1,choice.2,choice.3,choice.4,choice.5,choice.6,choice.7,choice.8,choice.9,choice.10,choice.11,choice.12,choice.13,choice.14,choice.15,choice.16,voting_power,timestamp,author_ipfs_hash,reason 2 | 0x3919Adc5AF250dA5a5fAB98b8346E959f03A7E8f,3,9,4,13,7,14,11,2,15,16,5,1,6,8,10,12,1,1697148626,bafkreifb6klwmvi5pnc5pbn6ewbxucnf6szvtc2fiktqdsb3ih2ozbpj7e,"" 3 | 0x8Da15F7e6bf20Eae393D0210d0F69eA98fC8Ea5e,5,3,8,10,16,2,4,12,11,15,6,9,1,14,7,13,1,1697148865,bafkreiavrrg3b3x7vmxdnqiuyw76drhxicetv2pvdfn72y7qbtyj7sk7pe,"" 4 | 0xAb817899D15dA40644470079F2dCF237Eed0D004,13,9,3,7,4,11,14,6,8,15,2,12,10,1,5,16,1,1697148991,bafkreigpyfgppdsfcaenzb43tapcp23inmaguuwg3jjzsu6ksygodvfy2e,"" 5 | 0x66Da63B03feCA7Dd44a5bB023BB3645D3252Fa32,10,7,2,12,16,15,5,8,6,13,1,4,14,9,3,11,1,1697149845,bafkreif6prqcis4najzopfkdgwvbp3tjzhlbhghs24viztixf3rdm3bnce,"" 6 | 0xF9D5c9A7200E1Ac4A0fD41b2a3d28FF5cf3F2284,3,16,15,13,7,11,1,2,4,5,6,8,9,10,12,14,1,1697149877,bafkreihr4k7pxfqzdlntzp2a6s3jaqhoacxhzzneugovvnhacn2zaskcdu,"" 7 | 0x1d14d9e297DfbcE003f5A8EbcF8cBa7fAEe70B91,16,15,9,6,10,7,12,11,13,4,2,14,3,1,8,5,1,1697149889,bafkreigpj5jfa4aoupfvgginwkoetq5pjedeygrbwwoybdzlb5fpurw4wy,"" 8 | 0x466fa491b4b62B70E4840A3e7501Cae3ae1cFA6D,3,11,12,14,4,2,6,15,13,9,7,5,1,10,16,8,1,1697150235,bafkreifxn7pbbbijpx6igfysedhzscrluutzrgkdwn7fae64oe2t2k6rcm,"" 9 | 0x7E3A74AB669d4C5f411940e97d1c29db3D39e950,11,3,15,13,7,5,6,1,14,16,9,2,12,4,10,8,1,1697150659,bafkreifgzn5zh2vlk2uotm2lv7uxizegshzoad2m3s73shivtassupdmkm,"" 10 | 0x435d821Ee5b346850545cB18443ca9808A9d47D0,13,9,11,4,14,7,3,10,5,12,2,1,6,15,8,16,1,1697151312,bafkreierjvflnqb2grchp33ihtgp7yljytwivsuxisboaogj6psgv7la5e,"" 11 | 0xf3AB2c2cDcB26A3B50dA3e2A56fd892A16a7eb3B,5,3,16,6,12,14,13,11,10,9,15,7,4,2,1,8,1,1697152010,bafkreieymb6sgq4mwqwczzni5mlfo7m4n7om4qciwwnwikncixf7ar6xby,"" 12 | 0x03d2d3F9f7DBB7195ac4222E45D1217dbC4C0ef7,3,4,14,9,11,15,7,13,10,12,1,2,6,16,5,8,1,1697152025,bafkreif3knmzkl5hdj5geu3tp5wkl6axckuo6cvchc43n3qbbbcf5j5cn4,"" 13 | 0x3dFF073eb7C81601eDf3740dcc3b29f3Fb2C39bA,9,7,4,11,13,16,14,10,2,1,5,3,6,15,8,12,1,1697152813,bafkreidp5vgfni7ajcqyeaph3plcly6pyditbvqrjfatahghnxn5ofxztq,"" 14 | 0x5a13E93873A51Fcb8C6417e8A15D8111250d3B14,11,6,3,7,9,13,14,16,1,2,4,10,12,15,8,5,1,1697152973,bafkreiepqnqcpo3elht4r4twfmr3f4nzgyj35k7szvdoxbkksfpubd4pqe,"" 15 | 0x7d8D2c8EA18f9a3Da11066f02057DAd708f97e8F,11,3,13,9,7,4,14,16,15,6,12,10,2,5,1,8,1,1697153052,bafkreicgnpmqmrsiaa6r75b2p7dcv25rifcsqoghz3v7hpje4chkemdvoa,"" 16 | 0xB2D6AAF0bca136C252Ec94F0f06c2489F734675f,11,4,15,9,3,12,13,14,5,1,16,6,7,8,10,2,1,1697153551,bafkreie4rguteqc7audn3wxoyewgt42h7xrhdwfqnmxqqf724uihsiqt6u,"" 17 | 0xa34FbC6bB017cd320833d74547fC639ECf39C12b,7,6,2,9,13,12,11,3,10,16,15,1,4,14,5,8,1,1697154181,bafkreib2rgvdd5j7irlfvaf2kjbonnnpv7oskz4qae5bdo32ftk4n6ky7i,"" 18 | 0x1e89e7268d0e030dA65E7203Fa5A3383429b9D40,9,2,16,8,13,1,10,4,11,12,15,6,3,5,7,14,1,1697154445,bafkreifnnbfkwygdfppxbciivlgdwosgpy3serqpwsnwaarqa67za2b2ye,"" 19 | 0x22c55c85A072b90cd0f3E76Fa77dD6B330477d84,7,16,13,6,9,8,2,10,12,11,4,15,5,14,1,3,1,1697158052,bafkreifnwohht4zrdnpr6yue55mb43mzmog7umobuvisv4x7bmtlkzstae,"" 20 | 0x1C714016e639aDA120a24B752c58C51a3BD8Eb9c,5,3,6,4,7,2,1,8,9,12,13,14,11,10,15,16,1,1697158837,bafkreifkix7mruxso62rx3zchsjn35r2fuijytbyyi4owa4u6f66viro5m,"" 21 | 0x87fB7c717A4145095Eb076e239BC0F8Fba42cf49,7,16,9,11,2,15,3,12,1,4,5,6,8,10,13,14,1,1697159960,bafkreiexhqlqon6wkrgljna33ihxaxfhhcpfwiprgsjv4gtxaobdaaerxa,"" 22 | 0x3B60e31CFC48a9074CD5bEbb26C9EAa77650a43F,3,11,9,4,13,7,14,16,10,6,8,1,2,5,12,15,176.3,1697162258,bafkreifidfom7etydlo7aeuevjzkynnox7qcmnyelsh7n5hgwo3rsnlfsq,"" 23 | 0xe9976c99962cDBb8E599EA878bfE4B67ce229884,3,2,11,12,10,9,4,6,7,5,1,14,13,15,16,8,1,1697164790,bafkreigaaqzc4k3zlqwjkoaifsrv5q4vnbxqk7x7hpfslot4ku33b34q5i,"" 24 | 0x3FC6C9e44B23Bf3aD216e5458392A9A214B891FA,9,14,16,7,2,6,12,11,1,3,10,13,4,8,5,15,1,1697166412,bafkreiafsu3rzqvusy55c3sr3inuns5lxljxzqgpyubve2pt54rvcz2gbu,"" 25 | 0x2276D9478932eF7E722C12FC47055ef6e4ae427c,6,7,3,9,11,16,15,4,2,5,12,10,13,14,1,8,1,1697167856,bafkreieoqjfnwfdp5idlngytqpsbebffflxvnbwrg65gttxepcbvqlcpca,"" 26 | 0xAA2F1Afc79855bc11686a1a50013C1CedFEB52A9,6,11,12,7,9,16,15,14,13,10,8,5,4,3,1,2,1,1697168104,bafkreibbkpsjfosd5jwjarb2ac247buocisiexhqetuhpxxhgh6h3d6oha,"" 27 | 0x01503DC708ce3C55017194847A07aCb679D49f47,3,16,6,11,15,7,9,13,12,4,2,5,10,1,14,8,1,1697168600,bafkreid2dwoiwlvsykx2mhoiuv4f7bq3hl7l4gqfudl3jxeii7xkds363e,"" 28 | 0x9e724C94692eea5777cAD2E7EbF40c338169b268,7,10,12,1,2,3,5,8,14,15,16,4,6,11,13,9,1,1697170409,bafkreihogwih7wh56h4gaatnsoppbxrhmwioitdu56kl2nuesmzl6kszdm,"" 29 | 0x7b78F347cc40F15344EDE278EcEC4E39E1F15485,10,7,12,15,16,14,11,9,8,5,3,1,2,4,13,6,1,1697170804,bafkreigmxt2ylvilqpmrqgnyuxynwoynfvi5n4etemxzs2qkn2qislcsia,"" 30 | 0x0040DAAC32D83c78546ae36dA42A496B28ab09E1,1,12,3,5,7,16,14,2,4,9,8,15,6,10,11,13,1,1697175093,bafkreiam4ofzngj55i3kvzctzvmb57q62qhsrndyszyemhcdmpbuxmmk4i,"" 31 | 0x9dD7D725982BF9bB1CF1c5ecaC3Ca68e0bDBD2B9,11,8,6,3,9,4,1,7,12,15,10,2,5,13,14,16,1,1697177334,bafkreiai5lswjxchehd6j6gtztwn6ux3dhpwprhug36zzpbtsy3vcdxzn4,"" 32 | 0xf823825DC97a8c81Ec09D53b6E3F734E76E60cB6,6,7,5,2,16,10,4,3,9,1,13,15,12,8,11,14,1,1697178370,bafkreiftbzln3dhchsutnpmd2th34xzddhkjce62mmbvgsim6merbro4fm,"" 33 | 0x8Ca62376c6D1abB560BdfC07a383cE5E17c54E3d,3,6,11,9,1,2,4,5,7,8,10,12,13,14,15,16,1,1697195993,bafkreif4yjkltqcxcve7ro6reolxhq4kvaqb2nx4qeefyshzrp5ktjjlvu,"" 34 | 0xA2c3D2309241dA102f7Bc0F6FEc2e08a6779a4BD,16,2,11,15,13,9,10,12,6,4,7,1,8,14,3,5,1,1697197707,bafkreiantbq27sbxugi2pjyrqmk3nh6k4q7i54golauj7nhkohpv6pdxxe,"" 35 | 0x351f147b062B440Ab3B7c2a26c5FE1c3897DAd26,4,13,9,7,11,14,16,15,10,3,1,6,2,5,12,8,1,1697201619,bafkreifrughoi6suqonsvz32j7jyoiay3dt7cffxqrqzbfbe6iot33mbdi,"" 36 | 0x2D9Bb0CA565AE83B93737e4f2D9aCC0d2ae21055,7,9,4,12,15,1,6,10,11,14,16,2,3,5,8,13,1,1697202103,bafkreidclr5y72awjgsh6o2mmbxpqlzmou2r7plzqaiwahjlq33dqqaulq,"" 37 | 0x8ba60b93055713b86A952102239d894dE4b85AB9,1,7,9,6,14,3,11,15,2,4,12,8,10,16,5,13,1,1697204777,bafkreihby2dvcucekweboaibz3wmwylbmf6al7olu7anguzx67v6y3pmfy,"" 38 | 0x86EB834F4d8416586a014e3aDb1493B0Cb3C8850,7,9,16,6,10,11,12,15,1,5,2,3,4,8,13,14,1,1697205284,bafkreigrqqjy3xgptghg5ur3jjr3j6gce5h56rqsqph3ysjfkxib3xlhki,"" 39 | 0x74fa01a5D0ef8039f1E14F4d4C8f90e8602e07B4,6,15,14,7,12,8,5,4,3,2,1,9,10,11,13,16,1,1697206819,bafkreigdcecstzfc5r5qg7wgzgqweitxls52mqs2lobjbc22pzzzs2ai3e,"" 40 | 0x8D30496202cb84C146165945EC3E695C6c591454,9,15,11,14,16,3,1,4,7,12,10,13,5,2,6,8,1,1697206833,bafkreihczghqmjt5vhs5ewxtyolbxs6o6s7inrnpxytyvhmxdv34ah5bi4,"" 41 | 0x24749AedF18208aB74A8110E07E820286Bb5acf8,4,13,11,9,8,7,6,5,3,2,1,16,15,14,12,10,1,1697208240,bafkreihfnpyyns2o7clnda4n5rmtjdx5xtpivmdrpbicugmtktknbmufti,"" 42 | 0xb2b557CCE11a9D05D4df1dcB89FEC2a6412B4b53,3,14,11,12,4,7,1,16,15,6,10,2,13,9,5,8,1,1697211477,bafkreiayjo2rc73svmz2opka3vumargenx3mtqfg77vmuvo3c6353xvhny,"" 43 | 0x97f752e1d5F64CA932c32dBfF82f4c92710beE54,9,16,15,11,6,3,13,14,7,8,1,2,4,5,10,12,1,1697211507,bafkreiavsj2i7eg7svqvwagzscqw6t5lvnloxgjjnmqllcvruoynrtkq3a,"" 44 | 0x47Bbf62C4326EFF3353022e7Dd1d9caA5bc97841,9,3,12,6,1,15,13,11,10,14,8,7,5,4,2,16,1,1697212555,bafkreidl5eurf6amzrgqqfihoqlj3viofr4zien6carkgj25qavwkjcati,"" 45 | 0xe082b562b12c8C65D12187A387B06B8039d41771,3,12,11,2,10,16,14,15,4,7,6,5,1,13,8,9,1,1697213480,bafkreiafcsyc7lfwsgow5d7ptyo24o6k2ghlgbvfahc4uc57elgjfqrhf4,"" 46 | 0xb3D47ed740b1E4c65ec11BF0631FF530793A11ee,9,11,3,13,12,6,2,1,7,8,10,14,15,16,4,5,1,1697214629,bafkreigp4hyk7hlex6oe47omq5tz3av4ttjwtihfdvoqh5mcmxqdousllu,"" 47 | 0x736576018F8AFa9D5A22b43769804cD3efDb1129,9,11,3,13,1,2,4,5,6,7,8,10,12,14,15,16,1,1697214836,bafkreicp5pn7pqnstaijqfiipgkcuazm74ypjhhmzwqexjpmcrhqowo4tq,"" 48 | 0xdB762917cF21Bbe45D761f52931e7110cF72dBf1,15,12,10,8,11,16,9,13,6,2,14,4,7,5,1,3,1,1697215995,bafkreigebpqc3zxpa4wc6otwcehryh6oegnbphfgmp5icoeoe3mz7s6u5q,"" 49 | 0x722D80b8F0640F625DFd668b6DD8175b2F22e8Ab,9,16,1,12,15,4,6,8,7,3,2,11,13,14,5,10,1,1697216977,bafkreihl7o7ufxcrrqxrjt235we2r2j6dtlmrzravwz4424zl4dj6mkgt4,"" 50 | 0x86B6969cBaa76f38f263D7677a916b0EbEE4e00A,15,7,10,11,9,4,6,12,3,14,1,16,2,13,8,5,1,1697217056,bafkreihjmszizvvwjd6cyvljrb5l6urxh5tv7bxnkmebnbl4vcbxm5np7a,"" 51 | 0xf35880094DC18Df096891E4ccb51212d00311B86,12,2,9,3,15,11,6,7,10,14,4,1,8,16,13,5,1,1697217562,bafkreiggrok4wtdxhqk7mvuxuednzw3bui6jefzdyiqymygn5xs2jcosfu,"" 52 | 0xDEC3705633912c024911d3a5f357E8256A43ff7e,12,9,6,7,2,1,13,14,16,11,5,8,10,4,3,15,1,1697218859,bafkreidmmqm7cpsyscpx3oss65ofxxj6twzbw6hcr5z6az3spxtjhy7qle,"" 53 | 0xfe19915268E64BEc65B9aC2c0E23aC6ba2F11aD7,11,9,15,13,12,6,2,7,8,14,10,5,16,3,1,4,1,1697219074,bafkreicgpmsejswr7ficp3vihy7fsfu23puoo5falej7tycmunt3xfks3m,"" 54 | 0xdd081889735Ac9b51945D31035e621FD209c3e24,6,7,5,9,11,16,2,1,3,15,4,12,14,13,8,10,1,1697220038,bafkreifb4nmnfovmsnhrxiwmc45zst2d5jo7cklkheg4vzbwhf7b7j7a3m,"" 55 | 0x15f51853d17E89D97980883Eef4C6ABA6BA82ed5,3,11,4,5,12,10,14,9,13,7,2,1,6,16,8,15,1,1697223417,bafkreigvxldcyo2paop3gpfcrrgfw3k7zriobu5vo2kdptqpxg2gf4n3q4,"" 56 | 0x4718ce007293bCe1E514887E6F55ea71d9A992d6,12,3,10,1,2,4,5,6,7,8,9,11,13,14,15,16,1,1697225720,bafkreiccguvfn66zil2ccs2adkyj3fzsjlscnmxn7fi4kpfa6ux2pogldq,"" 57 | 0x7937E9eF694837c2794547eB74FcCcadd5A885c3,7,2,4,8,11,12,15,16,5,1,10,13,14,6,9,3,1,1697225914,bafkreicox5i6ok3ccf2friioabcnza7h2lgpcdqdpg3vbdmjm4msu4qmvm,"" 58 | 0x3ae285B8f6ADcf9C728d0B761948e25DD065610E,7,6,11,15,4,9,1,16,10,8,14,12,2,3,5,13,1,1697225943,bafkreiejojxjntst73mwcf42bixb6toyhdshnfnsbolbca7ucp3agvuk7m,"" 59 | 0xfB843f8c4992EfDb6b42349C35f025ca55742D33,3,10,2,12,15,14,13,11,9,8,7,6,5,4,1,16,1,1697227405,bafkreie6xn7j4evp2d56xcvee32ftrepzkhmvfy7xku6pnjpcj2fov6gmm,"" 60 | 0xb10944118BD56Ba667Eac4e8aDeD42ebD7FafA32,3,9,4,13,11,7,2,15,14,6,1,5,12,16,10,8,1,1697227640,bafkreideuoyrbjfb5rwhyjlyno35isacpoiyv5d2are2nvt3g7gox7wm3m,"" 61 | 0xa4142A998EeD156228b2F925a43fcEA25f363Cc5,3,12,11,16,15,10,7,6,9,4,13,5,1,2,8,14,1,1697227886,bafkreibmdoajmyulm6n64qtmjnrnldp6xa6tw7u55fbixngfkwvi77a3o4,"" 62 | 0x7fD8f6B669964D217aed9B325aB5Fc1c3Ca2E249,12,1,2,3,4,5,6,7,8,9,10,11,13,14,15,16,1,1697228760,bafkreihd6khgbsmuirtbtnhk7redatuiiwawqfapjrqdcii2xwqbciwhka,"" 63 | 0xB0B1A464bA4f583eBa9be9BF0CbFE4aAD03C1502,2,1,3,4,5,6,7,8,9,10,11,12,13,14,15,16,1,1697229366,bafkreiezefueztm4zp3qir2ltrve74nsuswo65n3xjs375g5353azvh36q,"" 64 | 0xAeb50Dd02f03aD7Bb3Fc9E90f66DDF58b4AB5019,7,2,1,9,5,16,11,4,10,12,15,14,8,6,13,3,1,1697230116,bafkreidgurzbqhks3itj6mf6kfg4mlhjuanrrwfv7u5qktn56edh7yp64q,"" 65 | 0xb5360d78643A0B0F39B4f32c629A2A920b74E3dC,3,6,7,11,5,4,9,14,16,1,2,8,10,12,13,15,1,1697231793,bafkreihej3f2a4hrbobkhov54qjltzvg26cuqh6zizeome5ylelnmxfgwq,"" 66 | 0x247BCDaaeE715428a0a8b02B79a641C051F3A111,6,9,12,7,3,14,2,1,11,5,16,4,8,15,10,13,1,1697232440,bafkreigf4gktujolq7jy6t3jdruipyynwoyzlb5yiufu532jwy4taxhsha,"" 67 | 0x2720299f969B3DD6175580c00b4F8102757c505C,9,11,6,7,16,4,13,15,5,1,14,12,10,3,2,8,1,1697236432,bafkreifnradzdtstgftjonoekb3g3m2cra3zho5hsodcrfr75cvvkeh6sy,"" 68 | 0x34AbBd03B73906f1AA79e31F5AACdaa5d3bcf3D3,3,11,1,16,6,9,12,14,7,2,13,4,15,5,10,8,1,1697236952,bafkreibwj2tn236cduklxbabzgbmw62nfjnzeslmqk52lfjdp7gzpkxtsa,"" 69 | 0x117fB889A01b9Cb614B4164599F960bB116c654A,8,1,2,16,15,14,13,11,9,7,6,5,4,3,10,12,1,1697237941,bafkreiardhbsnv4n5zosthgvfl5bvvatzxown3hxxcrltwfhvailx4z3u4,"" 70 | 0x118D6F5D61A9D881d452510F29C703AbE9d80cDC,6,11,8,16,7,5,3,1,2,4,9,10,14,15,12,13,1,1697238450,bafkreic6rsx4f6xriy7yulerp44w5sxhcdt3mk3mqhuxsstmb3anialsqq,"" 71 | 0x7558B9A644CcD3C815F7b717213Dff0B0a9b0f98,12,9,14,2,11,10,8,7,6,5,4,3,1,16,15,13,1,1697238925,bafkreibq3ff2sa3qjd4mbh7gmrizklxhjerv4oqqabjjqvr72sb2nmfxbe,"" 72 | 0xeb54D707252Ee9E26E6a4073680Bf71154Ce7Ab5,7,10,2,1,16,11,9,12,4,3,5,6,8,13,14,15,1,1697240826,bafkreihzwbogvimnf6kredrzhefsqxtf6vjxoqzploum3sqnynzmzfmspe,"" 73 | 0x865c529F7053ADc50aC9d3efc54D71DB7b28907C,6,9,7,15,16,10,8,4,2,3,5,11,12,13,14,1,1,1697240870,bafkreibcphcxud4a5eozevwhvxzqxcfya2pwakxkeyvi24oez22xxf37li,"" 74 | 0x2Ed0Db8d2870cCaC48B1693b9efE4341fEDAeCb1,7,10,2,1,16,12,15,9,11,4,14,6,8,13,3,5,1,1697240895,bafkreiasobgz4f7b6gpmfjbwsziaue5nhdwwy225wkhtk65kfhrtikh2qy,"" 75 | 0xaEd7f85A1a5f804b0311c54a679d8AEBf393c06b,12,10,2,11,15,13,9,8,6,7,14,16,1,4,5,3,1,1697243788,bafkreiefyvt4efbchwdcccrcnwvhdnbz2euz374tra6mh3pv2b3hp5trru,"" 76 | 0x414531E74c58B4a5C5C998B18FbE819A70d78f30,7,16,2,15,13,12,4,10,6,8,5,11,1,14,9,3,1,1697246326,bafkreidx7vr6zkhsm3re5doz3s5hh4oj2u2jrk24q34fddabcdo3vxdjue,"" 77 | 0xBedCcE028F470d29a3140C4173321286CB88b5E7,3,16,11,5,12,13,10,2,7,1,15,8,6,9,4,14,1,1697251216,bafkreibcdlxydscfn2ffoscrjxlvvfjafwlngyusbwuueswwa3fefw42k4,"" 78 | 0x938faba539B06C7AF959Fef188F1E1d4b4B37D1F,1,8,11,7,4,2,6,9,5,3,16,14,15,13,12,10,1,1697256772,bafkreiafdjmliz7aosx2ixefz5n5tzzz3bmmvjgvf4hmfgk3czxpkj5vbu,"" 79 | 0x6Bf505dB68719B49E5d9E513bA653D3f0bc4Ff65,1,8,7,11,4,2,6,9,5,3,16,15,14,13,12,10,1,1697256904,bafkreihcjwakq6lleh4dqgklcmzti2g4mq3tucowbivmmkpixw6dhpys5i,"" 80 | 0x69F3dAb38223298520aF6AA4bcB308d4411C2552,16,9,7,4,8,2,12,3,11,13,5,10,6,1,15,14,1,1697264567,bafkreid3oaaqjlnllgi7kzxmdwovyjjh73gtgxuxp7xpb55z7p6hcn7vgq,"" 81 | 0xEb4e3B117D9A1de8d1F58056a4d76d398C72bB27,2,4,14,16,5,3,7,8,6,1,10,9,11,13,15,12,1,1697281458,bafkreiav2sj2wciu3hc7r32bybnrrpej746q5pdfsdxwx5bwcmaylheepa,"" 82 | 0xc04aFCeEA0BE5A8CeCe930c4e6208eb03BeCd066,8,9,11,12,16,6,4,10,15,1,2,3,14,5,7,13,1,1697283636,bafkreibqvc3nyty4fhctewwhq6shdn562ntql7eeypntpvjkzoytvmfwye,"" 83 | 0x5EA39d1A8d63Ef8C72Be3c4D6513103063FefDD8,4,10,12,6,16,11,15,3,2,1,5,7,9,13,14,8,1,1697290010,bafkreiff5o4vae4n63gki3sibmqupb2oe6hquoiuv6dxk3etoiahr3omou,"" 84 | 0xfaDD3b0DcfB0Ae4A0ee676fAb249Bfbb0560C78F,10,12,9,7,2,1,8,4,3,11,16,15,14,5,6,13,1,1697296642,bafkreickwkldus4jlquiyikbibcn277nhmkwpxxlrspkcjux5gfsgx5yx4,"" 85 | 0x7cc4E967242E1CaD92152d47AE0bB9169e97d553,4,11,13,7,3,9,14,5,6,12,16,15,2,10,1,8,1,1697303007,bafkreiehzg5hjfxtjkvgwdhwxtmhmawlk2b7bgpyjv27kxu5xdyrwj5ef4,"" 86 | 0x627d0D67062B5143BDb2677f39C778f28aA59b10,12,9,4,11,1,6,14,15,16,7,2,10,13,5,3,8,1,1697305923,bafkreiaetl376zhxdhats6z2pmypo5awh7agdgmujk2zanbowpd7jjsmwu,"" 87 | 0x8424150a44e92047C3115f65a20588b49A7df262,16,14,9,4,2,11,15,13,7,5,12,6,3,10,1,8,1,1697306888,bafkreievmgk7ulpe6rih6cj6gkn6h3ggpgafkzwna3hlbnxilwhxofvcb4,"" 88 | 0x9959654Dd5B9C61f687dcd34Fc308397BF69fe6D,1,6,15,11,12,7,2,16,8,13,9,3,4,14,10,5,1,1697308643,bafkreifogdtps2o7pol2xq2mtc6lydvf7ia7qbfivpf25g5dtqpp373bna,"" 89 | 0xdFA413375306E2169AdCBbE8551f69739328E6dd,13,9,11,3,4,2,7,14,15,12,1,5,16,8,10,6,1,1697320332,bafkreichdivhq53lchp6dow3ptli4yqixhpjwtjexxmizehsveecysyd7m,"" 90 | 0x40FF52E1848660327F16ED96a307259Ec1D757eB,3,13,12,14,1,10,11,2,4,5,6,7,9,15,16,8,1,1697337343,bafkreiedqrji42aepuj6lfwkvb7m2potbv6pu4yx2oe6df4mkfl7fzr3ym,"" 91 | 0xD35964dFfF5f9a3DAf2766731B54E8580Be14F94,2,5,8,11,15,10,4,16,14,13,12,9,7,6,3,1,1,1697365358,bafkreicyvesaybvl57irkwo45iztdrokfkgemn7cb5xa5l2b4t74whxvr4,"" 92 | 0x24e5B063fBfa23A168148d102A239C139Ad83cC7,7,3,9,6,11,16,14,12,15,1,4,10,2,5,13,8,1,1697367405,bafkreif2twl3zrbncpqkjthy5cpnjfsordbykio4y6fipzpac6jkffw3g4,"" 93 | 0xc4Fe86dD25F7FA9F1b4CcD4fdA4e68dbFeFcB601,8,12,7,9,10,16,4,2,1,3,11,13,15,5,14,6,1,1697374362,bafkreifbef4fpgpj64zhycq7473qw2t3kpk6ugpuu3k6jtgtq4l2mvue6q,"" 94 | 0x08364bdB63045c391D33cb83d6AEd7582b94701d,9,11,16,12,6,4,7,8,10,13,14,15,1,2,5,3,1,1697379923,bafkreif6hhj26lyly2wicfohesqfjndgq5ye2at6fx7xqcvfva2tbaos7a,"" 95 | 0x7CB7E8D4a7019AaD1D25B166cAC2a9eB6598f641,9,8,7,15,12,16,11,2,14,3,1,6,4,10,13,5,1,1697385376,bafkreihsxmjwx5sbybnoy2olrwbewmusr4kngs6erxgzcftunhkfu4obka,"" 96 | 0x2afE4De9D1C679E42C03649D86FFDDDc07751AE6,7,2,11,12,8,9,6,10,15,1,3,4,16,5,13,14,1,1697391475,bafkreiakfs3uapfubia7qzy2zz272j5ihpgda4bnknefqs5cnxvtj4xcgu,"" 97 | 0x92572aae1c9C4BA92011a6A56579c79dBb272Cd8,5,3,7,13,16,10,12,4,11,8,9,14,1,15,2,6,1,1697413537,bafkreidwdaunxcxtey4xkh7pvdcgbk5b57yeifby6byveytpxbgizzey4y,"" 98 | 0x5657fa653af4E86C1B621fE0027C04B3C39838FC,2,1,16,9,13,8,4,5,11,12,3,15,6,7,10,14,1,1697414475,bafkreibzfgj4wiws5hjmthezhkc7ypysy2zi4pn377ss7ya7q75vejz6pa,"" 99 | 0x5877Af7FC64E26c695806E2Fd7e083c8511e61f1,3,9,16,7,6,1,12,4,2,11,13,15,5,14,10,8,1,1697421442,bafkreidfgnbc32blnjonecwcpqdlkjzxmlxgjxywwwnmz7nfnjapsirlse,"" 100 | 0xD1e0747B57bc35869F2e5D042081Dda1b9e1456C,16,2,11,3,1,8,7,10,9,12,5,4,14,13,6,15,1,1697464117,bafkreifmsqo6yr7lvfbabiu5g4lmru4zxmz7p6njoymo47srvqcvrihwaq,"" 101 | 0x999b3fd160954A542C13851eeA654bDD70396065,3,2,11,8,7,15,16,4,1,12,6,9,10,5,14,13,1,1697472902,bafkreici3d6tt4jimhjwvhl4kb2ebhbz3gtekqxssyoh7nc2oebnrthnuy,"" 102 | 0xA667d416B346222c8E218f68266B530d4b3666E6,14,11,9,3,13,15,16,4,10,6,7,12,2,5,1,8,1,1697477027,bafkreiby32geytibpr3acncfnvj7ky643ad6mkninz2rccqnkzukjopmlu,"" 103 | 0x4472Bd2ab2FB0eF5A9f4a0125546B11fDd027B01,15,16,9,11,2,10,7,13,12,5,1,6,4,8,14,3,1,1697478202,bafkreidd6gj4nqkmi7oguvghsaz2ikbizfkqteci4x44kix73vvpcoy3s4,"" 104 | 0x31CD63C73E74808793F134f8146a1E3AA1637B5d,5,3,4,11,2,1,6,7,8,9,10,12,13,14,15,16,1,1697478323,bafkreibtdlzcbytfszc6onusthsk2tuf4ihgymlgekk6awnha32k5newkm,"" 105 | 0x45eecB472A8e2235c2cC0d94CCC4A4e8C56Af2aB,5,16,15,12,4,11,10,6,8,13,7,9,3,1,2,14,1,1697479324,bafkreifwit763sfjfs3bfzzs35kmxmn75sb7hbh3qmbaabo3epq3yocwn4,"" 106 | 0x0d74d273a4d2971E7cb8e7acb25cCFe149e37465,6,9,7,11,4,12,14,10,5,2,3,8,13,15,16,1,1,1697480694,bafkreievzek7tn6kesceuhtlxvwhwsdgkegkzbftcuetss3eew47v4633q,"" 107 | 0x2E1AEFb1c02361a1daC8ac4bBD370aB9Db0c3C8E,8,9,5,1,7,13,11,6,16,14,10,3,2,4,12,15,1,1697481539,bafkreicus7tqdlpncclnblf3zjqd2hewpoqt3mpuky6n4rlrd6db3tjrg4,"" 108 | 0x9007e386e89eEcE958FFA5152Cc0a37b2f28012f,3,14,11,4,9,12,13,7,6,1,2,15,10,16,5,8,1,1697488966,bafkreihhx3drtfhwr7yu7mlm27yyktrlrmy5ecvillnmqpd3iw33spwv2m,"" 109 | 0x7654b41dABe6c80ebA346a9C955226E058FBF7Ba,1,16,2,11,15,10,12,9,7,6,8,5,4,13,3,14,1,1697496238,bafkreihgpdqgfrxlqhltbvrs6zdggvvnmdqyuldm6qpgc3zudhvtcyziiq,"" 110 | 0x47Edbd307180556dBbEb5BeABD8A40ac30520f57,6,9,11,13,2,7,1,4,12,5,10,3,14,16,15,8,1,1697499760,bafkreigciwplcctge4nyhgzyzuy6lcpldqzsvnqv2dv23om5xygdh4lf7y,"" 111 | 0x2bFaC2D8D79D13D45862EA64ce0f25C5D34e9cA8,13,9,3,11,7,12,14,6,15,10,4,8,16,2,1,5,1,1697505743,bafkreico52iauqjqdftwntsdqt7al5v5d4okq3ocgsmd4rb73mmpey44ma,"" 112 | 0x0B7a1e683bB5C882722Ef2F332288a10CDA30A95,9,7,14,12,2,16,10,3,4,1,5,11,13,6,15,8,1,1697514715,bafkreiautspls7b7clyounmf52ytfnbmicmjz7lth6gxenuckqodvqivau,"" 113 | 0x407D524621059f27335F29275B01b76d923Fb121,7,9,4,2,8,15,6,11,13,1,5,12,16,14,3,10,1,1697518827,bafkreih2fdgma7r4fmwidlzsf7po2azxovzocmylb5djiqrjr3w2e7fove,"" 114 | 0x3109a8e5143D6d22DB0cFc7B46462cb99c446B11,7,9,2,8,4,6,15,1,5,12,14,16,13,11,3,10,1,1697519123,bafkreif7hbivvsd34i36aaz4dlzh7jiqqcwj4awn6jvvolbscmv5x2pct4,"" 115 | 0xC93BBD6b37893667bf1e100F7Bd445E3044D72C9,9,7,2,6,4,8,15,13,5,11,14,1,16,12,3,10,1,1697520568,bafkreia3wmmeiela2a5xh4ho6sx4mvmtw3m4lot5yj4nfbhi63jwtpiifa,"" 116 | 0x197794928B72B5c6C8B3129342C2cf8eb8B6C92C,9,7,2,8,6,4,11,15,13,1,3,16,5,14,12,10,1,1697520667,bafkreigukb7732ew7kxiwt2tbo3mv76u5ajkco7zwdqtsxol2a5ke2t3ci,"" 117 | 0xB03a3004fd65964Eb3dd321b1237289773b6B187,9,7,4,2,6,8,5,15,13,14,11,1,12,16,3,10,1,1697520946,bafkreic7xjlarvp3enojlsc7pdgtl4stimw5gbvrmxt6uukk5fui6iwfqq,"" 118 | 0x7AE41835444183A23bAA10EbfdF2997D10130f5d,2,9,7,8,4,6,5,15,13,14,11,1,16,12,3,10,1,1697521043,bafkreihwrhj4tn4putbt5pkvxkfv4ohhltmfduefloiewtcawrmezfwwwa,"" 119 | 0xA3C79B411Df103431D333790E88ab0a6FB5F0946,3,11,13,7,9,15,2,4,10,14,16,1,5,6,12,8,1,1697531779,bafkreiaat5qmhjfjqcfz5qccd472lupacczamhe5l3fjpypsx3hoqilhwe,"" 120 | 0x81ae2b76f7316472124Ec7da84F6Bda62170Ef20,16,3,7,9,13,11,12,10,2,6,8,4,1,5,14,15,1,1697534330,bafkreifs7auugy2rzli5wdsweuy5iqhmzmbpytgmyu4deykfpwpgou5jha,"" 121 | 0x5D08821b7A096c226AF8241d1CB88cef67087874,11,16,3,7,9,2,12,15,4,1,6,5,13,10,8,14,1,1697545871,bafkreia6ffcncaye6h2c2fibg46rdcvqsuq4opjy5vq2kej3c5wtzjzc4i,"" 122 | 0x473e95Ac3646D286651aDF5Fa736368DFdCf9605,12,11,15,9,13,7,14,10,2,8,6,5,4,1,16,3,1,1697546884,bafkreibywtzrj6z6hypzz2gwkqfvnapoqtgrbbgnqiu4c7gnw4bq3zwylq,"" 123 | 0x1B025dC4cb10424bA3455A7E88FbB456078e7d1A,16,2,5,7,8,15,1,11,3,6,9,14,4,12,13,10,1,1697547415,bafkreib3icvklf655feh4c24rmlp6a45sijaixuklq33dhmgluoh5fyioa,"" 124 | 0xae0768E5Ee2d5F140802f998C7293E2fF00297B2,9,11,13,4,14,16,6,12,2,7,3,8,10,15,1,5,1,1697554912,bafkreigxzblt7w35t5n3vm5nn4opz3agzvlbfsdjl7j6nsu6tp73xup7vm,"" 125 | 0x9BA0022Ec84E92dceaC90d0a22E3A857ba37bfE6,7,12,11,2,13,16,14,15,10,9,1,3,4,5,6,8,1,1697555572,bafkreief7z24qlokqncvgmzijac3kygjxpynyhhl2no2zsfvwcmnwurks4,"" 126 | 0xC4798B79d22630CEE83B4eCb0Fd98cd5fF0fbb62,7,12,10,2,15,3,1,4,5,6,16,13,9,11,14,8,1,1697567589,bafkreiedjgpkwueh4h3hdpo5euppw4bvambrtoyzwhtyb6mwlssy6jwfm4,"" 127 | 0xE2F31eaEf47C69C6129C4ECe490cd99807bf47E3,7,12,2,10,15,3,6,1,4,5,8,9,11,13,14,16,1,1697567655,bafkreigovinza2k46irocipd22xgik3n4wxwmeo7fxcznc7rwsy76yfj4q,"" 128 | 0xE57e8aC01f6ACa6C172D40a750a04Ad2aCeFE5e2,12,5,3,15,13,9,16,14,11,10,8,7,6,4,2,1,1,1697569027,bafkreigxsosyb5g4tyiex72ipsrteuxgtud2ubjahl4dwjktypox6thqbu,"" 129 | 0x5ED2516e649926fEF80b3Fc8D6C58CaF981f2932,14,16,15,13,12,11,10,9,8,7,6,5,4,3,2,1,1,1697571719,bafkreiey2croeag5kwqaxf54qzw4xbm3fvatsdkyv26hoivbdd2thtnbci,"" 130 | 0xD18CAcF6353B7c8b6d1ee18e00D93a8b45F63C02,16,9,15,2,7,11,10,12,1,4,6,3,5,13,14,8,1,1697571844,bafkreic75q3ot7oruqxecxdzagkw2wvcshbdzcnhcmizdiaimlsk32zht4,"" 131 | 0x40B37d60b79202955BEF5bA9eaacf2bde606d06A,3,14,11,12,4,9,13,7,1,15,2,6,5,10,16,8,1,1697572162,bafkreicm3jswnc3fdntaqwmvlqpo7hbzmdozt7nlc7fwymsigxyxe43kkq,"" 132 | 0x5E713C0B6539243bCa0D019bd7EF48545c08B926,14,11,12,15,16,6,10,4,1,2,3,5,7,8,9,13,1,1697573974,bafkreihuag5zs4d7jfvee6z3uhamtu4fz5ylrw44oac2kazsagckokr6se,"" 133 | 0xFCe05E495753b4A7a137c565454f731cf0b09eA8,9,2,16,15,1,3,12,8,11,4,7,5,10,13,6,14,1,1697574995,bafkreigq3l7a32edgk3ll2m3hs3yhzfkxpmgalmamuopyzvqwbwxz26xv4,"" 134 | 0x081Bc58a9538b1313e93F6bBC6119Ac6434FbE05,9,15,8,16,1,2,4,11,12,6,14,7,10,13,5,3,1,1697576903,bafkreihr62t5bx46kmpmxxevzntllnzvrxdxv2vmoy7d2xsifsqat3vih4,"" 135 | 0x963b3360bd1AFd066D3C13de32269971012C9032,3,10,16,11,4,6,15,14,12,8,1,2,5,7,9,13,1,1697577292,bafkreiacpyujw32ikrzhmzttdjb53rophh7dle22uma4oyh5g7miquaapy,"" 136 | 0xff4160A2355B1fa42722cB63fA482E7061ee40e7,3,7,2,12,16,9,5,4,1,13,11,14,6,10,15,8,1,1697578558,bafkreier2iu7ytuchdlmjya2g7plbl7eqfo6ha5qheip4apwzni4gjubqu,"" 137 | 0x29b25fA646dec7f81cDFefeD0028b710653ceaBd,9,11,13,3,7,4,14,1,2,5,6,8,10,12,15,16,1,1697578670,bafkreiasoykom74xocdymyua2erutgufywndtc5xk66wplaenyoh2c2gma,"" 138 | 0x7137f0420565E1bCD6abEc57e3c4AaC05ddcaD01,2,12,10,9,3,15,16,7,11,8,4,1,6,5,13,14,1,1697581954,bafkreidihd2yojjevrdqx6nnqhp2rycdatcpge2bhhakdwh475io4fu5tu,"" 139 | 0xF34ddAf8984E115700AEf4EfDC5cb1Bec69785D3,10,12,8,9,2,1,6,5,13,16,15,4,3,7,11,14,1,1697591258,bafkreiajn7eoajyfmgmbvahd6mzllkgi3wrdvynk52hmu72mbytfi22ekq,"" 140 | 0x4AE5216cD1Bb656c3bE7C98971626F64936c6969,9,7,2,4,8,6,15,13,5,11,12,16,1,14,3,10,1,1697625790,bafkreiaygyzayjsfvt6ltaajktwiu3ekshss2qggxvb6x2mw3y34njsvo4,"" 141 | 0x27B503fd677dd889ea3E7804DF492D2D809E6052,9,7,2,4,8,6,15,13,5,11,12,16,1,3,10,14,1,1697626050,bafkreiaoqotkjifbs4vbj3ockawsqogyqjizyg2i4k3ylv27slnyq7v3fe,"" 142 | 0x931292c42A5F600e80D75B7c7743f0164240882c,9,7,2,3,8,6,15,1,4,5,10,11,12,13,14,16,1,1697626537,bafkreiebbcitxhqsg2ufa3kyp75revwsn2ex6qle52f4x5xuqqwb7rsvou,"" 143 | 0x4bb402271667A25d31935d13Ff15D2a8f8d08456,9,5,1,7,2,8,3,11,14,16,6,10,13,4,15,12,1,1697626732,bafkreifjj7z234iskucbhdivlmcwy2ewtnctcomuy3qnra3d2anu65htgm,"" 144 | 0xb805a4ba48E5C3BD4691B8E25685Bafd691933a4,10,2,16,14,11,7,5,3,6,15,12,9,4,1,8,13,1,1697638171,bafkreid6i7z3nq7pl7qexp32gsbbtizcewspbb7agvld7y4u2hwic72hxy,"" 145 | 0xF73FE15cFB88ea3C7f301F16adE3c02564ACa407,13,9,10,11,3,16,15,14,12,7,6,5,2,4,1,8,1,1697638323,bafkreie7mlopcsjxfkkbzvo4dt67t4mxcgclwv7uasslvpm4yioh2r7q34,"" 146 | 0x28B4DE9c45AF6cb1A5a46c19909108f2BB74a2BE,12,10,2,11,16,3,13,4,14,5,15,1,6,7,9,8,1,1697638617,bafkreifgloeukpo4xhgagk6oxgkllglh3g6metl7khm7wwcspuxqquf22a,"" 147 | 0xa2F0448f346cE50B9029506c88Dfa58d07bAF880,4,11,12,15,16,10,6,14,3,1,2,5,7,8,9,13,1,1697638847,bafkreifd5m25zcbxtqhnor23du7njlqmcfvnz46kflglaneyqmnq3kstz4,"" 148 | 0x532bfd20230E205Cf283996Ceef4E6BB19e65110,7,6,9,10,1,4,5,2,11,8,3,12,15,13,14,16,1,1697638856,bafkreibakfy4jgp4qf2i63n7nlb2sbnjo6f5qzt5vxopkoscvn7qyd46fa,"" 149 | 0xF61104Feb8b6AbDf90b7cF09CF056d51EF5f0350,2,12,13,15,10,6,11,16,4,3,1,9,14,5,7,8,1,1697639119,bafkreiel7g5hn3vf5v46kxzeuhw6n2bfovapnvojfxu6gqyhy4quptkuze,"" 150 | 0x942cBEa64876Ff0b2e23c0712B37Dc0091804e9c,7,2,12,10,1,3,4,5,6,8,9,11,13,14,15,16,1,1697639964,bafkreidrfavj5ix7rfd3autmram432yieo2iklzpkarvm2bzna23lijtqe,"" 151 | 0x7CE244e2812f3f73dd3294b0e71397ab819B57ee,8,16,12,4,11,7,13,15,10,1,2,6,3,9,5,14,1,1697640696,bafkreiedbcllappens6vrhhp47tj73fffivpwh62qbwa4q6wlfvrawrhxu,"" 152 | 0x8Cf84e6069fA1bdFb6db0be667E762de2984C817,2,11,16,1,7,9,13,12,15,3,4,5,6,10,14,8,1,1697640925,bafkreickkayjh5r7kj2p37si372lenu5gzvxyz4zvs2kivdqnvqfpnwmhu,"" 153 | 0x27371387a255a6F0bF499D834536F77a19C2CF3C,2,6,11,16,12,8,15,13,4,1,5,7,3,9,10,14,1,1697642420,bafkreiapoxkxjfggw2f4f6ldz63zlox4qqzbr3zlz42ifbtsnf37zppnpq,"" 154 | 0xa19f1f64c8365966363F6D303EBAf60c7A0CB901,2,3,10,16,7,11,15,12,6,8,4,1,5,14,9,13,1,1697642567,bafkreigp6lprasg62l57ycyywkqufln7oxfin7xyn36oryab55gzh5oeoe,"" 155 | 0x825728f78912B98ADFB06380F1fcDcDA76fd0f87,12,9,2,8,14,13,11,10,7,6,5,4,1,15,3,16,1,1697643704,bafkreibg5krfnqemgvkkqos3vc4pkbyna6pzg37fn65wdvqqc7p6mt56bi,"" 156 | 0x13E2eD5724E9d6Df54ed1ea5B4fa81310458c1d9,12,2,3,9,16,15,14,13,11,10,8,7,6,5,1,4,1,1697643799,bafkreifbhestxfwt7nsr5gfrj4gi5ygqpm24dcbj75wbfvwkdxxkp6ctom,"" 157 | 0x6dd1E0028eF0a634b01E13B2291949255610b38f,6,7,11,3,5,1,2,9,4,8,10,12,13,14,15,16,1,1697646289,bafkreiezujvsdogizl5jm47rsy25p4msdgqvhlblgkkj3pd3paym2j4wzy,"" 158 | 0x134CD334e1a1854E6850779D34868641aaF9370B,12,10,2,7,6,9,15,16,3,5,11,1,8,13,14,4,1,1697647418,bafkreicktpqup73okqfzble36u27gyohlxxoweigs6aiopozy4g4uo3wgq,"" 159 | 0x68856c1Fb2525E65bFB3F945E2041E24ea373C21,11,9,10,4,1,2,15,16,6,3,12,8,13,14,5,7,1,1697648151,bafkreibgoy2ybaqxxznukv4vpi74knpblqikm7cnfkuuiqj3igndeo3654,"" 160 | 0x312cC22eB46B44365fc20b976A570596Ed8200ba,12,2,11,16,10,6,15,8,13,5,7,1,9,4,14,3,1,1697650952,bafkreig62tgdjutpcajniobplfqqrfocozvhakwqpr4v77dsc4uyuzff6y,"" 161 | 0xB00A93fF31217E49c3674e05b525f239a85bb78f,15,16,10,7,12,11,13,1,14,4,3,5,6,9,8,2,1,1697652076,bafkreicf3ekdkqyipqwbzyhvihulq47s4wvhjyve6rtadq2dftjfcfx2om,"" 162 | 0x1cCd9F598a51cE30A912C0B1713dEC9478762DeB,11,9,5,3,6,2,13,10,7,8,12,14,15,16,1,4,1,1697654088,bafkreid7grncou3ug66szbx3v7mb5jiiqnyzmcugwgdwi5prbljjwxrzh4,"" 163 | 0xF7E4aC558fe9EAdD91Ebb3cd77D4AA2Ab234496C,1,7,12,14,16,15,3,10,2,4,9,5,6,11,13,8,1,1697654392,bafkreigriagkk3ufan2rhmq7naglknu5l5jwpavxihrh2esyk7qosc4wgy,"" 164 | 0xad9d6FbbA0eEc0d77355663468b2FAfD44678fdE,12,13,1,6,7,11,5,2,3,16,14,8,10,4,15,9,1,1697655578,bafkreihqvl6xuu5bud53i5pbsmgtvjblyr7fy7uduln4q65w4lbs5m5u7i,"" 165 | 0x8CD51e5AF18c4dB7c62167a36F94148728C97367,6,9,12,3,2,7,10,11,4,15,16,8,13,1,5,14,1,1697656332,bafkreie3au46wkvpamzr53cn4sybk52vti52o77px4npciq2vdlznqlali,"" 166 | 0x28dB177ff3Acd56529246720E6e4ac652E3471ab,12,11,15,6,9,7,13,14,10,2,1,16,5,3,4,8,1,1697657877,bafkreiebnc4kyhntl5qg3jk25b7w3dfvyjxiye3ww6hhe5ruxbzkz6rebq,"" 167 | 0x0e4885b96Aa3834e328D217DD2eEc6012Bb8D28c,9,15,4,1,2,5,3,6,7,10,11,8,12,13,14,16,1,1697666013,bafkreihwpkhgz2v6ontiozjw4jdutao3oxdmtkzpmvjk4ngfrc55zh2eei,"" 168 | 0x7Ef34907fF0e84Ed776f6C0294BB1668C0B9c8A6,2,12,11,15,16,6,4,1,3,5,7,9,10,13,14,8,1,1697666188,bafkreieygxyc5txasizibswdlzpulrot7x2nwhzrewphn76a72knse7ira,"" 169 | 0x0c4c29011Aa9e0547572aF00aB3eef997B4fA1c6,7,6,16,9,11,13,3,2,12,15,10,5,4,1,14,8,1,1697669441,bafkreigvtux5zcws2vzltvwu2zb5r2nnhkwczfea5zi4vjue42qfxur5x4,"" 170 | 0x76963eE4C482fA4F9E125ea3C9Cc2Ea81fe8e8C6,3,12,2,9,16,13,4,6,7,1,15,14,10,8,5,11,1,1697674825,bafkreift6sol7dhaydphr4h6s7m5jvpiognux3jppwu54w3l5wyz6n2q3e,"" 171 | 0x0f9b1b68f848Cb65F532BC12825357201726d3d2,2,8,3,16,15,10,1,9,7,11,13,12,14,5,4,6,1,1697675751,bafkreihjie7hbdc6g6smn2owma7jilrcnonvricjjd37dd6rrwhtfkjxg4,"" 172 | 0x2057c3C95F998E00240659A8F58D7013AAd16425,11,16,15,10,4,12,9,14,1,2,3,5,6,8,13,7,1,1697676610,bafkreidoqfwuzhcijvzvday3az2ijda6j26udwrupski2e6wg2xn2u25ky,"" 173 | 0xFfe5B864e90d7894cD374f689D08E917DF9A32da,7,8,15,1,2,6,11,12,13,16,10,5,3,4,14,9,1,1697681520,bafkreidzatdgaxpmeazxhfgg6uym7fiwpmvfhc2eaputlcfsjqjrq54pni,"" 174 | 0x06A61Fd03051ae191C5c0321324c85Db11340bc7,11,15,4,16,9,13,6,7,14,12,2,1,5,10,8,3,1,1697683512,bafkreihwd7ryio324o6qpe5ekakfa5ik5otdlpf3jzpqstwvwipbubia6e,"" 175 | 0x2bd946abaDB1F88D4EF1Aab73cc0E0eDCC2B7650,7,9,2,6,8,4,15,13,11,5,12,14,16,1,3,10,1,1697685049,bafkreig4h5pleeszjhyg23zrchrdudcjbybkejhze2sfizijuuzx2mbpgi,"" 176 | 0x82e2873095bF0E34E6F3bFe8fdAb8093406176eb,7,6,8,9,4,2,15,13,14,11,5,12,16,1,3,10,1,1697685112,bafkreia6hwzxdvvqwnbcniehmrfreb7d74bp7d2bslrlckqs4xe5oipuqa,"" 177 | 0x97159cd5AeD606E061d1E0e01fE04DAd5312286a,14,13,11,9,7,4,3,12,2,15,8,1,5,10,6,16,1,1697685576,bafkreia5niw6rpr3qok6uutvwdwsdoo4cwnsc6ftqrninprneb6vzuvdre,"" 178 | 0x68e19ADa86678133FEfDc54A98558746bD56B067,11,13,14,3,7,9,4,12,2,8,10,15,5,1,6,16,1,1697685845,bafkreig5v6uhraxmk42ghd2vwvgwx4dputzeanc3sruyrvgzkp545rkruq,"" 179 | 0x7EC5FfdCe1cFf1FaC0763D6B332d224496A48843,3,4,7,9,11,13,14,12,2,8,10,15,5,1,6,16,1,1697685975,bafkreiaaskn7cgbpkujw26yy6ebsv3gsd6hosdfapiu3rnp25lmn6oa2ne,"" 180 | 0xF143db60A0B1cBb8076B786Eb6635b93f18db744,11,2,10,16,8,15,9,12,4,7,6,14,5,3,13,1,1,1697685990,bafkreibnkliccqxqu5zfxc5ochdrjrjqqij4vsg4fonpd2zqc2rbumm5ge,"" 181 | 0xFc1674d4701AB0b656d78f3F7767BaE176eC0012,7,9,6,8,2,4,5,11,12,15,16,14,13,1,3,10,1,1697687964,bafkreig6pguhpjlyjxgx3n2f76lqeif67vwctlxg7fc7wrfc73fkseqr4i,"" 182 | 0xB0623C91c65621df716aB8aFE5f66656B21A9108,13,3,10,12,2,9,14,16,15,8,1,11,6,7,4,5,1,1697688833,bafkreic6insi4eennrbmz5eilwhmxgxbm2t2ldi2beqdbudxrcr2b6l524,"" 183 | 0x9a84ad3a811A6a6fcBb6B716FeCaB8d65414d98A,9,7,2,6,4,8,11,15,13,16,5,12,14,1,3,10,1,1697691455,bafkreibrhdpltmklxyetipgqhyndwb4vmll6vwrztyaaqbkqcywiv4atra,"" 184 | 0x4d6823B083a7d03BcE47d50E224903016353B6C8,11,4,2,15,13,5,6,7,1,3,8,12,16,10,9,14,1,1697691511,bafkreifdpuogwsmatg6e4yz6hymhrzysba4rrblx2c6le5flcxtajchqy4,"" 185 | 0xa69E7A9147602A051AE127c128961fbd2e1754e5,4,12,15,11,13,10,16,9,14,1,2,3,5,6,7,8,1,1697696196,bafkreihpwdbjva53dwdw33bog3rvtw6fc6p2klhhokjiqbrtac7vnxvwqy,"" 186 | 0x5eeeFc7D872C03296639bcc49D11c99403d2F4A8,14,1,7,11,13,10,8,4,12,15,16,5,2,3,6,9,1,1697705155,bafkreigaag4ttf56oucfqibldl7qjyxbgbnmn6xkbgq42zyoaxdvhlxdvu,"" 187 | 0x976D8dc2905192749d76aBa38a3FBEae4E044211,2,10,12,16,8,13,14,6,11,15,7,1,4,9,3,5,1,1697708459,bafkreigt7kr7z3n3vykvzadx3nuaif7nzrd44ji3k5s7yyiswtre7cmhgy,"" 188 | 0x4DfE3DFd09F9d3Bf6E6Fa30Ed2d6C6b96A1E3De1,14,9,10,13,8,6,4,2,1,3,5,7,11,12,15,16,1,1697714647,bafkreie4dk7zdvbf3gs2tncx63y5e44pxjrbuydwp6zs6twnaiyp2xja5q,"" 189 | 0x779D742b9bA14334773D68487d5A78Eec6A7e1d7,15,16,9,2,11,1,10,12,4,13,6,14,7,3,5,8,1,1697718249,bafkreiaaf6ephv36nzv7rcppgudnrl2fiuqqlakcfmjii24dn3yvj72d6a,"" 190 | 0x5daC67F5bA2e32fAc6eaF682A61088bdC84FfDC8,4,11,12,15,16,6,1,3,2,7,5,8,9,10,13,14,1,1697722706,bafkreibxdpf4e3sfnqehxnnv2eqhlkhxfboonjjxcep7qr6oi6xmquwfom,"" 191 | 0xE0982E0d39eeE312017c58DBa76c99Ca59b8A958,12,10,9,11,15,2,7,16,6,3,14,4,1,5,13,8,1,1697726201,bafkreibsw6xny3bw2lohjjspohegxmjaajg42uucvzhqiysjf23afpeg2a,"" 192 | 0x5561974F0D35D8C355241a5568894df7B691BB28,12,10,2,11,9,13,14,15,6,16,8,5,3,1,4,7,1,1697729165,bafkreiekklguwj3fu6vqrkgssictmczf5o74i762yrvfuaogt4jv5g3gwq,"" 193 | 0xb97A251dF2672b0e252b28a96857A8ACE9929CCc,12,16,9,6,3,10,1,15,13,11,2,7,4,8,5,14,1,1697729966,bafkreibgugjkshidyo6whqvtjx6mbpwyxyvvavbqxrxwepi2oedn6vfy7u,"" 194 | 0x93ec6b8Df79341eC8509027320924021ECD536E3,2,10,14,5,8,4,16,6,11,3,15,1,13,7,9,12,1,1697732007,bafkreifrp6e3gddui2m67kxirfrk45mskasrozm6iah5tnxarra4vyfzwy,"" 195 | 0xE4cbdA373d6A445f83fc9125C6230Ba58Bc08320,12,9,16,6,15,11,2,3,10,7,4,13,1,14,5,8,1,1697734069,bafkreiaufixltcrk3i7hqnkdvupamixdebdahc7ana5oqucxvar7qt3fqi,"" 196 | 0xF3A45Ee798fc560CE080d143D12312185f84aa72,13,7,3,12,2,6,10,5,1,4,8,9,14,15,16,11,1,1697735138,bafkreigjc6rji2oniua3q4xvjzf27cbs6im3ou3xe73o7irrw474ae75s4,"" 197 | 0x9097cdFf0329382df4A17E86195cc664385656Cb,15,2,12,6,11,9,16,10,3,7,13,4,1,5,14,8,1,1697735323,bafkreif6eopot6sbnsmcbgkotlsxvb5fyt6y7x3suc2bfamiflqaf7u2pe,"" 198 | 0xC756F590E3f2459dFDefA8c7a5C94Ce1f6Fc9736,12,2,15,6,11,9,7,10,4,16,8,5,14,3,1,13,1,1697737804,bafkreiemn55g6mfwugmd6dhxa2shkptenmfjdeaw2bslhhknt7brrbaqmu,"" 199 | 0x49fbd13846F2428c148A4c165a22b4fFA54263a4,11,15,4,16,9,13,6,7,14,12,2,1,5,10,8,3,1,1697738029,bafkreidtexpp5vshp56waz7xfje3cwujrkqk5z5jbdnynbpuklw6gqmc5e,"" 200 | 0xd7B7A3AB29B3282dc76419Bd57776e0E90DE8A41,13,4,11,9,7,14,3,15,2,10,5,12,6,16,1,8,1,1697738396,bafkreigm246kydxja45el6xouj75hhzpxaq3i6h7qgmueaabw57wr7fdfu,"" 201 | 0xb683A2056526162C4771d363204aF41ea8c1eC52,2,16,7,9,10,8,3,6,11,14,15,4,12,13,1,5,1,1697738453,bafkreiavwjsm3qmukrqyhmoy5glfp7n2mguu45vjpvlnyoqge7a3ohqsdi,"" 202 | 0x78450a857347a10595aB92214a67d2ac3AeC562F,7,3,1,5,15,14,11,13,8,2,9,10,12,4,6,16,1,1697738674,bafkreigrpdmiz37irf2wlxhnmwmfrfrosealj3nmsrq54uo35af4v25274,"" 203 | 0x100da4222Cfbe6e8dEb8Cdb8C15899986CB188D9,7,6,12,11,4,1,9,5,10,13,2,3,15,14,16,8,1,1697738729,bafkreidt4unhkq4wzkazjzmnltsscg7itayzklchh7yp6eqrctbixnhlqe,"" 204 | 0x267aC7fda523066DA091a1A34826179B202f6081,11,4,6,9,13,16,15,1,2,3,5,7,8,10,12,14,1,1697739641,bafkreidwkswfhxfzu2oawl4jr2y7fj2eui45svq2xz3v4d2dfdbv5autn4,"" 205 | 0x51603C7059f369aB04B16AddFB7BB6c4e34b8523,9,1,3,15,2,11,13,8,7,16,12,6,10,5,14,4,1,1697740517,bafkreie27qkrdfwfckejxjvifcpqkxnlfxr2vhvcki5kogtulnahqj44dy,"" 206 | 0x98eBE133e57c5948fc26EdE79672D269F7e424a9,14,13,11,4,7,9,3,6,16,15,12,10,1,2,5,8,1,1697740608,bafkreib5e7kkw3n36l5jfeoga6rixxpointzi66t4shke57ao4tnduytqu,"" 207 | 0x85AAffc1F91cD828C82D5d0006B38C34b05917e9,11,4,3,9,14,13,7,16,15,2,1,6,5,10,12,8,1,1697741004,bafkreiauz27pzzflsdt27zwvdh5b7l55wu4rpccsu3odpcf4pw4msak5mu,"" 208 | 0x2a7730a6fa0043907195E5841D785c2566cCaEf5,12,11,15,10,3,9,7,1,5,4,2,6,13,16,14,8,1,1697741721,bafkreidfn72y2n5tf5fgdjlmkwyeuyc7dmxzm4jsxhspppyfrvlhfgdhgi,"" 209 | 0xf21b42E532B6B9dCe4f970f98c08BD79657d9f1B,3,8,1,11,12,13,2,4,6,7,5,14,10,15,9,16,1,1697743566,bafkreiec46bo2q3rslri4nyz4hszxdhi2ggdanxdegohzcfjgktybjg7la,"" 210 | 0xF4884cE82eE55ECe5DB07044c374ecC86e7562D7,7,3,6,9,4,1,2,12,11,15,14,10,13,5,8,16,1,1697744133,bafkreic5p3ij56222jzxqpeocpy4oadbfzd5lux4prhj2xthht74mxlyly,"" 211 | 0x1c7AfC36B1F656429Fa23a55D546b2b4ce2C6f99,7,3,6,9,4,1,2,12,11,15,14,10,13,5,8,16,1,1697744204,bafkreih2krjom7kcr4n2tadmvbk3sopa3nizc2owemdbttedqnw7veshhq,"" 212 | 0xD30F2888E7928b52EA5bF4cb1D323e0531aFe272,12,11,15,9,7,10,2,13,3,16,6,5,1,14,4,8,1,1697745590,bafkreidqe3ef5vvqcvwuzqliwm3n4ylgburuxlrm3jluuwact5rjww5aqe,"" 213 | 0x0F866BCc7cD4255E608DA0063db5A61dc2B93768,11,9,16,14,4,13,15,7,6,12,5,3,2,1,10,8,36,1697746243,bafkreicznap5wuosz4tgvmr3qvf75sjopvwgbwnat6pdjj5m7bk4tdn6pa,"" 214 | 0x38D894E135400587e11b4b5217867e75B0571bc0,6,12,16,9,1,2,3,4,5,7,8,10,11,13,14,15,1,1697746306,bafkreid3dnhj4dqw4tgrtvfei3nbwgrhs5sevoa6jdchs6jvtyrkpf4kmq,"" 215 | 0x4eDB818249C85d3b617FF4fbD034757560314A0c,12,1,9,2,4,5,6,3,7,8,10,13,11,15,14,16,1,1697747039,bafkreifvg7ys6nt2giifpqmekz5em4tgpdrxr7nh574eaxq72dfivlzy4q,"" 216 | 0x02ca10C62f160CDD126d1E44EF42224CAc745Ac8,4,11,15,13,16,9,10,12,2,14,1,3,7,5,6,8,1,1697751710,bafkreic5fpoot37rnyyvd2vscpd4vbjaudsic5rzhintqlcrsbg7tzarja,"" 217 | 0xdED00e1725D318CCc411D515efa30E6637B4bceB,11,9,3,7,14,4,13,15,6,2,5,10,16,12,1,8,1,1697752047,bafkreidewqjmb3s2czyose5y6psl54pvvdlrmmdauysdjsgjnkasydeb5q,"" 218 | 0xc7A0D765C3aF6E2710bA05A56c5E2cA190C2E11e,15,6,9,7,2,10,12,13,3,11,16,14,5,1,8,4,1,1697752386,bafkreihzmdut5jqpyopogp72ldpq3d2ty57htrracztdkolfephde6xd5a,"" 219 | 0x77F40BFD546004400284B637c40Dfd821e4ce39c,15,11,7,13,6,9,12,3,5,10,2,14,1,4,16,8,1,1697752713,bafkreidijcl7t6esxf4sqktsiuifqanseqzj2u2flxgrqygvkonmzretga,"" 220 | 0x187089B33E5812310Ed32A57F53B3fAD0383a19D,7,13,16,9,12,5,10,15,8,4,11,6,2,3,1,14,1,1697752740,bafkreicnin4jj4km23wos2hi3xnmxchagwblpsohymcclxx3c522lcko2e,"" -------------------------------------------------------------------------------- /test/fixtures/snapshot-votes-report-0xbb1b4f1f866fda9c1c19ff31bc32c98f92d70f2055a3ba26a502377cf2d1e743.csv: -------------------------------------------------------------------------------- 1 | address,choice.1,choice.2,voting_power,timestamp,author_ipfs_hash,reason 2 | 0x9c1b73E80D992389078a8ABc8B6039665C4846e7,,,20.992980242869496,1681954050,bafkreicfll75wtsmsvjk2oksmvfrwxtdduf72hfygcouwnkjitrzkx34ci,"" 3 | 0x5fb1E3A4b8968f6D0B276514A22eDF3a88B3FdD6,,,323.2730220659731,1681954081,bafkreiho66x5sasd3h5bfs3on7bnoxiwn55x2gx4kcvq7el44mkqf257fi,"" -------------------------------------------------------------------------------- /test/fixtures/snapshot-votes-report-0xd37c87edb3cbd78d58a78056b4facb00df739fdf3a16b168305e9cfdd00b3ab5.csv: -------------------------------------------------------------------------------- 1 | address,choice.1,choice.2,voting_power,timestamp,author_ipfs_hash,reason 2 | 0x9c1b73E80D992389078a8ABc8B6039665C4846e7,,,20.992980242869496,1681954050,bafkreicfll75wtsmsvjk2oksmvfrwxtdduf72hfygcouwnkjitrzkx34ci,"" 3 | 0x5fb1E3A4b8968f6D0B276514A22eDF3a88B3FdD6,1,2,323.2730220659731,1681954081,bafkreiho66x5sasd3h5bfs3on7bnoxiwn55x2gx4kcvq7el44mkqf257fi,"" -------------------------------------------------------------------------------- /test/fixtures/verifiedTokens.json: -------------------------------------------------------------------------------- 1 | { "test": { "value": "a" } } 2 | -------------------------------------------------------------------------------- /test/integration/helpers/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { fetchWithKeepAlive } from '../../../src/helpers/utils'; 2 | import fetch from 'node-fetch'; 3 | 4 | describe('utils.ts', () => { 5 | describe('fetchWithKeepAlive', () => { 6 | const TEST_URL = 'https://snapshot.org'; 7 | 8 | it('set the custom agent with keep-alive', async () => { 9 | const response = await fetchWithKeepAlive(TEST_URL); 10 | expect(response.headers.get('connection')).toEqual('keep-alive'); 11 | }); 12 | 13 | it('does not use a keep-alive connection with default node-fetch', async () => { 14 | const response = await fetch(TEST_URL); 15 | expect(response.headers.get('connection')).toEqual('close'); 16 | }); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /test/integration/lib/nftClaimer/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { Proposal } from '../../../../src/helpers/snapshot'; 2 | import { hasVoted } from '../../../../src/lib/nftClaimer/utils'; 3 | 4 | describe('nftClaimer/utils', () => { 5 | describe('hasVoted()', () => { 6 | it('returns true when the address has voted on the given proposal', () => { 7 | expect( 8 | hasVoted('0x96176C25803Ce4cF046aa74895646D8514Ea1611', { 9 | id: 'QmPvbwguLfcVryzBRrbY4Pb9bCtxURagdv1XjhtFLf3wHj' 10 | } as Proposal) 11 | ).resolves.toBe(true); 12 | }); 13 | 14 | it('returns false when the address has not voted on the given proposal', () => { 15 | expect( 16 | hasVoted('0x96176C25803Ce4cF046aa74895646D8514Ea1611', { 17 | id: '0xcf201ad7a32dcd399654c476093f079554dae429a13063f50d839e5621cd2e6e' 18 | } as Proposal) 19 | ).resolves.toBe(false); 20 | }); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /test/integration/lib/storage/aws.test.ts: -------------------------------------------------------------------------------- 1 | import Aws from '../../../../src/lib/storage/aws'; 2 | 3 | const TEST_CONTENT = 'test content'; 4 | const TEST_FILENAME = 'test.cache'; 5 | 6 | describe('Storage/Aws', () => { 7 | try { 8 | const storage = new Aws('test-sidekiq'); 9 | 10 | afterEach(() => { 11 | storage.delete(TEST_FILENAME); 12 | }); 13 | 14 | describe('when the file does not exists', () => { 15 | it('can create then retrieve the file', async () => { 16 | await storage.set(TEST_FILENAME, TEST_CONTENT); 17 | expect((await storage.get(TEST_FILENAME)).toString()).toEqual(TEST_CONTENT); 18 | }); 19 | 20 | describe('get()', () => { 21 | it('returns false', async () => { 22 | expect(await storage.get('unknown-file')).toBe(false); 23 | }); 24 | }); 25 | }); 26 | 27 | describe('when the file exists', () => { 28 | beforeEach(async () => { 29 | await storage.set(TEST_FILENAME, TEST_CONTENT); 30 | }); 31 | 32 | describe('set()', () => { 33 | it('overwrites the file with new content', async () => { 34 | const newContent = 'new content'; 35 | await storage.set(TEST_FILENAME, newContent); 36 | expect((await storage.get(TEST_FILENAME)).toString()).toEqual(newContent); 37 | }); 38 | }); 39 | 40 | describe('get()', () => { 41 | it('returns the file as Buffer', async () => { 42 | const result = await storage.get(TEST_FILENAME); 43 | expect(result.toString()).toEqual(TEST_CONTENT); 44 | expect(result).toBeInstanceOf(Buffer); 45 | }); 46 | }); 47 | }); 48 | } catch (e: any) { 49 | console.log('Test skipped: ', e.message); 50 | it.todo('needs to setup the AWS credentials'); 51 | } 52 | }); 53 | -------------------------------------------------------------------------------- /test/unit/lib/__snapshots__/moderationList.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`moderationList ignores invalid fields, and only returns flaggedIps 1`] = ` 4 | { 5 | "flaggedIps": [ 6 | "12ca17b49af2289436f303e0166030a21e525d266e209267433801a8fd4071a0", 7 | "19e36255972107d42b8cecb77ef5622e842e8a50778a6ed8dd1ce94732daca9e", 8 | ], 9 | } 10 | `; 11 | 12 | exports[`moderationList returns all fields by default 1`] = ` 13 | { 14 | "flaggedAddresses": [ 15 | "0x0001", 16 | "0x0002", 17 | ], 18 | "flaggedIps": [ 19 | "12ca17b49af2289436f303e0166030a21e525d266e209267433801a8fd4071a0", 20 | "19e36255972107d42b8cecb77ef5622e842e8a50778a6ed8dd1ce94732daca9e", 21 | ], 22 | "flaggedLinks": [ 23 | "https://gogle.com", 24 | "https://facebook.com", 25 | ], 26 | "verifiedTokens": { 27 | "test": { 28 | "value": "a", 29 | }, 30 | }, 31 | } 32 | `; 33 | 34 | exports[`moderationList returns multiple list: flaggedLinks and flaggedIps 1`] = ` 35 | { 36 | "flaggedIps": [ 37 | "12ca17b49af2289436f303e0166030a21e525d266e209267433801a8fd4071a0", 38 | "19e36255972107d42b8cecb77ef5622e842e8a50778a6ed8dd1ce94732daca9e", 39 | ], 40 | "flaggedLinks": [ 41 | "https://gogle.com", 42 | "https://facebook.com", 43 | ], 44 | } 45 | `; 46 | 47 | exports[`moderationList returns only the flaggedIps 1`] = ` 48 | { 49 | "flaggedIps": [ 50 | "12ca17b49af2289436f303e0166030a21e525d266e209267433801a8fd4071a0", 51 | "19e36255972107d42b8cecb77ef5622e842e8a50778a6ed8dd1ce94732daca9e", 52 | ], 53 | } 54 | `; 55 | 56 | exports[`moderationList returns only the flaggedLinks 1`] = ` 57 | { 58 | "flaggedLinks": [ 59 | "https://gogle.com", 60 | "https://facebook.com", 61 | ], 62 | } 63 | `; 64 | -------------------------------------------------------------------------------- /test/unit/lib/cache.test.ts: -------------------------------------------------------------------------------- 1 | import { readFileSync } from 'fs'; 2 | import { storageEngine } from '../../../src/helpers/utils'; 3 | import Cache from '../../../src/lib/cache'; 4 | 5 | const TEST_CACHE_DIR = 'cache-test'; 6 | const TEST_ID = 'test'; 7 | const TEST_STRING_CONTENT = 'test content'; 8 | 9 | describe('Cache', () => { 10 | const testStorageEngine = storageEngine(TEST_CACHE_DIR); 11 | const cache = new Cache(TEST_ID, testStorageEngine); 12 | 13 | describe('getCache()', () => { 14 | describe('when the cache exists', () => { 15 | it('returns the cached content as a Buffer', async () => { 16 | const mockCacheGet = jest 17 | .spyOn(testStorageEngine, 'get') 18 | .mockResolvedValueOnce(Buffer.from(TEST_STRING_CONTENT)); 19 | const result = await cache.getCache(); 20 | 21 | expect(result.toString()).toEqual(TEST_STRING_CONTENT); 22 | expect(result).toBeInstanceOf(Buffer); 23 | expect(mockCacheGet).toHaveBeenCalledTimes(1); 24 | }); 25 | }); 26 | 27 | describe('when the cache does not exists', () => { 28 | it('returns false', async () => { 29 | const mockCacheGet = jest.spyOn(testStorageEngine, 'get').mockResolvedValueOnce(false); 30 | 31 | expect(await cache.getCache()).toBe(false); 32 | expect(mockCacheGet).toHaveBeenCalledTimes(1); 33 | }); 34 | }); 35 | }); 36 | 37 | describe('createCache()', () => { 38 | const inputs = [ 39 | ['string', TEST_STRING_CONTENT], 40 | ['buffer', readFileSync(`${__dirname}/../../fixtures/icon.png`)] 41 | ]; 42 | 43 | it.each(inputs)('creates the cache file from a %s', async (type, content) => { 44 | const spy = jest.spyOn(cache, 'getContent').mockResolvedValueOnce(content); 45 | const mockCacheGet = jest.spyOn(testStorageEngine, 'set').mockResolvedValueOnce(true); 46 | 47 | await cache.createCache(); 48 | 49 | expect(spy).toHaveBeenCalledTimes(1); 50 | expect(mockCacheGet).toHaveBeenCalledWith(cache.filename, content); 51 | }); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /test/unit/lib/moderationList.test.ts: -------------------------------------------------------------------------------- 1 | import getModerationList from '../../../src/lib/moderationList'; 2 | import { SqlFixtures } from '../../fixtures/moderation'; 3 | 4 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 5 | const mockDbQueryAsync = jest.fn((query: string, args?: string[]): any[] => { 6 | return []; 7 | }); 8 | jest.mock('../../../src/helpers/mysql', () => ({ 9 | __esModule: true, 10 | default: { queryAsync: (query: string, args?: string[]) => mockDbQueryAsync(query, args) } 11 | })); 12 | 13 | describe('moderationList', () => { 14 | it.each(['flaggedLinks', 'flaggedIps'])('returns only the %s', async field => { 15 | mockDbQueryAsync.mockImplementationOnce(() => { 16 | return SqlFixtures[field]; 17 | }); 18 | 19 | const list = await getModerationList([field]); 20 | 21 | expect(list).toMatchSnapshot(); 22 | }); 23 | 24 | it('returns multiple list: flaggedLinks and flaggedIps', async () => { 25 | mockDbQueryAsync.mockImplementationOnce(() => { 26 | return SqlFixtures.flaggedLinks.concat(SqlFixtures.flaggedIps); 27 | }); 28 | 29 | const list = await getModerationList(['flaggedLinks', 'flaggedIps']); 30 | expect(list).toMatchSnapshot(); 31 | }); 32 | 33 | it('ignores invalid fields, and only returns flaggedIps', async () => { 34 | mockDbQueryAsync.mockImplementationOnce(() => { 35 | return SqlFixtures.flaggedIps; 36 | }); 37 | 38 | const list = await getModerationList(['a', 'b', 'flaggedIps']); 39 | expect(list).toMatchSnapshot(); 40 | }); 41 | 42 | it('returns all fields by default', async () => { 43 | mockDbQueryAsync.mockImplementationOnce(() => { 44 | return Object.values(SqlFixtures).flat(); 45 | }); 46 | 47 | const list = await getModerationList(); 48 | expect(list).toMatchSnapshot(); 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /test/unit/lib/nftClaimer/deploy.test.ts: -------------------------------------------------------------------------------- 1 | import { recoverAddress } from '@ethersproject/transactions'; 2 | import payload from '../../../../src/lib/nftClaimer/deploy'; 3 | import type { Space } from '../../../../src/helpers/snapshot'; 4 | import { signer } from '../../../../src/lib/nftClaimer/utils'; 5 | 6 | const mockFetchSpace = jest.fn((id: string): any => { 7 | return { id: id }; 8 | }); 9 | jest.mock('../../../../src/helpers/snapshot', () => ({ 10 | __esModule: true, 11 | fetchSpace: (id: string) => mockFetchSpace(id) 12 | })); 13 | 14 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 15 | const mockValidateSpace = jest.fn((address: string, space: Space | null): any => { 16 | return true; 17 | }); 18 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 19 | const mockValidateDeployInput = jest.fn((input: any): any => { 20 | return {}; 21 | }); 22 | jest.mock('../../../../src/lib/nftClaimer/utils', () => { 23 | const originalModule = jest.requireActual('../../../../src/lib/nftClaimer/utils'); 24 | 25 | return { 26 | __esModule: true, 27 | ...originalModule, 28 | validateSpace: (address: string, space: Space | null) => mockValidateSpace(address, space), 29 | validateDeployInput: (input: any) => mockValidateDeployInput(input) 30 | }; 31 | }); 32 | 33 | const NAN = ['', false, null, 'test']; 34 | 35 | describe('nftClaimer', () => { 36 | describe('payload()', () => { 37 | const spaceOwner = '0x5EF29cf961cf3Fc02551B9BdaDAa4418c446c5dd'; 38 | const spaceTreasury = '0x5EF29cf961cf3Fc02551B9BdaDAa4418c446c5dd'; 39 | const spaceId = 'TestDAO'; 40 | const maxSupply = '10'; 41 | const mintPrice = '100000000000000000'; 42 | const proposerFee = '10'; 43 | const salt = '72536493147621360896130495100276306361343381736075662552878320684807833746288'; 44 | 45 | // Signature expected by the smart contract 46 | const expectedScSignature = { 47 | r: '0xd921452d76ea510debe0260f7eba6327e1cb7172e22fcc0179a734a3e7234ca2', 48 | s: '0x0ee9397c6c803436aac0c8c496c50264b68f6664f4940f7addc5f9140b8b8237', 49 | v: 27 50 | }; 51 | 52 | const expectedDigest = '0xc3a4ff2bd44ee17fbc4f802953bd55392238b034c71ca4f5de2ad2f23fa31af7'; 53 | const expectedInitializer = 54 | '0x977b0efb00000000000000000000000000000000000000000000000000000000000000e00000000000000000000000000000000000000000000000000000000000000120000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000016345785d8a0000000000000000000000000000000000000000000000000000000000000000000a0000000000000000000000005ef29cf961cf3fc02551b9bdadaa4418c446c5dd0000000000000000000000005ef29cf961cf3fc02551b9bdadaa4418c446c5dd00000000000000000000000000000000000000000000000000000000000000075465737444414f000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003302e310000000000000000000000000000000000000000000000000000000000'; 55 | 56 | const input = { 57 | spaceOwner, 58 | id: spaceId, 59 | maxSupply, 60 | mintPrice, 61 | proposerFee, 62 | salt, 63 | spaceTreasury 64 | }; 65 | 66 | function getPayload(customParams = {}) { 67 | return payload({ ...input, ...customParams }); 68 | } 69 | 70 | describe('when deployable', () => { 71 | it.skip('generates the same signature as the smart contract from the data', async () => { 72 | mockValidateDeployInput.mockReturnValueOnce(input); 73 | 74 | const { signature } = await getPayload(); 75 | 76 | expect(mockValidateSpace).toHaveBeenCalled(); 77 | expect(mockValidateDeployInput).toHaveBeenCalled(); 78 | expect(signature.r).toEqual(expectedScSignature.r); 79 | expect(signature.s).toEqual(expectedScSignature.s); 80 | expect(signature.v).toEqual(expectedScSignature.v); 81 | }); 82 | 83 | it.skip('generates the same initializer as the smart contract from the data', async () => { 84 | mockValidateDeployInput.mockReturnValueOnce(input); 85 | 86 | const { initializer } = await getPayload(); 87 | 88 | expect(mockValidateSpace).toHaveBeenCalled(); 89 | expect(mockValidateDeployInput).toHaveBeenCalled(); 90 | expect(initializer).toEqual(expectedInitializer); 91 | }); 92 | 93 | it.skip('can recover the signer from the digest', async () => { 94 | mockValidateDeployInput.mockReturnValueOnce(input); 95 | 96 | const recoveredSigner = recoverAddress(expectedDigest, { 97 | r: expectedScSignature.r, 98 | s: expectedScSignature.s, 99 | v: expectedScSignature.v 100 | }); 101 | 102 | expect(recoveredSigner).toEqual(signer.address); 103 | }); 104 | }); 105 | 106 | describe('when the space validation failed', () => { 107 | it.skip('throws an error', () => { 108 | mockValidateSpace.mockImplementation(() => { 109 | throw new Error(); 110 | }); 111 | 112 | expect(async () => await getPayload()).rejects.toThrow(); 113 | }); 114 | }); 115 | 116 | describe('when passing invalid values', () => { 117 | it.skip('throws an error when the spaceOwer address is not valid', () => { 118 | expect(async () => await getPayload({ spaceOwner: 'test' })).rejects.toThrow(); 119 | }); 120 | 121 | it.skip('throws an error when the spaceTreasury address is not valid', () => { 122 | expect(async () => await getPayload({ spaceTreasury: 'test' })).rejects.toThrow(); 123 | }); 124 | 125 | it.each(NAN)('throws an error when the salt is not a number (%s)', val => { 126 | expect(async () => await getPayload({ salt: val as any })).rejects.toThrow(); 127 | }); 128 | 129 | it.each(NAN)('throws an error when the maxSupply is not a number (%s', val => { 130 | expect(async () => await getPayload({ maxSupply: val as any })).rejects.toThrow(); 131 | }); 132 | 133 | it.each(NAN)('throws an error when the mintPrice is not a number (%s)', val => { 134 | expect(async () => await getPayload({ mintPrice: val as any })).rejects.toThrow(); 135 | }); 136 | 137 | it.each(NAN)('throws an error when the proposerFee is not a number (%s)', val => { 138 | expect(async () => await getPayload({ proposerFee: val as any })).rejects.toThrow(); 139 | }); 140 | 141 | it.skip('throws an error when the proposerFee is out of range', () => { 142 | expect(async () => await getPayload({ proposerFee: '101' })).rejects.toThrow(); 143 | expect(async () => await getPayload({ proposerFee: '-5' })).rejects.toThrow(); 144 | }); 145 | }); 146 | }); 147 | }); 148 | -------------------------------------------------------------------------------- /test/unit/lib/nftClaimer/mint.test.ts: -------------------------------------------------------------------------------- 1 | import { BigNumber } from '@ethersproject/bignumber'; 2 | import { recoverAddress } from '@ethersproject/transactions'; 3 | import { getAddress } from '@ethersproject/address'; 4 | import payload from '../../../../src/lib/nftClaimer/mint'; 5 | 6 | const TEST_MINT_DOMAIN = 'TestDAO'; 7 | const proposer = '0x0000000000000000000000000000004242424242'; 8 | 9 | const NAN = ['', false, null, 'test']; 10 | 11 | const mockFetchProposal = jest.fn((id: string): any => { 12 | return { 13 | id: id, 14 | author: proposer, 15 | space: { id: TEST_MINT_DOMAIN } 16 | }; 17 | }); 18 | jest.mock('../../../../src/helpers/snapshot', () => ({ 19 | __esModule: true, 20 | fetchProposal: (id: string) => mockFetchProposal(id) 21 | })); 22 | 23 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 24 | const mockGetProposalContract = jest.fn((id: string): any => { 25 | return '0x2e234DAe75C793f67A35089C9d99245E1C58470b'; 26 | }); 27 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 28 | const mockValidateProposal = jest.fn((proposal: any): void => { 29 | return; 30 | }); 31 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 32 | const mockMintingAllowed = jest.fn((space: any): boolean => { 33 | return true; 34 | }); 35 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 36 | const mockHasVoted = jest.fn((address: string, proposal: string): boolean => { 37 | return true; 38 | }); 39 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 40 | const mockHasMinted = jest.fn((address: string, proposal: string): boolean => { 41 | return false; 42 | }); 43 | jest.mock('../../../../src/lib/nftClaimer/utils', () => { 44 | // Require the original module to not be mocked... 45 | const originalModule = jest.requireActual('../../../../src/lib/nftClaimer/utils'); 46 | 47 | return { 48 | __esModule: true, 49 | ...originalModule, 50 | getProposalContract: (id: string) => mockGetProposalContract(id), 51 | validateProposal: (id: any) => mockValidateProposal(id), 52 | mintingAllowed: (space: any) => mockMintingAllowed(space), 53 | hasVoted: (address: string, proposal: string) => mockHasVoted(address, proposal), 54 | hasMinted: (address: string, proposal: string) => mockHasMinted(address, proposal) 55 | }; 56 | }); 57 | 58 | describe('nftClaimer', () => { 59 | describe('payload()', () => { 60 | const signer = '0xD60349c24dB7F1053086eF0D6364b64B1e0313f0'; 61 | const recipient = getAddress('0x0000000000000000000000000000000000001234'); 62 | const proposalId = 42; 63 | const hexProposalId = BigNumber.from(proposalId).toHexString(); 64 | const salt = '0'; 65 | 66 | // Signature expected by the smart contract 67 | const expectedScSignature = { 68 | r: '0xf27b32f51f5804eb53705e7c0e3c63169282f0ac83543f515970531650d42be4', 69 | s: '0x54124669256e730bc60147fb00afcf1d5f028045f15e7d5bd77b2093cd2f9554', 70 | v: 27 71 | }; 72 | 73 | const expectedDigest = '0x65b2c526e8c21a68583765e51f03990a67a0a0cb46794ab7ec666e88808eb93a'; 74 | 75 | const input = { 76 | proposalAuthor: proposer, 77 | recipient, 78 | id: hexProposalId, 79 | salt 80 | }; 81 | 82 | async function getPayload(customParams = {}) { 83 | return payload({ ...input, ...customParams }); 84 | } 85 | 86 | describe('when mintable', () => { 87 | it.skip('generates the same signature as the smart contract from the data', async () => { 88 | const { signature } = await getPayload(); 89 | 90 | expect(mockFetchProposal).toHaveBeenCalledWith(hexProposalId); 91 | expect(signature.r).toEqual(expectedScSignature.r); 92 | expect(signature.s).toEqual(expectedScSignature.s); 93 | expect(signature.v).toEqual(expectedScSignature.v); 94 | }); 95 | 96 | it.skip('can recover the signer from the digest', async () => { 97 | const recoveredSigner = recoverAddress(expectedDigest, { 98 | r: expectedScSignature.r, 99 | s: expectedScSignature.s, 100 | v: expectedScSignature.v 101 | }); 102 | 103 | expect(recoveredSigner).toEqual(signer); 104 | }); 105 | }); 106 | 107 | describe('when spaceCollection is not found', () => { 108 | it.skip('throws a SpaceCollection not found error', async () => { 109 | mockGetProposalContract.mockImplementationOnce(() => { 110 | throw new Error(); 111 | }); 112 | return expect(async () => await getPayload()).rejects.toThrow(); 113 | }); 114 | }); 115 | 116 | describe('when space has closed minting', () => { 117 | it.skip('throws an error', () => { 118 | mockMintingAllowed.mockReturnValueOnce(false); 119 | return expect(async () => await payload(input)).rejects.toThrow(); 120 | }); 121 | }); 122 | 123 | describe('when address has not voted on the proposal', () => { 124 | it.skip('throws an error', () => { 125 | mockHasVoted.mockReturnValueOnce(false); 126 | return expect(async () => await payload(input)).rejects.toThrow(); 127 | }); 128 | }); 129 | 130 | describe('when address has already minted', () => { 131 | it.skip('throws an error', () => { 132 | mockHasMinted.mockReturnValueOnce(true); 133 | return expect(async () => await payload(input)).rejects.toThrow(); 134 | }); 135 | }); 136 | 137 | describe('when maxSupply has been reached', () => { 138 | it.todo('throws an error'); 139 | }); 140 | 141 | describe('when passing invalid values', () => { 142 | it.skip('throws an error when the proposalAuthor address is not valid', () => { 143 | expect(async () => getPayload({ proposalAuthor: 'test' })).rejects.toThrow(); 144 | }); 145 | 146 | it.skip('throws an error when the recipient address is not valid', () => { 147 | expect( 148 | async () => 149 | await getPayload({ 150 | recipient: 'test' 151 | }) 152 | ).rejects.toThrow(); 153 | }); 154 | 155 | it.each(NAN)('throws an error when the salt is not a number (%s)', val => { 156 | return expect( 157 | async () => 158 | await getPayload({ 159 | salt: val as any 160 | }) 161 | ).rejects.toThrow(); 162 | }); 163 | 164 | it.skip('throws an error when the proposal is not found', () => { 165 | mockFetchProposal.mockReturnValueOnce(null); 166 | return expect(getPayload()).rejects.toThrow(); 167 | }); 168 | }); 169 | }); 170 | }); 171 | -------------------------------------------------------------------------------- /test/unit/lib/nftClaimer/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | validateAddresses, 3 | validateProposal, 4 | validateProposerFee 5 | } from '../../../../src/lib/nftClaimer/utils'; 6 | 7 | describe('NFTClaimer/utils', () => { 8 | describe('validateAddresses()', () => { 9 | it.skip('returns true when all addresses are valid', () => { 10 | expect( 11 | validateAddresses({ 12 | contractA: '0x054a600d8B766c786270E25872236507D8459D8F', 13 | contractB: '0x33505720a7921d23E6b02EB69623Ed6A008Ca511' 14 | }) 15 | ).toBe(true); 16 | }); 17 | it.skip('throws an error when some of the addresses are invalid', () => { 18 | expect(() => { 19 | validateAddresses({ 20 | contractA: '0x054a600d8B766c786270E25872236507D8459D8F', 21 | contractB: 'hello-world' 22 | }); 23 | }).toThrowError(); 24 | }); 25 | }); 26 | 27 | describe('validateProposerFee()', () => { 28 | it.skip('throws an error when proposerFee + snapshotFee > 100', () => { 29 | return expect(async () => { 30 | await validateProposerFee(100); 31 | }).rejects.toThrow(); 32 | }); 33 | }); 34 | 35 | describe('validateProposal()', () => { 36 | it.skip('throws an error when the proposalAuthor is not matching', () => { 37 | const address = '0x054a600d8B766c786270E25872236507D8459D8F'; 38 | 39 | expect(async () => { 40 | validateProposal({ author: address } as any, ''); 41 | }).rejects.toThrow(); 42 | }); 43 | 44 | it.skip('throws an error when the proposal does not exist', () => { 45 | expect(async () => { 46 | validateProposal(null, ''); 47 | }).rejects.toThrowError('RECORD_NOT_FOUND'); 48 | }); 49 | 50 | it.todo('throws an error when the space has closed minting'); 51 | }); 52 | }); 53 | -------------------------------------------------------------------------------- /test/unit/lib/votesReport.test.ts: -------------------------------------------------------------------------------- 1 | import { readFileSync, rmSync } from 'fs'; 2 | import VotesReport from '../../../src/lib/votesReport'; 3 | import { storageEngine } from '../../../src/helpers/utils'; 4 | 5 | const TEST_CACHE_DIR = 'test-cache'; 6 | 7 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 8 | const mockFetchProposal = jest.fn((id: string): any => { 9 | return []; 10 | }); 11 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 12 | const mockFetchVotes = jest.fn((id: string): any => { 13 | return []; 14 | }); 15 | jest.mock('../../../src/helpers/snapshot', () => { 16 | const originalModule = jest.requireActual('../../../src/helpers/snapshot'); 17 | 18 | return { 19 | __esModule: true, 20 | ...originalModule, 21 | fetchProposal: (id: string) => mockFetchProposal(id), 22 | fetchVotes: (id: string) => mockFetchVotes(id) 23 | }; 24 | }); 25 | 26 | describe('VotesReport', () => { 27 | const id = '0x1e5fdb5c87867a94c1c7f27025d62851ea47f6072f2296ca53a48fce1b87cdef'; 28 | const weightedId = '0x79ae5f9eb3c710179cfbf706fa451459ddd18d4b0bce37c22aae601128efe927'; 29 | const rankedChoiceId = '0xafe3a0426d4e6c645e869707f1b581765698d80c8d3e9cd37d7d3bf5e6f894e7'; 30 | const approvalChoiceId = '0xe5e335af87dc10206e9f0de469f64901407837d659db6703cb3ea1437056a577'; 31 | const quadraticChoiceId = '0x07387077920ce65b805bd0ba913a02ecfe63d22cac3dbaed3d97c23afd053fe2'; 32 | const activeShutterId = '0xbb1b4f1f866fda9c1c19ff31bc32c98f92d70f2055a3ba26a502377cf2d1e743'; 33 | const closedShutterId = '0x0da0673d17298e8f52c88385959952d21c2d0ae2fff2f0fea9df02ca0590cb6a'; 34 | const closedInvalidShutterId = 35 | '0xd37c87edb3cbd78d58a78056b4facb00df739fdf3a16b168305e9cfdd00b3ab5'; 36 | const testStorageEngine = storageEngine(TEST_CACHE_DIR); 37 | const space = { id: '', name: '', network: '', settings: '' }; 38 | 39 | function fixtureFilePath(id: string) { 40 | return `${__dirname}/../../fixtures/snapshot-votes-report-${id}.csv`; 41 | } 42 | 43 | afterAll(() => { 44 | rmSync(testStorageEngine.path(), { recursive: true }); 45 | }); 46 | 47 | it.each([ 48 | ['single', id], 49 | ['weighted', weightedId], 50 | ['ranked-choice', rankedChoiceId], 51 | ['approval', approvalChoiceId], 52 | ['quadratic', quadraticChoiceId], 53 | ['ranked-choice (active) with shutter', activeShutterId], 54 | ['ranked-choice (closed) with shutter', closedShutterId], 55 | ['ranked-choice (closed) with invalid shutter', closedInvalidShutterId] 56 | ])('generates a %s choices votes report', async (type: string, pid: string) => { 57 | const report = new VotesReport(pid, testStorageEngine); 58 | mockFetchProposal.mockResolvedValueOnce( 59 | JSON.parse(readFileSync(`${__dirname}/../../fixtures/hub-proposal-${pid}.json`, 'utf8')) 60 | ); 61 | mockFetchVotes.mockResolvedValueOnce( 62 | JSON.parse(readFileSync(`${__dirname}/../../fixtures/hub-votes-${pid}.json`, 'utf8')) 63 | ); 64 | 65 | const content = await report.getContent(); 66 | 67 | expect(content).toEqual(readFileSync(fixtureFilePath(pid), 'utf8')); 68 | expect(mockFetchProposal).toHaveBeenCalled(); 69 | expect(mockFetchVotes).toHaveBeenCalled(); 70 | }); 71 | 72 | describe('isCacheable()', () => { 73 | it('raises an error when the proposal does not exist', () => { 74 | const report = new VotesReport('test', testStorageEngine); 75 | mockFetchProposal.mockResolvedValueOnce(null); 76 | 77 | expect(report.isCacheable()).rejects.toBe('RECORD_NOT_FOUND'); 78 | expect(mockFetchProposal).toHaveBeenCalled(); 79 | }); 80 | 81 | it('raises an error when the proposal is not closed', async () => { 82 | const report = new VotesReport(id, testStorageEngine); 83 | mockFetchProposal.mockResolvedValueOnce({ 84 | state: 'pending', 85 | id: '', 86 | title: '', 87 | votes: 0, 88 | author: '', 89 | choices: [], 90 | space 91 | }); 92 | 93 | expect(report.isCacheable()).rejects.toBe('RECORD_NOT_FOUND'); 94 | expect(mockFetchProposal).toHaveBeenCalled(); 95 | }); 96 | 97 | it('returns true when the proposal can be cached', async () => { 98 | const report = new VotesReport(id, testStorageEngine); 99 | mockFetchProposal.mockResolvedValueOnce({ 100 | state: 'closed', 101 | id: '', 102 | title: '', 103 | votes: 0, 104 | author: '', 105 | choices: [], 106 | space 107 | }); 108 | 109 | expect(await report.isCacheable()).toBe(true); 110 | expect(mockFetchProposal).toHaveBeenCalled(); 111 | }); 112 | }); 113 | }); 114 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "module": "commonjs", 5 | "rootDir": "./", 6 | "outDir": "./dist", 7 | "esModuleInterop": true, 8 | "strict": true, 9 | "noImplicitAny": true, 10 | "resolveJsonModule": true, 11 | "skipLibCheck": true, 12 | "moduleResolution": "Node", 13 | "sourceMap": true, 14 | "inlineSources": true, 15 | "sourceRoot": "/" 16 | } 17 | } 18 | --------------------------------------------------------------------------------