diff --git a/.env.example b/.env.example index a36f606..866928d 100644 --- a/.env.example +++ b/.env.example @@ -1,11 +1,15 @@ -MERKLE_TREE_HEIGHT=20 -# in wei -ETH_AMOUNT=100000000000000000 -# check config.js -TOKEN_AMOUNT=100000000000000000 -ERC20_TOKEN= - -PRIVATE_KEY= - -#ERC20_TOKEN=0xf3e0d7bf58c5d455d31ef1c2d5375904df525105 -#TOKEN_AMOUNT=1000000 +RPC_URL=https://mainnet.chainnodes.org/d692ae63-0a7e-43e0-9da9-fe4f4cc6c607 +ETHRPC_URL=https://mainnet.chainnodes.org/d692ae63-0a7e-43e0-9da9-fe4f4cc6c607 +GRAPH_URL=https://api.thegraph.com +ETHGRAPH_URL=https://api.thegraph.com +DISABLE_GRAPH=true +RELAYER=torn-city.eth +WALLET_WITHDRAWAL=true +TOR_PORT=9150 +TOKEN=0x77777FeDdddFfC19Ff86DB637967013e6C6A116C +VIEW_ONLY=0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 +MNEMONIC="test test test test test test test test test test test junk" +MNEMONIC_INDEX=0 +PRIVATE_KEY=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 +NON_INTERACTIVE=true +LOCAL_RPC=true \ No newline at end of file diff --git a/.eslintrc.js b/.eslintrc.js index c3ad939..f1e2882 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -5,7 +5,11 @@ module.exports = { }, "extends": [ "eslint:recommended", - "plugin:@typescript-eslint/recommended" + "plugin:@typescript-eslint/recommended", + "plugin:import/recommended", + "plugin:import/typescript", + "prettier", + "plugin:prettier/recommended", ], "overrides": [ { @@ -26,9 +30,18 @@ module.exports = { "sourceType": "module" }, "plugins": [ - "@typescript-eslint" + "@typescript-eslint", + "prettier" ], "rules": { + "prettier/prettier": [ + "error", + { + singleQuote: true, + printWidth: 120 + } + ], + "import/order": ["error"], "indent": [ "error", 2 @@ -44,6 +57,7 @@ module.exports = { "semi": [ "error", "always" - ] + ], + "@typescript-eslint/no-unused-vars": ["warn"] } } diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..a4105e6 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +dist/* linguist-vendored +static/* linguist-vendored \ No newline at end of file diff --git a/README.md b/README.md index 53bfd63..ffdc8b2 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,38 @@ -# Tornado-CLI +
-Command line tool to interact with [Tornado Cash](https://tornado.ws). + -### How to install tornado cli +# Tornado CLI -Download and install [node.js](https://nodejs.org/en/download/). +Modern Toolsets for [Privacy Pools](https://www.forbes.com/sites/tomerniv/2023/09/07/privacy-pools-bridging-the-gap-between-blockchain-and-regulatory-compliance) on Ethereum -You also need to install C++ build tools in order to do 'npm install', for more information please checkout https://github.com/nodejs/node-gyp#on-unix. +[![Telegram Badge](https://img.shields.io/badge/Join%20Group-telegram?style=flat&logo=telegram&color=blue&link=https%3A%2F%2Ft.me%2Ftornadocli)](https://t.me/tornadocli) [![Element Badge](https://img.shields.io/badge/Join%20Element%20Chat-Element?style=flat&logo=element&color=green&link=https%3A%2F%2Felement.tornadocash.social%2F)](https://element.tornadocash.social) [![Discourse Badge](https://img.shields.io/badge/Discourse-Discourse?style=flat&logo=Discourse&color=black&link=https%3A%2F%2Fforum.tornado.ws%2F)](https://forum.tornado.ws/) -- For Windows: https://stackoverflow.com/a/64224475 +
-- For MacOS: Install XCode Command Line Tools +### About Tornado CLI -- For Linux: Install make & gcc, for ubuntu `$ sudo apt-get install -y build-essentials` +Tornado CLI is a complete rewrite of old tornado-cli command line tool with the following changes + + + Rewritten to [TypeScript](https://www.typescriptlang.org/) + + + Built on top of modern tech stacks like [Ethers.js V6](https://docs.ethers.org/v6/) or [TypeChain](https://github.com/dethcrypto/TypeChain) + + + Creates resource heavy Merkle Trees on a separate thread using Web Workers / Worker Threads + + + Resilient API requests made by [cross-fetch](https://www.npmjs.com/package/cross-fetch) and retries, especially for Tor Users + + + Modular design + +### How to install Tornado CLI + +Download and install the latest [Node.js](https://nodejs.org/en/download/) LTS version (Current: 20.x). + +You also need to install [Yarn](https://yarnpkg.com/) 1.x package manager using following command + +```bash +$ npm i -g yarn +``` If you have git installed on your system, clone the master branch. @@ -26,68 +46,120 @@ After downloading or cloning the repository, you must install necessary librarie ```bash $ cd tornado-cli -$ npm install +$ yarn ``` -If you want to use Tor connection to conceal ip address, install [Tor Browser](https://www.torproject.org/download/) and add `--tor 9150` for `cli.js` if you connect tor with browser. (For non tor-browser tor service you can use the default 9050 port). +If you want to use Tor connection to conceal ip address, install [Tor Browser](https://www.torproject.org/download/) and add `--tor-port 9150` for `yarn start` if you connect tor with browser. (For non tor-browser tor service you can use the default 9050 port). Note that you should reset your tor connection by restarting the browser every time when you deposit & withdraw otherwise you will have the same exit node used for connection. -### Goerli, Mainnet, Binance Smart Chain, Gnosis Chain, Polygon Network, Arbitrum, Avalanche +### Configuration -1. `node cli.js --help` -2. If you want to use secure, anonymous tor connection add `--tor ` behind the command. +Commands like `yarn deposit`, `yarn depositInvoice` or `yarn send` would require either a valid view-only wallet address or mnemonic or a private key to perform actions ( Because, if you want to deposit to pools or send the token, you would need your wallet right? ). + +You can apply those values with two options + +1. Refer the `VIEW_ONLY` or `MNEMONIC` or `PRIVATE_KEY` value from the `.env.example` file and create a new `.env` file with the appropriate value. + +2. Supply command-line options `--view-only` or `--mnemonic` or `--private-key` during the commands. + +### How to start + +1. `yarn start --help` +2. If you want to use a secure, anonymous tor connection add `--tor-port ` behind the command or add `TOR_PORT` at the `.env` file. 3. Add `PRIVATE_KEY` to `.env` file (optional, only if you want to use it for many operations) - open `.env.example` file, add private key after `PRIVATE_KEY=` and rename file to `.env`. #### To deposit: ```bash -$ node cli.js deposit --rpc --tor --private-key +$ yarn deposit ``` -Note that `--tor ` is optional, and use `--private-key ` only if you didn't add it to `.env` file. - -For RPC nodes please refer to the list of public RPC nodes below. +RPC nodes are now selected automatically however if you want to change you can use the `--rpc-url` option with the RPC URL following behind (Find any from https://chainlist.org) ##### Example: ```bash -$ node cli.js deposit ETH 0.1 --rpc https://mainnet.chainnodes.org/d692ae63-0a7e-43e0-9da9-fe4f4cc6c607 --tor 9150 +$ yarn deposit 1 ETH 0.1 -Your note: tornado-eth-0.1-5-0xf73dd6833ccbcc046c44228c8e2aa312bf49e08389dadc7c65e6a73239867b7ef49c705c4db227e2fadd8489a494b6880bdcb6016047e019d1abec1c7652 -Tornado ETH balance is 8.9 -Sender account ETH balance is 1004873.470619891361352542 -Submitting deposit transaction -Tornado ETH balance is 9 -Sender account ETH balance is 1004873.361652048361352542 +==================================================================== + + _____ _ ____ _ ___ + |_ _|__ _ __ _ __ __ _ __| | ___ / ___| | |_ _| + | |/ _ \| '__| '_ \ / _` |/ _` |/ _ \ | | | | | | + | | (_) | | | | | | (_| | (_| | (_) | | |___| |___ | | + |_|\___/|_| |_| |_|\__,_|\__,_|\___/ \____|_____|___| + + +==================================================================== + +┌───────────────────────────────────────────────────────┐ +│ Program Options │ +├──────────┬────────────────────────────────────────────┤ +│ IP │ │ +├──────────┼────────────────────────────────────────────┤ +│ Is Tor │ false │ +└──────────┴────────────────────────────────────────────┘ + +Confirm? [Y/n] +Y +New deposit: { + "currency": "eth", + "amount": "0.1", + "netId": 1, + "nullifier": "447406849888912231527060205543641504804080944127170669822752873679919469946", + "secret": "174744806548157587591107992863852449674497869575837897594110402641101509504", + "note": "tornado-eth-0.1-1-0x7a558fc1c1169a22661ce7256ff1a525b494916832c6ea10ba8209652a39fd80d3c62ebb20ad83c0777ceb1fd7a463eb0e952fcad9223a6f37cc1cede662", + "noteHex": "0x7a558fc1c1169a22661ce7256ff1a525b494916832c6ea10ba8209652a39fd80d3c62ebb20ad83c0777ceb1fd7a463eb0e952fcad9223a6f37cc1cede662", + "invoice": "tornadoInvoice-eth-0.1-1-0x0ad52c6472894d3521e40b27af6c590d0567c11819e9bacd4a872f3cc7056a54", + "commitmentHex": "0x0ad52c6472894d3521e40b27af6c590d0567c11819e9bacd4a872f3cc7056a54", + "nullifierHex": "0x2eb4ea29bbf999c40c7ceef35dc5917fbc6ed591fa7eb50b0aea94d3c1254d67" +} + +┌────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ +│ Send Transaction? │ +├──────────────────────┬─────────────────────────────────────────────────────────────────────────────────────┤ +│ to │ 0xd90e2f925DA726b50C4Ed8D0Fb90Ad053324F31b │ +├──────────────────────┼─────────────────────────────────────────────────────────────────────────────────────┤ +│ from │ │ +├──────────────────────┼─────────────────────────────────────────────────────────────────────────────────────┤ +│ data │ 0x13d98d1300000000000000000000000012d66f...0000000000000000000000000000000000000000 │ +├──────────────────────┼─────────────────────────────────────────────────────────────────────────────────────┤ +│ gasLimit │ 1238242 │ +├──────────────────────┼─────────────────────────────────────────────────────────────────────────────────────┤ +│ maxFeePerGas │ 10000000000 │ +├──────────────────────┼─────────────────────────────────────────────────────────────────────────────────────┤ +│ maxPriorityFeePerGas │ 1000000 │ +├──────────────────────┼─────────────────────────────────────────────────────────────────────────────────────┤ +│ value │ 100000000000000000 │ +├──────────────────────┼─────────────────────────────────────────────────────────────────────────────────────┤ +│ type │ 2 │ +├──────────────────────┼─────────────────────────────────────────────────────────────────────────────────────┤ +│ nonce │ │ +├──────────────────────┼─────────────────────────────────────────────────────────────────────────────────────┤ +│ chainId │ 1 │ +└──────────────────────┴─────────────────────────────────────────────────────────────────────────────────────┘ + +Confirm? [Y/n] +Y + +Sent transaction 0x0ad52c6472894d3521e40b27af6c590d0567c11819e9bacd4a872f3cc7056a54 ``` #### To withdraw: ```bash -$ node cli.js withdraw --rpc --relayer --tor --private-key +$ yarn withdraw ``` -Note that `--relayer `, `--tor ` and `--rpc ` are optional parameters, and use `--private-key ` only if you withdraw without relayer. -You can don't provide RPC link and withdrawal will be made via default RPC for the chain to which note belongs. +Note that `--relayer `, `--tor-port ` and `--rpc-url ` are optional parameters, and use `--wallet-withdrawal --private-key ` only if you withdraw without the relayer. -If you want to use Tornado Cash relayer for your first withdrawal to your new ethereum account, please refer to the list of relayers below. - -If you don't need relayer while doing withdrawals, you must provide your withdrawal account's private key - either as parameter, or by adding it to `.env` file. +The CLI will select the relayer from the Relayer Registry contract by scoring for more information about how the relayers are being selected you could refer here https://docs.tornado.ws/general/guides/relayer.html. ##### Example: ```bash -$ node cli.js withdraw tornado-eth-0.1-5-0xf73dd6833ccbcc046c44228c8e2aa312bf49e08389dadc7c65e6a73239867b7ef49c705c4db227e2fadd8489a494b6880bdcb6016047e019d1abec1c7652 0x8589427373D6D84E98730D7795D8f6f8731FDA16 --rpc https://mainnet.chainnodes.org/d692ae63-0a7e-43e0-9da9-fe4f4cc6c607 --relayer https://goerli-relay.example.org --tor 9150 - -Relay address: 0x6A31736e7490AbE5D5676be059DFf064AB4aC754 -Getting current state from tornado contract -Generating SNARK proof -Proof time: 9117.051ms -Sending withdraw transaction through relay -Transaction submitted through the relay. View transaction on etherscan https://goerli.etherscan.io/tx/0xcb21ae8cad723818c6bc7273e83e00c8393fcdbe74802ce5d562acad691a2a7b -Transaction mined in block 17036120 -Done +$ yarn withdraw tornado-eth-0.1-5-0xf73dd6833ccbcc046c44228c8e2aa312bf49e08389dadc7c65e6a73239867b7ef49c705c4db227e2fadd8489a494b6880bdcb6016047e019d1abec1c7652 0x8589427373D6D84E98730D7795D8f6f8731FDA16 ``` ### (Optional) Creating Deposit Notes & Invoices offline @@ -96,10 +168,10 @@ One of the main features of tornado-cli is that it supports creating deposit not After the private-key like notes are backed up somewhere safe, you can copy the created deposit invoices and use them to create new deposit transaction on online environment. -#### To create deposit notes with `createNote` command. +#### To create deposit notes with `create (createDeposit)` command. ```bash -$ node cli.js createNote +$ yarn createDeposit ``` To find out chainId value for your network, refer to https://chainlist.org/. @@ -107,11 +179,39 @@ To find out chainId value for your network, refer to https://chainlist.org/. ##### Example: ```bash -$ node cli.js createNote ETH 0.1 5 -Your note: tornado-eth-0.1-5-0x1d9771a7b9f8b6c03d33116208ce8db1aa559d33e65d22dd2ff78375fc6b635f930536d2432b4bde0178c72cfc79d6b27023c5d9de60985f186b34c18c00 -Your invoice for deposit: tornadoInvoice-eth-0.1-5-0x1b680c7dda0c2dd1b85f0fe126d49b16ed594b3cd6d5114db5f4593877a6b84f -Backed up deposit note as ./backup-tornado-eth-0.1-5-0x1d9771a7.txt -Backed up invoice as ./backup-tornadoInvoice-eth-0.1-5-0x1b680c7d.txt +$ yarn createDeposit 1 ETH 0.1 + +==================================================================== + + _____ _ ____ _ ___ + |_ _|__ _ __ _ __ __ _ __| | ___ / ___| | |_ _| + | |/ _ \| '__| '_ \ / _` |/ _` |/ _ \ | | | | | | + | | (_) | | | | | | (_| | (_| | (_) | | |___| |___ | | + |_|\___/|_| |_| |_|\__,_|\__,_|\___/ \____|_____|___| + + +==================================================================== + + +New deposit: { + "currency": "eth", + "amount": "0.1", + "netId": 1, + "nullifier": "211996166335523441594778881807923807770971048532637197153927747977918013739", + "secret": "443763519478082043322725320022481467938478224697448315688237911974763852521", + "note": "tornado-eth-0.1-1-0x2be1c7f1d7cc77e96e394b108ddc2bf2b25b8c2158ebb92b6cc347d74efc77e9723c6b6ac0654e00588f7ec8177e8a7dc47bc3b00fe75c7d094dc24729fb", + "noteHex": "0x2be1c7f1d7cc77e96e394b108ddc2bf2b25b8c2158ebb92b6cc347d74efc77e9723c6b6ac0654e00588f7ec8177e8a7dc47bc3b00fe75c7d094dc24729fb", + "invoice": "tornadoInvoice-eth-0.1-1-0x24d26c7d0381dc34941b6fe9e0d622c7efadc0bfdc9d3f7e8dcb1e490e6ce9ea", + "commitmentHex": "0x24d26c7d0381dc34941b6fe9e0d622c7efadc0bfdc9d3f7e8dcb1e490e6ce9ea", + "nullifierHex": "0x0e3ec1a269e2e143bc8b2dfca40975bc8f5af2754917cd1f839e499162b28324" +} + +Transaction Data: { + "to": "0xd90e2f925DA726b50C4Ed8D0Fb90Ad053324F31b", + "value": "100000000000000000", + "data": "0x13d98d1300000000000000000000000012d66f87a04a9e220743712ce6d9bb1b5616b8fc24d26c7d0381dc34941b6fe9e0d622c7efadc0bfdc9d3f7e8dcb1e490e6ce9ea00000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000000" +} +Done in 4.33s. ``` #### To create corresponding deposit transaction with `depositInvoice` command. @@ -119,374 +219,23 @@ Backed up invoice as ./backup-tornadoInvoice-eth-0.1-5-0x1b680c7d.txt Creating deposit transaction with `depositInvoice` only requires valid deposit note created by `createNote` command, so that the deposit note could be stored without exposed anywhere. ```bash -$ node cli.js depositInvoice --rpc --tor +$ node cli.js depositInvoice ``` -Parameter `--rpc ` is optional, if you don't provide it, default RPC (corresponding to note chain) will be used. +Parameter `--rpc-url ` is optional, if you don't provide it, default RPC (corresponding to note chain) will be used. ##### Example: ```bash -node cli.js depositInvoice tornadoInvoice-eth-0.1-5-0x1b680c7dda0c2dd1b85f0fe126d49b16ed594b3cd6d5114db5f4593877a6b84f --rpc https://goerli.infura.io/v3/9aa3d95b3bc440fa88ea12eaa4456161 --tor 9150 -Using tor network -Your remote IP address is xx.xx.xx.xx from xx. -Creating ETH 0.1 deposit for Goerli network. -Using supplied invoice for deposit -Tornado contract balance is xxx.x ETH -Sender account balance is x.xxxxxxx ETH -Submitting deposit transaction -Submitting transaction to the remote node -View transaction on block explorer https://goerli.etherscan.io/tx/0x6ded443caed8d6f2666841149532c64bee149a9a8e1070ed4c91a12dd1837747 -Tornado contract balance is xxx.x ETH -Sender account balance is x.xxxxxxx ETH +yarn depositInvoice tornadoInvoice-eth-0.1-1-0x24d26c7d0381dc34941b6fe9e0d622c7efadc0bfdc9d3f7e8dcb1e490e6ce9ea ``` ### List of rpc & relayers for withdrawal -```json -{ - "netId1": { - "rpcUrls": { - "publicRpc1": { - "name": "1RPC", - "url": "https://1rpc.io/eth" - }, - "Chainnodes": { - "name": "Chainnodes", - "url": "https://mainnet.chainnodes.org/d692ae63-0a7e-43e0-9da9-fe4f4cc6c607" - } - }, - "relayers": { - "0xproxy.eth": { - "url": "0xproxy.eth", - "name": "0xproxy.eth", - "cachedUrl": "http://mainnet.0x0relayer.xyz/" - }, - "0xtornadocash.eth": { - "url": "0xtornadocash.eth", - "name": "0xtornadocash.eth", - "cachedUrl": "http://mainnet-tornado-0xtornadocash-eth.crypto-bot.exchange/" - }, - "available-reliable-relayer.eth": { - "url": "available-reliable-relayer.eth", - "name": "available-reliable-relayer.eth", - "cachedUrl": "http://mainnet-tornado-arr-eth.crypto-bot.exchange/" - }, - "bitah.eth": { - "url": "bitah.eth", - "name": "bitah.eth", - "cachedUrl": "http://tornado.bitah.link/" - }, - "cheap-relayer.eth": { - "url": "cheap-relayer.eth", - "name": "cheap-relayer.eth", - "cachedUrl": "http://mainnet-tornado.cheap-relayer.xyz/" - }, - "em3tornado.eth": { - "url": "em3tornado.eth", - "name": "em3tornado.eth", - "cachedUrl": "http://em3torn.com/" - }, - "lowcost.eth": { - "url": "lowcost.eth", - "name": "lowcost.eth", - "cachedUrl": "http://mainnet-tornado.low-fee.xyz/" - }, - "relayer007.eth": { - "url": "relayer007.eth", - "name": "relayer007.eth", - "cachedUrl": "http://torn.relayersdao.finance/" - }, - "reltor.eth": { - "url": "reltor.eth", - "name": "reltor.eth", - "cachedUrl": "http://reltor.su/" - }, - "shadow-out.eth": { - "url": "shadow-out.eth", - "name": "shadow-out.eth", - "cachedUrl": "http://torn-relayer.shadowninjas.xyz/" - }, - "thornadope.eth": { - "url": "thornadope.eth", - "name": "thornadope.eth", - "cachedUrl": "http://thornadope.xyz/" - }, - "torn-eth.eth": { - "url": "torn-eth.eth", - "name": "torn-eth.eth", - "cachedUrl": "http://mainnet-tornado.50swap.com/" - }, - "torn-relayers.eth": { - "url": "torn-relayers.eth", - "name": "torn-relayers.eth", - "cachedUrl": "http://mainnet.tornrelayers.com/" - }, - "torn-secure.eth": { - "url": "torn-secure.eth", - "name": "torn-secure.eth", - "cachedUrl": "http://mainnet-tornado.secure-relays.site/" - }, - "torn69.eth": { - "url": "torn69.eth", - "name": "torn69.eth", - "cachedUrl": "http://m2.torn69.gq/" - }, - "tornado-crypto-bot-exchange.eth": { - "url": "tornado-crypto-bot-exchange.eth", - "name": "tornado-crypto-bot-exchange.eth", - "cachedUrl": "http://tornado.crypto-bot.exchange/" - }, - "torndao.eth": { - "url": "torndao.eth", - "name": "torndao.eth", - "cachedUrl": "http://eth-tornado.zkany.com/" - }, - "tornrelayers.eth": { - "url": "tornrelayers.eth", - "name": "tornrelayers.eth", - "cachedUrl": "http://mainnet-tornado-tornrelayer-eth.crypto-bot.exchange/" - } - } - }, - "netId56": { - "rpcUrls": { - "publicRpc1": { - "name": "BSC Public RPC 1", - "url": "https://1rpc.io/bnb" - }, - "publicRpc2": { - "name": "BSC Public RPC 2", - "url": "https://bsc-dataseed1.defibit.io" - }, - "publicRpc3": { - "name": "BSC Public RPC 3", - "url": "https://bsc-dataseed1.ninicoin.io" - } - }, - "relayers": { - "0xproxy.eth": { - "url": "0xproxy.eth", - "name": "0xproxy.eth", - "cachedUrl": "http://bsc.0x0relayer.xyz/" - }, - "bitah.eth": { - "url": "bitah.eth", - "name": "bitah.eth", - "cachedUrl": "http://bsc-tornado.bitah.link/" - }, - "cheap-relayer.eth": { - "url": "cheap-relayer.eth", - "name": "cheap-relayer.eth", - "cachedUrl": "http://bsc-tornado.cheap-relayer.xyz/" - }, - "em3tornado.eth": { - "url": "em3tornado.eth", - "name": "em3tornado.eth", - "cachedUrl": "http://bsc.em3torn.com/" - }, - "lowcost.eth": { - "url": "lowcost.eth", - "name": "lowcost.eth", - "cachedUrl": "http://bsc-tornado.low-fee.xyz/" - }, - "relayer007.eth": { - "url": "relayer007.eth", - "name": "relayer007.eth", - "cachedUrl": "http://bsc.relayersdao.finance/" - }, - "reltor.eth": { - "url": "reltor.eth", - "name": "reltor.eth", - "cachedUrl": "http://bsc.reltor.su/" - }, - "thornadope.eth": { - "url": "thornadope.eth", - "name": "thornadope.eth", - "cachedUrl": "http://tornado-bsc.thornadope.xyz/" - }, - "torn-relay.eth": { - "url": "torn-relay.eth", - "name": "torn-relay.eth", - "cachedUrl": "http://bsc.torn-relay.com/" - }, - "torn-relayers.eth": { - "url": "torn-relayers.eth", - "name": "torn-relayers.eth", - "cachedUrl": "http://bsc.tornrelayers.com/" - }, - "torn-secure.eth": { - "url": "torn-secure.eth", - "name": "torn-secure.eth", - "cachedUrl": "http://bsc-v0.secure-relays.site/" - }, - "torn69.eth": { - "url": "torn69.eth", - "name": "torn69.eth", - "cachedUrl": "http://bsc.0x111111.xyz/" - }, - "tornado-crypto-bot-exchange.eth": { - "url": "tornado-crypto-bot-exchange.eth", - "name": "tornado-crypto-bot-exchange.eth", - "cachedUrl": "http://tornado-bsc.crypto-bot.exchange/" - }, - "torndao.eth": { - "url": "torndao.eth", - "name": "torndao.eth", - "cachedUrl": "http://bsc-tornado.zkany.com/" - } - } - }, - "netId100": { - "rpcUrls": { - "publicRpc": { - "name": "Gnosis Chain RPC", - "url": "https://rpc.gnosischain.com" - } - }, - "relayers": { - "torndao.eth": { - "url": "torndao.eth", - "name": "torndao.eth", - "cachedUrl": "http://gnosis-tornado.zkany.com/" - } - } - }, - "netId137": { - "rpcUrls": { - "publicRpc1": { - "name": "1RPC", - "url": "https://1rpc.io/matic" - }, - "Chainnodes": { - "name": "Chainnodes", - "url": "https://polygon-mainnet.chainnodes.org/d692ae63-0a7e-43e0-9da9-fe4f4cc6c607" - } - }, - "relayers": { - "cheap-relayer.eth": { - "url": "cheap-relayer.eth", - "name": "cheap-relayer.eth", - "cachedUrl": "http://polygon-tornado.cheap-relayer.xyz/" - }, - "lowcost.eth": { - "url": "lowcost.eth", - "name": "lowcost.eth", - "cachedUrl": "http://polygon-tornado.low-fee.xyz/" - }, - "relayer007.eth": { - "url": "relayer007.eth", - "name": "relayer007.eth", - "cachedUrl": "http://matic.relayersdao.finance/" - }, - "reltor.eth": { - "url": "reltor.eth", - "name": "reltor.eth", - "cachedUrl": "http://polygon.reltor.su/" - }, - "thornadope.eth": { - "url": "thornadope.eth", - "name": "thornadope.eth", - "cachedUrl": "http://tornado-polygon.thornadope.xyz/" - }, - "torn-secure.eth": { - "url": "torn-secure.eth", - "name": "torn-secure.eth", - "cachedUrl": "http://poly-v0.secure-relays.site/" - }, - "tornado-crypto-bot-exchange.eth": { - "url": "tornado-crypto-bot-exchange.eth", - "name": "tornado-crypto-bot-exchange.eth", - "cachedUrl": "http://tornado-polygon.crypto-bot.exchange/" - }, - "torndao.eth": { - "url": "torndao.eth", - "name": "torndao.eth", - "cachedUrl": "http://polygon-tornado.zkany.com/" - } - } - }, - "netId42161": { - "rpcUrls": { - "publicRpc1": { - "name": "Arbitrum Public RPC", - "url": "https://arb1.arbitrum.io/rpc" - }, - "publicRpc2": { - "name": "ChainnodesRPC", - "url": "https://arbitrum-one.chainnodes.org/d692ae63-0a7e-43e0-9da9-fe4f4cc6c607" - } - }, - "relayers": { - "tornado-crypto-bot-exchange.eth": { - "url": "tornado-crypto-bot-exchange.eth", - "name": "tornado-crypto-bot-exchange.eth", - "cachedUrl": "http://tornado-arbitrum.crypto-bot.exchange/" - }, - "torndao.eth": { - "url": "torndao.eth", - "name": "torndao.eth", - "cachedUrl": "http://arbitrum-tornado.zkany.com/" - } - } - }, - "netId43114": { - "rpcUrls": { - "publicRpc": { - "name": "1RPC", - "url": "https://1rpc.io/avax/c" - } - }, - "relayers": { - "cheap-relayer.eth": { - "url": "cheap-relayer.eth", - "name": "cheap-relayer.eth", - "cachedUrl": "http://avalanche-tornado.cheap-relayer.xyz/" - }, - "lowcost.eth": { - "url": "lowcost.eth", - "name": "lowcost.eth", - "cachedUrl": "http://avalanche-tornado.low-fee.xyz/" - }, - "thornadope.eth": { - "url": "thornadope.eth", - "name": "thornadope.eth", - "cachedUrl": "http://tornado-avalanche.thornadope.xyz/" - }, - "tornado-crypto-bot-exchange.eth": { - "url": "tornado-crypto-bot-exchange.eth", - "name": "tornado-crypto-bot-exchange.eth", - "cachedUrl": "http://tornado-avalanche.crypto-bot.exchange/" - } - } - }, - "netId10": { - "rpcUrls": { - "publicRpc1": { - "name": "1RPC", - "url": "https://1rpc.io/op" - }, - "Chainnodes": { - "name": "Chainnodes", - "url": "https://optimism-mainnet.chainnodes.org/d692ae63-0a7e-43e0-9da9-fe4f4cc6c607" - } - }, - "relayers": { - "tornado-crypto-bot-exchange.eth": { - "url": "tornado-crypto-bot-exchange.eth", - "name": "tornado-crypto-bot-exchange.eth", - "cachedUrl": "http://tornado-optimism.crypto-bot.exchange/" - } - } - }, - "netId5": { - "rpcUrls": { - "Chainnodes": { - "name": "Chainnodes RPC", - "url": "https://goerli.chainnodes.org/d692ae63-0a7e-43e0-9da9-fe4f4cc6c607" - } - }, - "relayers": {} - } -} -``` +Refer https://chainlist.org for a full list of available public RPC URLs. + +Note that most of the RPC would censor sanctionded pool contracts. + +So either you can use the default RPC or find yourself a suitable one. + +For the list of avaiable relayers, use the `yarn relayers 1` command. \ No newline at end of file diff --git a/logo.png b/logo.png new file mode 100644 index 0000000..7987929 Binary files /dev/null and b/logo.png differ diff --git a/logo2.png b/logo2.png new file mode 100644 index 0000000..40e33e1 Binary files /dev/null and b/logo2.png differ diff --git a/package.json b/package.json index a6c2b88..853fa4e 100644 --- a/package.json +++ b/package.json @@ -1,19 +1,31 @@ { "name": "tornado-cli", - "version": "1.0.0", - "description": "", + "version": "1.0.1-alpha", + "description": "Modern Toolsets for Privacy Pools on Ethereum", "main": "./dist/index.js", "module": "./dist/index.mjs", "types": "./dist/index.d.ts", "bin": { - "tornado": "./dist/index.js" + "tornado-cli": "./dist/cli.js" }, "scripts": { "typechain": "typechain --target ethers-v6 --out-dir src/typechain src/abi/*.json", "types": "tsc --declaration --emitDeclarationOnly", - "lint": "eslint src/**/*.ts --ext .ts --ignore-pattern src/typechain --fix", - "build": "rollup -c", - "start": "ts-node src/index.ts" + "lint": "eslint src/**/*.ts --ext .ts --ignore-pattern src/typechain", + "build": "yarn types && rollup -c", + "start": "ts-node src/cli.ts", + "startHelp": "ts-node src/cli.ts help", + "createDeposit": "ts-node src/cli.ts create", + "deposit": "ts-node src/cli.ts deposit", + "depositInvoice": "ts-node src/cli.ts depositInvoice", + "withdraw": "ts-node src/cli.ts withdraw", + "compliance": "ts-node src/cli.ts compliance", + "syncEvents": "ts-node src/cli.ts syncEvents", + "relayers": "ts-node src/cli.ts relayers", + "send": "ts-node src/cli.ts send", + "balance": "ts-node src/cli.ts balance", + "sign": "ts-node src/cli.ts sign", + "broadcast": "ts-node src/cli.ts broadcast" }, "author": "", "license": "MIT", @@ -26,6 +38,7 @@ ".eslintrc.js", ".gitattributes", ".gitignore", + ".npmrc", "rollup.config.mjs", "tsconfig.json", "yarn.lock" diff --git a/rollup.config.mjs b/rollup.config.mjs index b1ed6fc..98e4b34 100644 --- a/rollup.config.mjs +++ b/rollup.config.mjs @@ -2,19 +2,25 @@ import esbuild from 'rollup-plugin-esbuild'; import { nodeResolve } from '@rollup/plugin-node-resolve'; import commonjs from '@rollup/plugin-commonjs'; import json from '@rollup/plugin-json'; +import replace from '@rollup/plugin-replace'; +import pkgJson from './package.json' assert { type: 'json' }; + +const external = Object.keys(pkgJson.dependencies).concat(...[ + '@tornado/websnark/src/utils', + '@tornado/websnark/src/groth16', +]); const config = [ { input: 'src/index.ts', output: [ { - file: 'dist/index.js', + file: pkgJson.main, format: "cjs", esModule: false, - banner: '#!/usr/bin/env node\n' }, ], - external: [], + external, plugins: [ esbuild({ include: /\.[jt]sx?$/, @@ -26,6 +32,96 @@ const config = [ commonjs(), json() ], + }, + { + input: 'src/index.ts', + output: [ + { + file: pkgJson.module, + format: "esm", + }, + ], + external, + plugins: [ + esbuild({ + include: /\.[jt]sx?$/, + minify: false, + sourceMap: true, + target: 'es2016', + }), + nodeResolve(), + json() + ], + }, + { + input: 'src/cli.ts', + output: [ + { + file: pkgJson.bin[pkgJson.name], + format: "cjs", + esModule: false, + banner: '#!/usr/bin/env node\n' + }, + ], + plugins: [ + esbuild({ + include: /\.[jt]sx?$/, + minify: false, + sourceMap: true, + target: 'es2016', + }), + nodeResolve(), + commonjs(), + json() + ], + }, + { + input: 'src/merkleTreeWorker.ts', + output: [ + { + file: 'static/merkleTreeWorker.js', + format: "cjs", + esModule: false, + }, + ], + treeshake: 'smallest', + plugins: [ + esbuild({ + include: /\.[jt]sx?$/, + minify: false, + sourceMap: true, + target: 'es2016', + }), + nodeResolve(), + commonjs(), + json() + ], + }, + { + input: 'src/merkleTreeWorker.ts', + output: [ + { + file: 'static/merkleTreeWorker.umd.js', + format: "umd", + esModule: false + }, + ], + treeshake: 'smallest', + external: ['web-worker'], + plugins: [ + esbuild({ + include: /\.[jt]sx?$/, + minify: false, + sourceMap: true, + target: 'es2016', + }), + nodeResolve(), + commonjs(), + json(), + replace({ + 'process.browser': 'true' + }) + ], } ] diff --git a/scripts/figlet-font.ts b/scripts/figlet-font.ts new file mode 100644 index 0000000..27a9f38 --- /dev/null +++ b/scripts/figlet-font.ts @@ -0,0 +1,12 @@ +import fs from 'fs' + +let figletStandard = fs.readFileSync('./node_modules/figlet/importable-fonts/Standard.js', { encoding: 'utf8' }) as string; + +figletStandard = figletStandard.replace('export default `', '') + +figletStandard = figletStandard.replace(' `', '') + +fs.writeFileSync('./src/fonts/figletStandard.ts', ` +export const figletStandard: string = \`${figletStandard}\`; +export default figletStandard; +`) \ No newline at end of file diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..b341924 --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,15 @@ +import figlet from 'figlet'; +import Standard from './fonts/figletStandard'; +import { tornadoProgram } from './program'; + +figlet.parseFont('Standard', Standard); + +console.log(` +==================================================================== + +${figlet.textSync('Tornado CLI', { font: 'Standard' })} + +====================================================================\n +`); + +tornadoProgram().parse(); diff --git a/src/fonts/figletStandard.ts b/src/fonts/figletStandard.ts new file mode 100644 index 0000000..85d7393 --- /dev/null +++ b/src/fonts/figletStandard.ts @@ -0,0 +1,2238 @@ +export const figletStandard: string = `flf2a$ 6 5 16 15 13 0 24463 229 +Standard by Glenn Chappell & Ian Chai 3/93 -- based on Frank's .sig +Includes ISO Latin-1 +figlet release 2.1 -- 12 Aug 1994 +Modified for figlet 2.2 by John Cowan + to add Latin-{2,3,4,5} support (Unicode U+0100-017F). +Permission is hereby given to modify this font, as long as the +modifier's name is placed on a comment line. + +Modified by Paul Burton 12/96 to include new parameter +supported by FIGlet and FIGWin. May also be slightly modified for better use +of new full-width/kern/smush alternatives, but default output is NOT changed. + +Font modified May 20, 2012 by patorjk to add the 0xCA0 character + $@ + $@ + $@ + $@ + $@ + $@@ + _ @ + | |@ + | |@ + |_|@ + (_)@ + @@ + _ _ @ + ( | )@ + V V @ + $ @ + $ @ + @@ + _ _ @ + _| || |_ @ + |_ .. _|@ + |_ _|@ + |_||_| @ + @@ + _ @ + | | @ + / __)@ + \\__ \\@ + ( /@ + |_| @@ + _ __@ + (_)/ /@ + / / @ + / /_ @ + /_/(_)@ + @@ + ___ @ + ( _ ) @ + / _ \\/\\@ + | (_> <@ + \\___/\\/@ + @@ + _ @ + ( )@ + |/ @ + $ @ + $ @ + @@ + __@ + / /@ + | | @ + | | @ + | | @ + \\_\\@@ + __ @ + \\ \\ @ + | |@ + | |@ + | |@ + /_/ @@ + @ + __/\\__@ + \\ /@ + /_ _\\@ + \\/ @ + @@ + @ + _ @ + _| |_ @ + |_ _|@ + |_| @ + @@ + @ + @ + @ + _ @ + ( )@ + |/ @@ + @ + @ + _____ @ + |_____|@ + $ @ + @@ + @ + @ + @ + _ @ + (_)@ + @@ + __@ + / /@ + / / @ + / / @ + /_/ @ + @@ + ___ @ + / _ \\ @ + | | | |@ + | |_| |@ + \\___/ @ + @@ + _ @ + / |@ + | |@ + | |@ + |_|@ + @@ + ____ @ + |___ \\ @ + __) |@ + / __/ @ + |_____|@ + @@ + _____ @ + |___ / @ + |_ \\ @ + ___) |@ + |____/ @ + @@ + _ _ @ + | || | @ + | || |_ @ + |__ _|@ + |_| @ + @@ + ____ @ + | ___| @ + |___ \\ @ + ___) |@ + |____/ @ + @@ + __ @ + / /_ @ + | '_ \\ @ + | (_) |@ + \\___/ @ + @@ + _____ @ + |___ |@ + / / @ + / / @ + /_/ @ + @@ + ___ @ + ( _ ) @ + / _ \\ @ + | (_) |@ + \\___/ @ + @@ + ___ @ + / _ \\ @ + | (_) |@ + \\__, |@ + /_/ @ + @@ + @ + _ @ + (_)@ + _ @ + (_)@ + @@ + @ + _ @ + (_)@ + _ @ + ( )@ + |/ @@ + __@ + / /@ + / / @ + \\ \\ @ + \\_\\@ + @@ + @ + _____ @ + |_____|@ + |_____|@ + $ @ + @@ + __ @ + \\ \\ @ + \\ \\@ + / /@ + /_/ @ + @@ + ___ @ + |__ \\@ + / /@ + |_| @ + (_) @ + @@ + ____ @ + / __ \\ @ + / / _\` |@ + | | (_| |@ + \\ \\__,_|@ + \\____/ @@ + _ @ + / \\ @ + / _ \\ @ + / ___ \\ @ + /_/ \\_\\@ + @@ + ____ @ + | __ ) @ + | _ \\ @ + | |_) |@ + |____/ @ + @@ + ____ @ + / ___|@ + | | @ + | |___ @ + \\____|@ + @@ + ____ @ + | _ \\ @ + | | | |@ + | |_| |@ + |____/ @ + @@ + _____ @ + | ____|@ + | _| @ + | |___ @ + |_____|@ + @@ + _____ @ + | ___|@ + | |_ @ + | _| @ + |_| @ + @@ + ____ @ + / ___|@ + | | _ @ + | |_| |@ + \\____|@ + @@ + _ _ @ + | | | |@ + | |_| |@ + | _ |@ + |_| |_|@ + @@ + ___ @ + |_ _|@ + | | @ + | | @ + |___|@ + @@ + _ @ + | |@ + _ | |@ + | |_| |@ + \\___/ @ + @@ + _ __@ + | |/ /@ + | ' / @ + | . \\ @ + |_|\\_\\@ + @@ + _ @ + | | @ + | | @ + | |___ @ + |_____|@ + @@ + __ __ @ + | \\/ |@ + | |\\/| |@ + | | | |@ + |_| |_|@ + @@ + _ _ @ + | \\ | |@ + | \\| |@ + | |\\ |@ + |_| \\_|@ + @@ + ___ @ + / _ \\ @ + | | | |@ + | |_| |@ + \\___/ @ + @@ + ____ @ + | _ \\ @ + | |_) |@ + | __/ @ + |_| @ + @@ + ___ @ + / _ \\ @ + | | | |@ + | |_| |@ + \\__\\_\\@ + @@ + ____ @ + | _ \\ @ + | |_) |@ + | _ < @ + |_| \\_\\@ + @@ + ____ @ + / ___| @ + \\___ \\ @ + ___) |@ + |____/ @ + @@ + _____ @ + |_ _|@ + | | @ + | | @ + |_| @ + @@ + _ _ @ + | | | |@ + | | | |@ + | |_| |@ + \\___/ @ + @@ + __ __@ + \\ \\ / /@ + \\ \\ / / @ + \\ V / @ + \\_/ @ + @@ + __ __@ + \\ \\ / /@ + \\ \\ /\\ / / @ + \\ V V / @ + \\_/\\_/ @ + @@ + __ __@ + \\ \\/ /@ + \\ / @ + / \\ @ + /_/\\_\\@ + @@ + __ __@ + \\ \\ / /@ + \\ V / @ + | | @ + |_| @ + @@ + _____@ + |__ /@ + / / @ + / /_ @ + /____|@ + @@ + __ @ + | _|@ + | | @ + | | @ + | | @ + |__|@@ + __ @ + \\ \\ @ + \\ \\ @ + \\ \\ @ + \\_\\@ + @@ + __ @ + |_ |@ + | |@ + | |@ + | |@ + |__|@@ + /\\ @ + |/\\|@ + $ @ + $ @ + $ @ + @@ + @ + @ + @ + @ + _____ @ + |_____|@@ + _ @ + ( )@ + \\|@ + $ @ + $ @ + @@ + @ + __ _ @ + / _\` |@ + | (_| |@ + \\__,_|@ + @@ + _ @ + | |__ @ + | '_ \\ @ + | |_) |@ + |_.__/ @ + @@ + @ + ___ @ + / __|@ + | (__ @ + \\___|@ + @@ + _ @ + __| |@ + / _\` |@ + | (_| |@ + \\__,_|@ + @@ + @ + ___ @ + / _ \\@ + | __/@ + \\___|@ + @@ + __ @ + / _|@ + | |_ @ + | _|@ + |_| @ + @@ + @ + __ _ @ + / _\` |@ + | (_| |@ + \\__, |@ + |___/ @@ + _ @ + | |__ @ + | '_ \\ @ + | | | |@ + |_| |_|@ + @@ + _ @ + (_)@ + | |@ + | |@ + |_|@ + @@ + _ @ + (_)@ + | |@ + | |@ + _/ |@ + |__/ @@ + _ @ + | | __@ + | |/ /@ + | < @ + |_|\\_\\@ + @@ + _ @ + | |@ + | |@ + | |@ + |_|@ + @@ + @ + _ __ ___ @ + | '_ \` _ \\ @ + | | | | | |@ + |_| |_| |_|@ + @@ + @ + _ __ @ + | '_ \\ @ + | | | |@ + |_| |_|@ + @@ + @ + ___ @ + / _ \\ @ + | (_) |@ + \\___/ @ + @@ + @ + _ __ @ + | '_ \\ @ + | |_) |@ + | .__/ @ + |_| @@ + @ + __ _ @ + / _\` |@ + | (_| |@ + \\__, |@ + |_|@@ + @ + _ __ @ + | '__|@ + | | @ + |_| @ + @@ + @ + ___ @ + / __|@ + \\__ \\@ + |___/@ + @@ + _ @ + | |_ @ + | __|@ + | |_ @ + \\__|@ + @@ + @ + _ _ @ + | | | |@ + | |_| |@ + \\__,_|@ + @@ + @ + __ __@ + \\ \\ / /@ + \\ V / @ + \\_/ @ + @@ + @ + __ __@ + \\ \\ /\\ / /@ + \\ V V / @ + \\_/\\_/ @ + @@ + @ + __ __@ + \\ \\/ /@ + > < @ + /_/\\_\\@ + @@ + @ + _ _ @ + | | | |@ + | |_| |@ + \\__, |@ + |___/ @@ + @ + ____@ + |_ /@ + / / @ + /___|@ + @@ + __@ + / /@ + | | @ + < < @ + | | @ + \\_\\@@ + _ @ + | |@ + | |@ + | |@ + | |@ + |_|@@ + __ @ + \\ \\ @ + | | @ + > >@ + | | @ + /_/ @@ + /\\/|@ + |/\\/ @ + $ @ + $ @ + $ @ + @@ + _ _ @ + (_)_(_)@ + /_\\ @ + / _ \\ @ + /_/ \\_\\@ + @@ + _ _ @ + (_)_(_)@ + / _ \\ @ + | |_| |@ + \\___/ @ + @@ + _ _ @ + (_) (_)@ + | | | |@ + | |_| |@ + \\___/ @ + @@ + _ _ @ + (_)_(_)@ + / _\` |@ + | (_| |@ + \\__,_|@ + @@ + _ _ @ + (_)_(_)@ + / _ \\ @ + | (_) |@ + \\___/ @ + @@ + _ _ @ + (_) (_)@ + | | | |@ + | |_| |@ + \\__,_|@ + @@ + ___ @ + / _ \\@ + | |/ /@ + | |\\ \\@ + | ||_/@ + |_| @@ +160 NO-BREAK SPACE + $@ + $@ + $@ + $@ + $@ + $@@ +161 INVERTED EXCLAMATION MARK + _ @ + (_)@ + | |@ + | |@ + |_|@ + @@ +162 CENT SIGN + _ @ + | | @ + / __)@ + | (__ @ + \\ )@ + |_| @@ +163 POUND SIGN + ___ @ + / ,_\\ @ + _| |_ @ + | |___ @ + (_,____|@ + @@ +164 CURRENCY SIGN + /\\___/\\@ + \\ _ /@ + | (_) |@ + / ___ \\@ + \\/ \\/@ + @@ +165 YEN SIGN + __ __ @ + \\ V / @ + |__ __|@ + |__ __|@ + |_| @ + @@ +166 BROKEN BAR + _ @ + | |@ + |_|@ + _ @ + | |@ + |_|@@ +167 SECTION SIGN + __ @ + _/ _)@ + / \\ \\ @ + \\ \\\\ \\@ + \\ \\_/@ + (__/ @@ +168 DIAERESIS + _ _ @ + (_) (_)@ + $ $ @ + $ $ @ + $ $ @ + @@ +169 COPYRIGHT SIGN + _____ @ + / ___ \\ @ + / / __| \\ @ + | | (__ |@ + \\ \\___| / @ + \\_____/ @@ +170 FEMININE ORDINAL INDICATOR + __ _ @ + / _\` |@ + \\__,_|@ + |____|@ + $ @ + @@ +171 LEFT-POINTING DOUBLE ANGLE QUOTATION MARK + ____@ + / / /@ + / / / @ + \\ \\ \\ @ + \\_\\_\\@ + @@ +172 NOT SIGN + @ + _____ @ + |___ |@ + |_|@ + $ @ + @@ +173 SOFT HYPHEN + @ + @ + ____ @ + |____|@ + $ @ + @@ +174 REGISTERED SIGN + _____ @ + / ___ \\ @ + / | _ \\ \\ @ + | | / |@ + \\ |_|_\\ / @ + \\_____/ @@ +175 MACRON + _____ @ + |_____|@ + $ @ + $ @ + $ @ + @@ +176 DEGREE SIGN + __ @ + / \\ @ + | () |@ + \\__/ @ + $ @ + @@ +177 PLUS-MINUS SIGN + _ @ + _| |_ @ + |_ _|@ + _|_|_ @ + |_____|@ + @@ +178 SUPERSCRIPT TWO + ___ @ + |_ )@ + / / @ + /___|@ + $ @ + @@ +179 SUPERSCRIPT THREE + ____@ + |__ /@ + |_ \\@ + |___/@ + $ @ + @@ +180 ACUTE ACCENT + __@ + /_/@ + $ @ + $ @ + $ @ + @@ +181 MICRO SIGN + @ + _ _ @ + | | | |@ + | |_| |@ + | ._,_|@ + |_| @@ +182 PILCROW SIGN + _____ @ + / |@ + | (| | |@ + \\__ | |@ + |_|_|@ + @@ +183 MIDDLE DOT + @ + _ @ + (_)@ + $ @ + $ @ + @@ +184 CEDILLA + @ + @ + @ + @ + _ @ + )_)@@ +185 SUPERSCRIPT ONE + _ @ + / |@ + | |@ + |_|@ + $ @ + @@ +186 MASCULINE ORDINAL INDICATOR + ___ @ + / _ \\@ + \\___/@ + |___|@ + $ @ + @@ +187 RIGHT-POINTING DOUBLE ANGLE QUOTATION MARK + ____ @ + \\ \\ \\ @ + \\ \\ \\@ + / / /@ + /_/_/ @ + @@ +188 VULGAR FRACTION ONE QUARTER + _ __ @ + / | / / _ @ + | |/ / | | @ + |_/ /|_ _|@ + /_/ |_| @ + @@ +189 VULGAR FRACTION ONE HALF + _ __ @ + / | / /__ @ + | |/ /_ )@ + |_/ / / / @ + /_/ /___|@ + @@ +190 VULGAR FRACTION THREE QUARTERS + ____ __ @ + |__ / / / _ @ + |_ \\/ / | | @ + |___/ /|_ _|@ + /_/ |_| @ + @@ +191 INVERTED QUESTION MARK + _ @ + (_) @ + | | @ + / /_ @ + \\___|@ + @@ +192 LATIN CAPITAL LETTER A WITH GRAVE + __ @ + \\_\\ @ + /_\\ @ + / _ \\ @ + /_/ \\_\\@ + @@ +193 LATIN CAPITAL LETTER A WITH ACUTE + __ @ + /_/ @ + /_\\ @ + / _ \\ @ + /_/ \\_\\@ + @@ +194 LATIN CAPITAL LETTER A WITH CIRCUMFLEX + //\\ @ + |/_\\| @ + /_\\ @ + / _ \\ @ + /_/ \\_\\@ + @@ +195 LATIN CAPITAL LETTER A WITH TILDE + /\\/| @ + |/\\/ @ + /_\\ @ + / _ \\ @ + /_/ \\_\\@ + @@ +196 LATIN CAPITAL LETTER A WITH DIAERESIS + _ _ @ + (_)_(_)@ + /_\\ @ + / _ \\ @ + /_/ \\_\\@ + @@ +197 LATIN CAPITAL LETTER A WITH RING ABOVE + _ @ + (o) @ + /_\\ @ + / _ \\ @ + /_/ \\_\\@ + @@ +198 LATIN CAPITAL LETTER AE + ______ @ + / ____|@ + / _ _| @ + / __ |___ @ + /_/ |_____|@ + @@ +199 LATIN CAPITAL LETTER C WITH CEDILLA + ____ @ + / ___|@ + | | @ + | |___ @ + \\____|@ + )_) @@ +200 LATIN CAPITAL LETTER E WITH GRAVE + __ @ + _\\_\\_ @ + | ____|@ + | _|_ @ + |_____|@ + @@ +201 LATIN CAPITAL LETTER E WITH ACUTE + __ @ + _/_/_ @ + | ____|@ + | _|_ @ + |_____|@ + @@ +202 LATIN CAPITAL LETTER E WITH CIRCUMFLEX + //\\ @ + |/_\\| @ + | ____|@ + | _|_ @ + |_____|@ + @@ +203 LATIN CAPITAL LETTER E WITH DIAERESIS + _ _ @ + (_)_(_)@ + | ____|@ + | _|_ @ + |_____|@ + @@ +204 LATIN CAPITAL LETTER I WITH GRAVE + __ @ + \\_\\ @ + |_ _|@ + | | @ + |___|@ + @@ +205 LATIN CAPITAL LETTER I WITH ACUTE + __ @ + /_/ @ + |_ _|@ + | | @ + |___|@ + @@ +206 LATIN CAPITAL LETTER I WITH CIRCUMFLEX + //\\ @ + |/_\\|@ + |_ _|@ + | | @ + |___|@ + @@ +207 LATIN CAPITAL LETTER I WITH DIAERESIS + _ _ @ + (_)_(_)@ + |_ _| @ + | | @ + |___| @ + @@ +208 LATIN CAPITAL LETTER ETH + ____ @ + | _ \\ @ + _| |_| |@ + |__ __| |@ + |____/ @ + @@ +209 LATIN CAPITAL LETTER N WITH TILDE + /\\/|@ + |/\\/ @ + | \\| |@ + | .\` |@ + |_|\\_|@ + @@ +210 LATIN CAPITAL LETTER O WITH GRAVE + __ @ + \\_\\ @ + / _ \\ @ + | |_| |@ + \\___/ @ + @@ +211 LATIN CAPITAL LETTER O WITH ACUTE + __ @ + /_/ @ + / _ \\ @ + | |_| |@ + \\___/ @ + @@ +212 LATIN CAPITAL LETTER O WITH CIRCUMFLEX + //\\ @ + |/_\\| @ + / _ \\ @ + | |_| |@ + \\___/ @ + @@ +213 LATIN CAPITAL LETTER O WITH TILDE + /\\/| @ + |/\\/ @ + / _ \\ @ + | |_| |@ + \\___/ @ + @@ +214 LATIN CAPITAL LETTER O WITH DIAERESIS + _ _ @ + (_)_(_)@ + / _ \\ @ + | |_| |@ + \\___/ @ + @@ +215 MULTIPLICATION SIGN + @ + @ + /\\/\\@ + > <@ + \\/\\/@ + @@ +216 LATIN CAPITAL LETTER O WITH STROKE + ____ @ + / _// @ + | |// |@ + | //| |@ + //__/ @ + @@ +217 LATIN CAPITAL LETTER U WITH GRAVE + __ @ + _\\_\\_ @ + | | | |@ + | |_| |@ + \\___/ @ + @@ +218 LATIN CAPITAL LETTER U WITH ACUTE + __ @ + _/_/_ @ + | | | |@ + | |_| |@ + \\___/ @ + @@ +219 LATIN CAPITAL LETTER U WITH CIRCUMFLEX + //\\ @ + |/ \\| @ + | | | |@ + | |_| |@ + \\___/ @ + @@ +220 LATIN CAPITAL LETTER U WITH DIAERESIS + _ _ @ + (_) (_)@ + | | | |@ + | |_| |@ + \\___/ @ + @@ +221 LATIN CAPITAL LETTER Y WITH ACUTE + __ @ + __/_/__@ + \\ \\ / /@ + \\ V / @ + |_| @ + @@ +222 LATIN CAPITAL LETTER THORN + _ @ + | |___ @ + | __ \\@ + | ___/@ + |_| @ + @@ +223 LATIN SMALL LETTER SHARP S + ___ @ + / _ \\@ + | |/ /@ + | |\\ \\@ + | ||_/@ + |_| @@ +224 LATIN SMALL LETTER A WITH GRAVE + __ @ + \\_\\_ @ + / _\` |@ + | (_| |@ + \\__,_|@ + @@ +225 LATIN SMALL LETTER A WITH ACUTE + __ @ + /_/_ @ + / _\` |@ + | (_| |@ + \\__,_|@ + @@ +226 LATIN SMALL LETTER A WITH CIRCUMFLEX + //\\ @ + |/_\\| @ + / _\` |@ + | (_| |@ + \\__,_|@ + @@ +227 LATIN SMALL LETTER A WITH TILDE + /\\/| @ + |/\\/_ @ + / _\` |@ + | (_| |@ + \\__,_|@ + @@ +228 LATIN SMALL LETTER A WITH DIAERESIS + _ _ @ + (_)_(_)@ + / _\` |@ + | (_| |@ + \\__,_|@ + @@ +229 LATIN SMALL LETTER A WITH RING ABOVE + __ @ + (()) @ + / _ '|@ + | (_| |@ + \\__,_|@ + @@ +230 LATIN SMALL LETTER AE + @ + __ ____ @ + / _\` _ \\@ + | (_| __/@ + \\__,____|@ + @@ +231 LATIN SMALL LETTER C WITH CEDILLA + @ + ___ @ + / __|@ + | (__ @ + \\___|@ + )_) @@ +232 LATIN SMALL LETTER E WITH GRAVE + __ @ + \\_\\ @ + / _ \\@ + | __/@ + \\___|@ + @@ +233 LATIN SMALL LETTER E WITH ACUTE + __ @ + /_/ @ + / _ \\@ + | __/@ + \\___|@ + @@ +234 LATIN SMALL LETTER E WITH CIRCUMFLEX + //\\ @ + |/_\\|@ + / _ \\@ + | __/@ + \\___|@ + @@ +235 LATIN SMALL LETTER E WITH DIAERESIS + _ _ @ + (_)_(_)@ + / _ \\ @ + | __/ @ + \\___| @ + @@ +236 LATIN SMALL LETTER I WITH GRAVE + __ @ + \\_\\@ + | |@ + | |@ + |_|@ + @@ +237 LATIN SMALL LETTER I WITH ACUTE + __@ + /_/@ + | |@ + | |@ + |_|@ + @@ +238 LATIN SMALL LETTER I WITH CIRCUMFLEX + //\\ @ + |/_\\|@ + | | @ + | | @ + |_| @ + @@ +239 LATIN SMALL LETTER I WITH DIAERESIS + _ _ @ + (_)_(_)@ + | | @ + | | @ + |_| @ + @@ +240 LATIN SMALL LETTER ETH + /\\/\\ @ + > < @ + _\\/\\ |@ + / __\` |@ + \\____/ @ + @@ +241 LATIN SMALL LETTER N WITH TILDE + /\\/| @ + |/\\/ @ + | '_ \\ @ + | | | |@ + |_| |_|@ + @@ +242 LATIN SMALL LETTER O WITH GRAVE + __ @ + \\_\\ @ + / _ \\ @ + | (_) |@ + \\___/ @ + @@ +243 LATIN SMALL LETTER O WITH ACUTE + __ @ + /_/ @ + / _ \\ @ + | (_) |@ + \\___/ @ + @@ +244 LATIN SMALL LETTER O WITH CIRCUMFLEX + //\\ @ + |/_\\| @ + / _ \\ @ + | (_) |@ + \\___/ @ + @@ +245 LATIN SMALL LETTER O WITH TILDE + /\\/| @ + |/\\/ @ + / _ \\ @ + | (_) |@ + \\___/ @ + @@ +246 LATIN SMALL LETTER O WITH DIAERESIS + _ _ @ + (_)_(_)@ + / _ \\ @ + | (_) |@ + \\___/ @ + @@ +247 DIVISION SIGN + @ + _ @ + _(_)_ @ + |_____|@ + (_) @ + @@ +248 LATIN SMALL LETTER O WITH STROKE + @ + ____ @ + / _//\\ @ + | (//) |@ + \\//__/ @ + @@ +249 LATIN SMALL LETTER U WITH GRAVE + __ @ + _\\_\\_ @ + | | | |@ + | |_| |@ + \\__,_|@ + @@ +250 LATIN SMALL LETTER U WITH ACUTE + __ @ + _/_/_ @ + | | | |@ + | |_| |@ + \\__,_|@ + @@ +251 LATIN SMALL LETTER U WITH CIRCUMFLEX + //\\ @ + |/ \\| @ + | | | |@ + | |_| |@ + \\__,_|@ + @@ +252 LATIN SMALL LETTER U WITH DIAERESIS + _ _ @ + (_) (_)@ + | | | |@ + | |_| |@ + \\__,_|@ + @@ +253 LATIN SMALL LETTER Y WITH ACUTE + __ @ + _/_/_ @ + | | | |@ + | |_| |@ + \\__, |@ + |___/ @@ +254 LATIN SMALL LETTER THORN + _ @ + | |__ @ + | '_ \\ @ + | |_) |@ + | .__/ @ + |_| @@ +255 LATIN SMALL LETTER Y WITH DIAERESIS + _ _ @ + (_) (_)@ + | | | |@ + | |_| |@ + \\__, |@ + |___/ @@ +0x0100 LATIN CAPITAL LETTER A WITH MACRON + ____ @ + /___/ @ + /_\\ @ + / _ \\ @ + /_/ \\_\\@ + @@ +0x0101 LATIN SMALL LETTER A WITH MACRON + ___ @ + /_ _/@ + / _\` |@ + | (_| |@ + \\__,_|@ + @@ +0x0102 LATIN CAPITAL LETTER A WITH BREVE + _ _ @ + \\\\_// @ + /_\\ @ + / _ \\ @ + /_/ \\_\\@ + @@ +0x0103 LATIN SMALL LETTER A WITH BREVE + \\_/ @ + ___ @ + / _\` |@ + | (_| |@ + \\__,_|@ + @@ +0x0104 LATIN CAPITAL LETTER A WITH OGONEK + @ + _ @ + /_\\ @ + / _ \\ @ + /_/ \\_\\@ + (_(@@ +0x0105 LATIN SMALL LETTER A WITH OGONEK + @ + __ _ @ + / _\` |@ + | (_| |@ + \\__,_|@ + (_(@@ +0x0106 LATIN CAPITAL LETTER C WITH ACUTE + __ @ + _/_/ @ + / ___|@ + | |___ @ + \\____|@ + @@ +0x0107 LATIN SMALL LETTER C WITH ACUTE + __ @ + /__/@ + / __|@ + | (__ @ + \\___|@ + @@ +0x0108 LATIN CAPITAL LETTER C WITH CIRCUMFLEX + /\\ @ + _//\\\\@ + / ___|@ + | |___ @ + \\____|@ + @@ +0x0109 LATIN SMALL LETTER C WITH CIRCUMFLEX + /\\ @ + /_\\ @ + / __|@ + | (__ @ + \\___|@ + @@ +0x010A LATIN CAPITAL LETTER C WITH DOT ABOVE + [] @ + ____ @ + / ___|@ + | |___ @ + \\____|@ + @@ +0x010B LATIN SMALL LETTER C WITH DOT ABOVE + [] @ + ___ @ + / __|@ + | (__ @ + \\___|@ + @@ +0x010C LATIN CAPITAL LETTER C WITH CARON + \\\\// @ + _\\/_ @ + / ___|@ + | |___ @ + \\____|@ + @@ +0x010D LATIN SMALL LETTER C WITH CARON + \\\\//@ + _\\/ @ + / __|@ + | (__ @ + \\___|@ + @@ +0x010E LATIN CAPITAL LETTER D WITH CARON + \\\\// @ + __\\/ @ + | _ \\ @ + | |_| |@ + |____/ @ + @@ +0x010F LATIN SMALL LETTER D WITH CARON + \\/ _ @ + __| |@ + / _\` |@ + | (_| |@ + \\__,_|@ + @@ +0x0110 LATIN CAPITAL LETTER D WITH STROKE + ____ @ + |_ __ \\ @ + /| |/ | |@ + /|_|/_| |@ + |_____/ @ + @@ +0x0111 LATIN SMALL LETTER D WITH STROKE + ---|@ + __| |@ + / _\` |@ + | (_| |@ + \\__,_|@ + @@ +0x0112 LATIN CAPITAL LETTER E WITH MACRON + ____ @ + /___/ @ + | ____|@ + | _|_ @ + |_____|@ + @@ +0x0113 LATIN SMALL LETTER E WITH MACRON + ____@ + /_ _/@ + / _ \\ @ + | __/ @ + \\___| @ + @@ +0x0114 LATIN CAPITAL LETTER E WITH BREVE + _ _ @ + \\\\_// @ + | ____|@ + | _|_ @ + |_____|@ + @@ +0x0115 LATIN SMALL LETTER E WITH BREVE + \\\\ //@ + -- @ + / _ \\ @ + | __/ @ + \\___| @ + @@ +0x0116 LATIN CAPITAL LETTER E WITH DOT ABOVE + [] @ + _____ @ + | ____|@ + | _|_ @ + |_____|@ + @@ +0x0117 LATIN SMALL LETTER E WITH DOT ABOVE + [] @ + __ @ + / _ \\@ + | __/@ + \\___|@ + @@ +0x0118 LATIN CAPITAL LETTER E WITH OGONEK + @ + _____ @ + | ____|@ + | _|_ @ + |_____|@ + (__(@@ +0x0119 LATIN SMALL LETTER E WITH OGONEK + @ + ___ @ + / _ \\@ + | __/@ + \\___|@ + (_(@@ +0x011A LATIN CAPITAL LETTER E WITH CARON + \\\\// @ + __\\/_ @ + | ____|@ + | _|_ @ + |_____|@ + @@ +0x011B LATIN SMALL LETTER E WITH CARON + \\\\//@ + \\/ @ + / _ \\@ + | __/@ + \\___|@ + @@ +0x011C LATIN CAPITAL LETTER G WITH CIRCUMFLEX + _/\\_ @ + / ___|@ + | | _ @ + | |_| |@ + \\____|@ + @@ +0x011D LATIN SMALL LETTER G WITH CIRCUMFLEX + /\\ @ + _/_ \\@ + / _\` |@ + | (_| |@ + \\__, |@ + |___/ @@ +0x011E LATIN CAPITAL LETTER G WITH BREVE + _\\/_ @ + / ___|@ + | | _ @ + | |_| |@ + \\____|@ + @@ +0x011F LATIN SMALL LETTER G WITH BREVE + \\___/ @ + __ _ @ + / _\` |@ + | (_| |@ + \\__, |@ + |___/ @@ +0x0120 LATIN CAPITAL LETTER G WITH DOT ABOVE + _[]_ @ + / ___|@ + | | _ @ + | |_| |@ + \\____|@ + @@ +0x0121 LATIN SMALL LETTER G WITH DOT ABOVE + [] @ + __ _ @ + / _\` |@ + | (_| |@ + \\__, |@ + |___/ @@ +0x0122 LATIN CAPITAL LETTER G WITH CEDILLA + ____ @ + / ___|@ + | | _ @ + | |_| |@ + \\____|@ + )__) @@ +0x0123 LATIN SMALL LETTER G WITH CEDILLA + @ + __ _ @ + / _\` |@ + | (_| |@ + \\__, |@ + |_))))@@ +0x0124 LATIN CAPITAL LETTER H WITH CIRCUMFLEX + _/ \\_ @ + | / \\ |@ + | |_| |@ + | _ |@ + |_| |_|@ + @@ +0x0125 LATIN SMALL LETTER H WITH CIRCUMFLEX + _ /\\ @ + | |//\\ @ + | '_ \\ @ + | | | |@ + |_| |_|@ + @@ +0x0126 LATIN CAPITAL LETTER H WITH STROKE + _ _ @ + | |=| |@ + | |_| |@ + | _ |@ + |_| |_|@ + @@ +0x0127 LATIN SMALL LETTER H WITH STROKE + _ @ + |=|__ @ + | '_ \\ @ + | | | |@ + |_| |_|@ + @@ +0x0128 LATIN CAPITAL LETTER I WITH TILDE + /\\//@ + |_ _|@ + | | @ + | | @ + |___|@ + @@ +0x0129 LATIN SMALL LETTER I WITH TILDE + @ + /\\/@ + | |@ + | |@ + |_|@ + @@ +0x012A LATIN CAPITAL LETTER I WITH MACRON + /___/@ + |_ _|@ + | | @ + | | @ + |___|@ + @@ +0x012B LATIN SMALL LETTER I WITH MACRON + ____@ + /___/@ + | | @ + | | @ + |_| @ + @@ +0x012C LATIN CAPITAL LETTER I WITH BREVE + \\__/@ + |_ _|@ + | | @ + | | @ + |___|@ + @@ +0x012D LATIN SMALL LETTER I WITH BREVE + @ + \\_/@ + | |@ + | |@ + |_|@ + @@ +0x012E LATIN CAPITAL LETTER I WITH OGONEK + ___ @ + |_ _|@ + | | @ + | | @ + |___|@ + (__(@@ +0x012F LATIN SMALL LETTER I WITH OGONEK + _ @ + (_) @ + | | @ + | | @ + |_|_@ + (_(@@ +0x0130 LATIN CAPITAL LETTER I WITH DOT ABOVE + _[] @ + |_ _|@ + | | @ + | | @ + |___|@ + @@ +0x0131 LATIN SMALL LETTER DOTLESS I + @ + _ @ + | |@ + | |@ + |_|@ + @@ +0x0132 LATIN CAPITAL LIGATURE IJ + ___ _ @ + |_ _|| |@ + | | | |@ + | |_| |@ + |__|__/ @ + @@ +0x0133 LATIN SMALL LIGATURE IJ + _ _ @ + (_) (_)@ + | | | |@ + | | | |@ + |_|_/ |@ + |__/ @@ +0x0134 LATIN CAPITAL LETTER J WITH CIRCUMFLEX + /\\ @ + /_\\|@ + _ | | @ + | |_| | @ + \\___/ @ + @@ +0x0135 LATIN SMALL LETTER J WITH CIRCUMFLEX + /\\@ + /_\\@ + | |@ + | |@ + _/ |@ + |__/ @@ +0x0136 LATIN CAPITAL LETTER K WITH CEDILLA + _ _ @ + | |/ / @ + | ' / @ + | . \\ @ + |_|\\_\\ @ + )__)@@ +0x0137 LATIN SMALL LETTER K WITH CEDILLA + _ @ + | | __@ + | |/ /@ + | < @ + |_|\\_\\@ + )_)@@ +0x0138 LATIN SMALL LETTER KRA + @ + _ __ @ + | |/ \\@ + | < @ + |_|\\_\\@ + @@ +0x0139 LATIN CAPITAL LETTER L WITH ACUTE + _ //@ + | | // @ + | | @ + | |___ @ + |_____|@ + @@ +0x013A LATIN SMALL LETTER L WITH ACUTE + //@ + | |@ + | |@ + | |@ + |_|@ + @@ +0x013B LATIN CAPITAL LETTER L WITH CEDILLA + _ @ + | | @ + | | @ + | |___ @ + |_____|@ + )__)@@ +0x013C LATIN SMALL LETTER L WITH CEDILLA + _ @ + | | @ + | | @ + | | @ + |_| @ + )_)@@ +0x013D LATIN CAPITAL LETTER L WITH CARON + _ \\\\//@ + | | \\/ @ + | | @ + | |___ @ + |_____|@ + @@ +0x013E LATIN SMALL LETTER L WITH CARON + _ \\\\//@ + | | \\/ @ + | | @ + | | @ + |_| @ + @@ +0x013F LATIN CAPITAL LETTER L WITH MIDDLE DOT + _ @ + | | @ + | | [] @ + | |___ @ + |_____|@ + @@ +0x0140 LATIN SMALL LETTER L WITH MIDDLE DOT + _ @ + | | @ + | | []@ + | | @ + |_| @ + @@ +0x0141 LATIN CAPITAL LETTER L WITH STROKE + __ @ + | // @ + |//| @ + // |__ @ + |_____|@ + @@ +0x0142 LATIN SMALL LETTER L WITH STROKE + _ @ + | |@ + |//@ + //|@ + |_|@ + @@ +0x0143 LATIN CAPITAL LETTER N WITH ACUTE + _/ /_ @ + | \\ | |@ + | \\| |@ + | |\\ |@ + |_| \\_|@ + @@ +0x0144 LATIN SMALL LETTER N WITH ACUTE + _ @ + _ /_/ @ + | '_ \\ @ + | | | |@ + |_| |_|@ + @@ +0x0145 LATIN CAPITAL LETTER N WITH CEDILLA + _ _ @ + | \\ | |@ + | \\| |@ + | |\\ |@ + |_| \\_|@ + )_) @@ +0x0146 LATIN SMALL LETTER N WITH CEDILLA + @ + _ __ @ + | '_ \\ @ + | | | |@ + |_| |_|@ + )_) @@ +0x0147 LATIN CAPITAL LETTER N WITH CARON + _\\/ _ @ + | \\ | |@ + | \\| |@ + | |\\ |@ + |_| \\_|@ + @@ +0x0148 LATIN SMALL LETTER N WITH CARON + \\\\// @ + _\\/_ @ + | '_ \\ @ + | | | |@ + |_| |_|@ + @@ +0x0149 LATIN SMALL LETTER N PRECEDED BY APOSTROPHE + @ + _ __ @ + ( )| '_\\ @ + |/| | | |@ + |_| |_|@ + @@ +0x014A LATIN CAPITAL LETTER ENG + _ _ @ + | \\ | |@ + | \\| |@ + | |\\ |@ + |_| \\ |@ + )_)@@ +0x014B LATIN SMALL LETTER ENG + _ __ @ + | '_ \\ @ + | | | |@ + |_| | |@ + | |@ + |__ @@ +0x014C LATIN CAPITAL LETTER O WITH MACRON + ____ @ + /_ _/ @ + / _ \\ @ + | (_) |@ + \\___/ @ + @@ +0x014D LATIN SMALL LETTER O WITH MACRON + ____ @ + /_ _/ @ + / _ \\ @ + | (_) |@ + \\___/ @ + @@ +0x014E LATIN CAPITAL LETTER O WITH BREVE + \\ / @ + _-_ @ + / _ \\ @ + | |_| |@ + \\___/ @ + @@ +0x014F LATIN SMALL LETTER O WITH BREVE + \\ / @ + _-_ @ + / _ \\ @ + | |_| |@ + \\___/ @ + @@ +0x0150 LATIN CAPITAL LETTER O WITH DOUBLE ACUTE + ___ @ + /_/_/@ + / _ \\ @ + | |_| |@ + \\___/ @ + @@ +0x0151 LATIN SMALL LETTER O WITH DOUBLE ACUTE + ___ @ + /_/_/@ + / _ \\ @ + | |_| |@ + \\___/ @ + @@ +0x0152 LATIN CAPITAL LIGATURE OE + ___ ___ @ + / _ \\| __|@ + | | | | | @ + | |_| | |__@ + \\___/|____@ + @@ +0x0153 LATIN SMALL LIGATURE OE + @ + ___ ___ @ + / _ \\ / _ \\@ + | (_) | __/@ + \\___/ \\___|@ + @@ +0x0154 LATIN CAPITAL LETTER R WITH ACUTE + _/_/ @ + | _ \\ @ + | |_) |@ + | _ < @ + |_| \\_\\@ + @@ +0x0155 LATIN SMALL LETTER R WITH ACUTE + __@ + _ /_/@ + | '__|@ + | | @ + |_| @ + @@ +0x0156 LATIN CAPITAL LETTER R WITH CEDILLA + ____ @ + | _ \\ @ + | |_) |@ + | _ < @ + |_| \\_\\@ + )_) @@ +0x0157 LATIN SMALL LETTER R WITH CEDILLA + @ + _ __ @ + | '__|@ + | | @ + |_| @ + )_) @@ +0x0158 LATIN CAPITAL LETTER R WITH CARON + _\\_/ @ + | _ \\ @ + | |_) |@ + | _ < @ + |_| \\_\\@ + @@ +0x0159 LATIN SMALL LETTER R WITH CARON + \\\\// @ + _\\/_ @ + | '__|@ + | | @ + |_| @ + @@ +0x015A LATIN CAPITAL LETTER S WITH ACUTE + _/_/ @ + / ___| @ + \\___ \\ @ + ___) |@ + |____/ @ + @@ +0x015B LATIN SMALL LETTER S WITH ACUTE + __@ + _/_/@ + / __|@ + \\__ \\@ + |___/@ + @@ +0x015C LATIN CAPITAL LETTER S WITH CIRCUMFLEX + _/\\_ @ + / ___| @ + \\___ \\ @ + ___) |@ + |____/ @ + @@ +0x015D LATIN SMALL LETTER S WITH CIRCUMFLEX + @ + /_\\_@ + / __|@ + \\__ \\@ + |___/@ + @@ +0x015E LATIN CAPITAL LETTER S WITH CEDILLA + ____ @ + / ___| @ + \\___ \\ @ + ___) |@ + |____/ @ + )__)@@ +0x015F LATIN SMALL LETTER S WITH CEDILLA + @ + ___ @ + / __|@ + \\__ \\@ + |___/@ + )_)@@ +0x0160 LATIN CAPITAL LETTER S WITH CARON + _\\_/ @ + / ___| @ + \\___ \\ @ + ___) |@ + |____/ @ + @@ +0x0161 LATIN SMALL LETTER S WITH CARON + \\\\//@ + _\\/ @ + / __|@ + \\__ \\@ + |___/@ + @@ +0x0162 LATIN CAPITAL LETTER T WITH CEDILLA + _____ @ + |_ _|@ + | | @ + | | @ + |_| @ + )__)@@ +0x0163 LATIN SMALL LETTER T WITH CEDILLA + _ @ + | |_ @ + | __|@ + | |_ @ + \\__|@ + )_)@@ +0x0164 LATIN CAPITAL LETTER T WITH CARON + _____ @ + |_ _|@ + | | @ + | | @ + |_| @ + @@ +0x0165 LATIN SMALL LETTER T WITH CARON + \\/ @ + | |_ @ + | __|@ + | |_ @ + \\__|@ + @@ +0x0166 LATIN CAPITAL LETTER T WITH STROKE + _____ @ + |_ _|@ + | | @ + -|-|- @ + |_| @ + @@ +0x0167 LATIN SMALL LETTER T WITH STROKE + _ @ + | |_ @ + | __|@ + |-|_ @ + \\__|@ + @@ +0x0168 LATIN CAPITAL LETTER U WITH TILDE + @ + _/\\/_ @ + | | | |@ + | |_| |@ + \\___/ @ + @@ +0x0169 LATIN SMALL LETTER U WITH TILDE + @ + _/\\/_ @ + | | | |@ + | |_| |@ + \\__,_|@ + @@ +0x016A LATIN CAPITAL LETTER U WITH MACRON + ____ @ + /__ _/@ + | | | |@ + | |_| |@ + \\___/ @ + @@ +0x016B LATIN SMALL LETTER U WITH MACRON + ____ @ + / _ /@ + | | | |@ + | |_| |@ + \\__,_|@ + @@ +0x016C LATIN CAPITAL LETTER U WITH BREVE + @ + \\_/_ @ + | | | |@ + | |_| |@ + \\____|@ + @@ +0x016D LATIN SMALL LETTER U WITH BREVE + @ + \\_/_ @ + | | | |@ + | |_| |@ + \\__,_|@ + @@ +0x016E LATIN CAPITAL LETTER U WITH RING ABOVE + O @ + __ _ @ + | | | |@ + | |_| |@ + \\___/ @ + @@ +0x016F LATIN SMALL LETTER U WITH RING ABOVE + O @ + __ __ @ + | | | |@ + | |_| |@ + \\__,_|@ + @@ +0x0170 LATIN CAPITAL LETTER U WITH DOUBLE ACUTE + -- --@ + /_//_/@ + | | | |@ + | |_| |@ + \\___/ @ + @@ +0x0171 LATIN SMALL LETTER U WITH DOUBLE ACUTE + ____@ + _/_/_/@ + | | | |@ + | |_| |@ + \\__,_|@ + @@ +0x0172 LATIN CAPITAL LETTER U WITH OGONEK + _ _ @ + | | | |@ + | | | |@ + | |_| |@ + \\___/ @ + (__(@@ +0x0173 LATIN SMALL LETTER U WITH OGONEK + @ + _ _ @ + | | | |@ + | |_| |@ + \\__,_|@ + (_(@@ +0x0174 LATIN CAPITAL LETTER W WITH CIRCUMFLEX + __ /\\ __@ + \\ \\ //\\\\/ /@ + \\ \\ /\\ / / @ + \\ V V / @ + \\_/\\_/ @ + @@ +0x0175 LATIN SMALL LETTER W WITH CIRCUMFLEX + /\\ @ + __ //\\\\__@ + \\ \\ /\\ / /@ + \\ V V / @ + \\_/\\_/ @ + @@ +0x0176 LATIN CAPITAL LETTER Y WITH CIRCUMFLEX + /\\ @ + __//\\\\ @ + \\ \\ / /@ + \\ V / @ + |_| @ + @@ +0x0177 LATIN SMALL LETTER Y WITH CIRCUMFLEX + /\\ @ + //\\\\ @ + | | | |@ + | |_| |@ + \\__, |@ + |___/ @@ +0x0178 LATIN CAPITAL LETTER Y WITH DIAERESIS + [] []@ + __ _@ + \\ \\ / /@ + \\ V / @ + |_| @ + @@ +0x0179 LATIN CAPITAL LETTER Z WITH ACUTE + __/_/@ + |__ /@ + / / @ + / /_ @ + /____|@ + @@ +0x017A LATIN SMALL LETTER Z WITH ACUTE + _ @ + _/_/@ + |_ /@ + / / @ + /___|@ + @@ +0x017B LATIN CAPITAL LETTER Z WITH DOT ABOVE + __[]_@ + |__ /@ + / / @ + / /_ @ + /____|@ + @@ +0x017C LATIN SMALL LETTER Z WITH DOT ABOVE + [] @ + ____@ + |_ /@ + / / @ + /___|@ + @@ +0x017D LATIN CAPITAL LETTER Z WITH CARON + _\\_/_@ + |__ /@ + / / @ + / /_ @ + /____|@ + @@ +0x017E LATIN SMALL LETTER Z WITH CARON + \\\\//@ + _\\/_@ + |_ /@ + / / @ + /___|@ + @@ +0x017F LATIN SMALL LETTER LONG S + __ @ + / _|@ + |-| | @ + |-| | @ + |_| @ + @@ +0x02C7 CARON + \\\\//@ + \\/ @ + $@ + $@ + $@ + $@@ +0x02D8 BREVE + \\\\_//@ + \\_/ @ + $@ + $@ + $@ + $@@ +0x02D9 DOT ABOVE + []@ + $@ + $@ + $@ + $@ + $@@ +0x02DB OGONEK + $@ + $@ + $@ + $@ + $@ + )_) @@ +0x02DD DOUBLE ACUTE ACCENT + _ _ @ + /_/_/@ + $@ + $@ + $@ + $@@ +0xCA0 KANNADA LETTER TTHA + _____)@ + /_ ___/@ + / _ \\ @ + | (_) | @ + $\\___/$ @ + @@ +`; +export default figletStandard; diff --git a/src/index.ts b/src/index.ts index 35c1865..d96cebf 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,2 @@ -import { ethers } from 'ethers'; - -export const provider = new ethers.JsonRpcProvider('http://localhost:8545'); \ No newline at end of file +export * from './services'; +export * from './typechain'; diff --git a/src/merkleTreeWorker.ts b/src/merkleTreeWorker.ts new file mode 100644 index 0000000..f4e2201 --- /dev/null +++ b/src/merkleTreeWorker.ts @@ -0,0 +1,69 @@ +/* eslint-disable @typescript-eslint/no-explicit-any, prettier/prettier */ +import workerThreads from 'worker_threads'; +import { MerkleTree, Element, TreeEdge, PartialMerkleTree } from '@tornado/fixed-merkle-tree'; +import { mimc, isNode } from './services'; + +interface WorkData { + merkleTreeHeight: number; + edge?: TreeEdge; + elements: Element[]; + zeroElement: string; +} + +async function nodePostWork() { + const { hash: hashFunction } = await mimc.getHash(); + const { merkleTreeHeight, edge, elements, zeroElement } = workerThreads.workerData as WorkData; + + if (edge) { + const merkleTree = new PartialMerkleTree(merkleTreeHeight, edge, elements, { + zeroElement, + hashFunction, + }); + + (workerThreads.parentPort as workerThreads.MessagePort).postMessage(merkleTree.toString()); + return; + } + + const merkleTree = new MerkleTree(merkleTreeHeight, elements, { + zeroElement, + hashFunction, + }); + + (workerThreads.parentPort as workerThreads.MessagePort).postMessage(merkleTree.toString()); +} + +if (isNode && workerThreads) { + nodePostWork(); +} else if (!isNode && typeof addEventListener === 'function' && typeof postMessage === 'function') { + addEventListener('message', async (e: any) => { + let data; + + if (e.data) { + data = e.data; + } else { + data = e; + } + + const { hash: hashFunction } = await mimc.getHash(); + const { merkleTreeHeight, edge, elements, zeroElement } = data as WorkData; + + if (edge) { + const merkleTree = new PartialMerkleTree(merkleTreeHeight, edge, elements, { + zeroElement, + hashFunction, + }); + + postMessage(merkleTree.toString()); + return; + } + + const merkleTree = new MerkleTree(merkleTreeHeight, elements, { + zeroElement, + hashFunction, + }); + + postMessage(merkleTree.toString()); + }); +} else { + throw new Error('This browser / environment doesn\'t support workers!'); +} diff --git a/src/program.ts b/src/program.ts new file mode 100644 index 0000000..e3a78fb --- /dev/null +++ b/src/program.ts @@ -0,0 +1,1751 @@ +import 'dotenv/config'; +import { readFile, writeFile } from 'fs/promises'; +import path from 'path'; +import process from 'process'; +import { createInterface } from 'readline'; +import { Command } from 'commander'; +import Table from 'cli-table3'; +import colors from '@colors/colors'; +import moment from 'moment'; +import { + Tornado__factory, + TornadoRouter__factory, + RelayerRegistry__factory, + Aggregator__factory, + Governance__factory, +} from '@tornado/contracts'; +import { + JsonRpcProvider, + Provider, + TransactionLike, + Wallet, + VoidSigner, + formatEther, + formatUnits, + parseEther, + parseUnits, + ZeroAddress, + MaxUint256, + Transaction, + BigNumberish, +} from 'ethers'; +import type MerkleTree from '@tornado/fixed-merkle-tree'; +import * as packageJson from '../package.json'; +import { + ERC20, + ERC20__factory, + Multicall__factory, + OffchainOracle__factory, + OvmGasPriceOracle__factory, +} from './typechain'; +import { + parseUrl, + parseRelayer, + parseNumber, + parseMnemonic, + parseKey, + parseAddress, + getProviderOptions, + getProviderWithNetId, + getTokenBalances, + TornadoWallet, + TornadoVoidSigner, + tokenBalances, + Deposit, + NodeDepositsService, + DepositsEvents, + WithdrawalsEvents, + Relayer, + RelayerInfo, + RelayerError, + NodeRegistryService, + TornadoFeeOracle, + TokenPriceOracle, + calculateSnarkProof, + NodeEncryptedNotesService, + NodeGovernanceService, + RelayerClient, + MerkleTreeService, + multicall, + Invoice, + fetchData, + fetchDataOptions, + networkConfig, + subdomains, + Config, + enabledChains, + substring, +} from './services'; + +const DEFAULT_GAS_LIMIT = 600_000; +const RELAYER_NETWORK = 1; +const TOKEN_PRICE_ORACLE = '0x0AdDd25a91563696D8567Df78D5A01C9a991F9B8'; + +// Where cached events, trees, circuits, and key is saved +const STATIC_DIR = process.env.CACHE_DIR || path.join(__dirname, '../static'); +const EVENTS_DIR = path.join(STATIC_DIR, './events'); +const TREES_DIR = path.join(STATIC_DIR, './trees'); +const MERKLE_WORKER_PATH = + process.env.DISABLE_MERKLE_WORKER === 'true' ? undefined : path.join(STATIC_DIR, './merkleTreeWorker.js'); + +// Where we should backup notes and save events +const USER_DIR = process.env.USER_DIR || '.'; +const SAVED_DIR = path.join(USER_DIR, './events'); + +const CIRCUIT_PATH = path.join(__dirname, '../static/tornado.json'); +const KEY_PATH = path.join(__dirname, '../static/tornadoProvingKey.bin'); + +interface packageJson { + name: string; + version: string; + description: string; +} + +export type commonProgramOptions = { + rpc?: string; + ethRpc?: string; + graph?: string; + ethGraph?: string; + disableGraph?: boolean; + relayer?: string; + walletWithdrawal?: boolean; + torPort?: number; + token?: string; + viewOnly?: string; + mnemonic?: string; + mnemonicIndex?: number; + privateKey?: string; + nonInteractive?: boolean; + localRpc?: boolean; +}; + +export async function promptConfirmation(nonInteractive?: boolean) { + if (nonInteractive) { + return; + } + + const prompt = createInterface({ input: process.stdin, output: process.stdout }); + const query = 'Confirm? [Y/n]\n'; + + const confirmation = await new Promise((resolve) => prompt.question(query, resolve)); + + if (!confirmation || (confirmation as string).toUpperCase() !== 'Y') { + throw new Error('User canceled'); + } +} + +export async function getIPAddress(fetchDataOptions: fetchDataOptions) { + const htmlIPInfo = await fetchData('https://check.torproject.org', { + ...fetchDataOptions, + method: 'GET', + headers: { + 'Content-Type': 'text/html; charset=utf-8', + }, + }); + const ip = htmlIPInfo.split('Your IP address appears to be: ').pop().split(' { + options = { + rpc: options.rpc || (process.env.RPC_URL ? parseUrl(process.env.RPC_URL) : undefined), + ethRpc: options.ethRpc || (process.env.ETHRPC_URL ? parseUrl(process.env.ETHRPC_URL) : undefined), + graph: options.graph || (process.env.GRAPH_URL ? parseUrl(process.env.GRAPH_URL) : undefined), + ethGraph: options.ethGraph || (process.env.ETHGRAPH_URL ? parseUrl(process.env.ETHGRAPH_URL) : undefined), + disableGraph: Boolean(options.disableGraph) || (process.env.DISABLE_GRAPH === 'true' ? true : undefined), + relayer: options.relayer || (process.env.RELAYER ? parseRelayer(process.env.RELAYER) : undefined), + walletWithdrawal: + Boolean(options.walletWithdrawal) || (process.env.WALLET_WITHDRAWAL === 'true' ? true : undefined), + torPort: options.torPort || (process.env.TOR_PORT ? parseNumber(process.env.TOR_PORT) : undefined), + token: options.token || (process.env.TOKEN ? parseAddress(process.env.TOKEN) : undefined), + viewOnly: options.viewOnly || (process.env.VIEW_ONLY ? parseAddress(process.env.VIEW_ONLY) : undefined), + mnemonic: options.mnemonic || (process.env.MNEMONIC ? parseMnemonic(process.env.MNEMONIC) : undefined), + mnemonicIndex: + options.mnemonicIndex || (process.env.MNEMONIC_INDEX ? parseNumber(process.env.MNEMONIC_INDEX) : undefined), + privateKey: options.privateKey || (process.env.PRIVATE_KEY ? parseKey(process.env.PRIVATE_KEY) : undefined), + nonInteractive: Boolean(options.nonInteractive) || (process.env.NON_INTERACTIVE === 'true' ? true : undefined), + localRpc: Boolean(options.localRpc) || (process.env.LOCAL_RPC === 'true' ? true : undefined), + }; + + const fetchDataOptions = { + torPort: options.torPort, + }; + + const { ip, isTor } = await getIPAddress(fetchDataOptions); + + const optionsTable = new Table(); + + optionsTable.push( + [{ colSpan: 2, content: 'Program Options', hAlign: 'center' }], + ['IP', ip], + ['Is Tor', isTor], + ...(Object.keys(options) + .map((key) => { + const value = (options as unknown as { [key in string]: string })[key]; + + if (typeof value !== 'undefined') { + return [key, (options as unknown as { [key in string]: string })[key]]; + } + }) + .filter((r) => r) as string[][]), + ); + + console.log('\n' + optionsTable.toString() + '\n'); + + await promptConfirmation(options.nonInteractive); + + return { + options, + fetchDataOptions, + }; +} + +export function getProgramGraphAPI(options: commonProgramOptions, config: Config): string { + if (options.disableGraph) { + return ''; + } + + if (!options.graph) { + return Object.values(config.subgraphs)[0].url; + } + + return options.graph; +} + +export function getProgramProvider( + netId: BigNumberish, + rpcUrl: string = '', + config: Config, + providerOptions?: getProviderOptions, +): JsonRpcProvider { + if (!rpcUrl) { + rpcUrl = Object.values(config.rpcUrls)[0].url; + } + + return getProviderWithNetId(netId, rpcUrl, config, providerOptions); +} + +export function getProgramSigner({ + options, + provider, +}: { + options: commonProgramOptions; + provider: Provider; +}): TornadoVoidSigner | TornadoWallet | undefined { + if (options.viewOnly) { + return new TornadoVoidSigner(options.viewOnly, provider); + } + + if (options.privateKey) { + return new TornadoWallet(options.privateKey, provider); + } + + if (options.mnemonic) { + return TornadoWallet.fromMnemonic(options.mnemonic, provider, options.mnemonicIndex); + } +} + +export async function getProgramRelayer({ + options, + fetchDataOptions, + netId, +}: { + options: commonProgramOptions; + fetchDataOptions?: fetchDataOptions; + netId: number | string; +}): Promise<{ + validRelayers?: RelayerInfo[] | Relayer[]; + invalidRelayers?: RelayerError[]; + relayerClient?: RelayerClient; +}> { + const { ethRpc, ethGraph, relayer, disableGraph } = options; + + const netConfig = networkConfig[`netId${netId}`]; + + const ethConfig = networkConfig[`netId${RELAYER_NETWORK}`]; + + const { + aggregatorContract, + registryContract, + registrySubgraph, + constants: { REGISTRY_BLOCK }, + } = ethConfig; + + const provider = getProgramProvider(1, ethRpc, ethConfig, { + ...fetchDataOptions, + }); + + const graphApi = getProgramGraphAPI( + { + disableGraph, + graph: ethGraph, + }, + ethConfig, + ); + + const registryService = new NodeRegistryService({ + netId: RELAYER_NETWORK, + provider, + graphApi, + subgraphName: registrySubgraph, + RelayerRegistry: RelayerRegistry__factory.connect(registryContract as string, provider), + deployedBlock: REGISTRY_BLOCK, + fetchDataOptions, + cacheDirectory: EVENTS_DIR, + userDirectory: SAVED_DIR, + }); + + const relayerClient = new RelayerClient({ + netId, + config: netConfig, + Aggregator: Aggregator__factory.connect(aggregatorContract as string, provider), + fetchDataOptions, + }); + + if (relayer) { + if (!relayer.endsWith('.eth')) { + const relayerStatus = await relayerClient.askRelayerStatus({ + hostname: new URL(relayer).hostname, + }); + + if (relayerStatus) { + relayerClient.selectedRelayer = { + netId: relayerStatus.netId, + url: relayerStatus.url, + rewardAccount: relayerStatus.rewardAccount, + currentQueue: relayerStatus.currentQueue, + tornadoServiceFee: relayerStatus.tornadoServiceFee, + }; + } + + return { + validRelayers: relayerClient.selectedRelayer ? [relayerClient.selectedRelayer] : [], + invalidRelayers: [], + relayerClient, + }; + } else { + const { validRelayers } = await relayerClient.getValidRelayers([{ ensName: relayer }], subdomains, true); + const relayerStatus = validRelayers[0]; + + if (relayerStatus) { + relayerClient.selectedRelayer = { + netId: relayerStatus.netId, + url: relayerStatus.url, + rewardAccount: relayerStatus.rewardAccount, + currentQueue: relayerStatus.currentQueue, + tornadoServiceFee: relayerStatus.tornadoServiceFee, + }; + } + + return { + validRelayers, + invalidRelayers: [], + relayerClient, + }; + } + } + + console.log('\nGetting valid relayers from registry, would take some time\n'); + + const { validRelayers, invalidRelayers } = await relayerClient.getValidRelayers( + await registryService.fetchRelayers(), + subdomains, + ); + + const relayerStatus = relayerClient.pickWeightedRandomRelayer(validRelayers); + + if (relayerStatus) { + relayerClient.selectedRelayer = { + netId: relayerStatus.netId, + url: relayerStatus.url, + rewardAccount: relayerStatus.rewardAccount, + currentQueue: relayerStatus.currentQueue, + tornadoServiceFee: relayerStatus.tornadoServiceFee, + }; + } + + return { + validRelayers, + invalidRelayers, + relayerClient, + }; +} + +export async function programSendTransaction({ + signer, + options, + populatedTransaction, +}: { + signer: VoidSigner | Wallet; + options: commonProgramOptions; + populatedTransaction: TransactionLike; +}) { + const txTable = new Table(); + // ethers.js doesn't output complete transaction from the contract runner so we populate the transaction once again + const txObject = + !populatedTransaction.gasLimit || !populatedTransaction.from + ? (JSON.parse(JSON.stringify(await signer.populateTransaction(populatedTransaction))) as TransactionLike) + : (JSON.parse(JSON.stringify(populatedTransaction, null, 2)) as TransactionLike); + + const txKeys = Object.keys(txObject); + const txValues = Object.values(txObject); + const txType = + signer instanceof VoidSigner + ? 'Unsigned Transaction' + : options.localRpc + ? 'Unbroadcasted Transaction' + : 'Send Transaction?'; + + txTable.push( + [{ colSpan: 2, content: txType, hAlign: 'center' }], + ...txKeys.map((key, index) => { + if (key === 'data' && txValues[index]) { + return ['data', substring(txValues[index], 40)]; + } + return [key, txValues[index]]; + }), + ); + + if (txType === 'Unsigned Transaction') { + // delete from field as the Transaction.from method doesn't accept it from unsigned tx + delete txObject.from; + const transaction = Transaction.from(txObject); + console.log('\n' + txTable.toString() + '\n'); + console.log('Sign this transaction:\n'); + console.log(`${transaction.unsignedSerialized}\n`); + return; + } + + if (txType === 'Unbroadcasted Transaction') { + const signedTx = await signer.signTransaction(txObject); + console.log('\n' + txTable.toString() + '\n'); + console.log('Broadcast transaction:\n'); + console.log(`${signedTx}\n`); + return; + } + + console.log('\n' + txTable.toString() + '\n'); + + await promptConfirmation(options.nonInteractive); + + const { hash } = await signer.sendTransaction(txObject); + + console.log(`Sent transaction ${hash}\n`); +} + +export function tornadoProgram() { + const { name, version, description } = packageJson as packageJson; + + const program = new Command(); + + program.name(name).version(version).description(description); + + program + .command('create') + .description('Creates Tornado Cash deposit note and deposit invoice') + .argument('', 'Network Chain ID to connect with (see https://chainlist.org for examples)', parseNumber) + .argument('', 'Currency to deposit on Tornado Cash') + .argument('', 'Amount to deposit on Tornado Cash') + .action(async (netId: string | number, currency: string, amount: string) => { + currency = currency.toLowerCase(); + + const config = networkConfig[`netId${netId}`]; + + const { + routerContract, + nativeCurrency, + tokens: { [currency]: currencyConfig }, + } = config; + + const { + decimals, + tokenAddress, + instanceAddress: { [amount]: instanceAddress }, + } = currencyConfig; + + const isEth = nativeCurrency === currency; + const denomination = parseUnits(amount, decimals); + + const deposit = await Deposit.createNote({ currency, amount, netId }); + + const { noteHex, note, commitmentHex } = deposit; + + const ERC20Interface = ERC20__factory.createInterface(); + const TornadoRouterInterface = TornadoRouter__factory.createInterface(); + + console.log(`New deposit: ${deposit.toString()}\n`); + + await writeFile(`./backup-tornado-${currency}-${amount}-${netId}-${noteHex.slice(0, 10)}.txt`, note, { + encoding: 'utf8', + }); + + const depositData = TornadoRouterInterface.encodeFunctionData('deposit', [instanceAddress, commitmentHex, '0x']); + + if (!isEth) { + const approveData = ERC20Interface.encodeFunctionData('approve', [routerContract, MaxUint256]); + + console.log(`Approve Data: ${JSON.stringify({ to: tokenAddress, data: approveData }, null, 2)}]\n`); + + console.log(`Transaction Data: ${JSON.stringify({ to: routerContract, data: depositData }, null, 2)}\n`); + } else { + console.log( + `Transaction Data: ${JSON.stringify({ to: routerContract, value: denomination.toString(), data: depositData }, null, 2)}`, + ); + } + + process.exit(0); + }); + + program + .command('deposit') + .description( + 'Submit a deposit of specified currency and amount from default eth account and return the resulting note. \n\n' + + 'The currency is one of (ETH|DAI|cDAI|USDC|cUSDC|USDT). \n\n' + + 'The amount depends on currency, see config.js file or see Tornado Cash UI.', + ) + .argument('', 'Network Chain ID to connect with (see https://chainlist.org for examples)', parseNumber) + .argument('', 'Currency to deposit on Tornado Cash') + .argument('', 'Amount to deposit on Tornado Cash') + .action(async (netId: string | number, currency: string, amount: string, cmdOptions: commonProgramOptions) => { + const { options, fetchDataOptions } = await getProgramOptions(cmdOptions); + currency = currency.toLowerCase(); + const { rpc } = options; + + const config = networkConfig[`netId${netId}`]; + + const { + multicall: multicallAddress, + routerContract, + nativeCurrency, + tokens: { [currency]: currencyConfig }, + } = config; + + const { + decimals, + tokenAddress, + instanceAddress: { [amount]: instanceAddress }, + } = currencyConfig; + + const isEth = nativeCurrency === currency; + const denomination = parseUnits(amount, decimals); + + const provider = getProgramProvider(netId, rpc, config, { + ...fetchDataOptions, + }); + + const signer = getProgramSigner({ + options, + provider, + }); + + if (!signer) { + throw new Error( + 'Signer not defined, make sure you have either viewOnly address, mnemonic, or private key configured', + ); + } + + const TornadoProxy = TornadoRouter__factory.connect(routerContract, signer); + const Multicall = Multicall__factory.connect(multicallAddress, provider); + const Token = tokenAddress ? ERC20__factory.connect(tokenAddress, signer) : undefined; + + const [ethBalance, tokenBalance, tokenApprovals] = await multicall(Multicall, [ + { + contract: Multicall, + name: 'getEthBalance', + params: [signer.address], + }, + /* eslint-disable prettier/prettier */ + ...(!isEth + ? [ + { + contract: Token as ERC20, + name: 'balanceOf', + params: [signer.address], + }, + { + contract: Token as ERC20, + name: 'allowance', + params: [signer.address, routerContract], + }, + ] + : []), + /* eslint-enable prettier/prettier */ + ]); + + if (isEth && denomination > ethBalance) { + const errMsg = `Invalid ${currency.toUpperCase()} balance, wants ${amount} have ${formatUnits(ethBalance, decimals)}`; + throw new Error(errMsg); + } else if (!isEth && denomination > tokenBalance) { + const errMsg = `Invalid ${currency.toUpperCase()} balance, wants ${amount} have ${formatUnits(tokenBalance, decimals)}`; + throw new Error(errMsg); + } + + if (!isEth && denomination > tokenApprovals) { + // token approval + await programSendTransaction({ + signer, + options, + populatedTransaction: await (Token as ERC20).approve.populateTransaction(routerContract, MaxUint256), + }); + + // wait until signer sends the approve transaction offline + if (signer instanceof VoidSigner || options.localRpc) { + console.log( + 'Signer can not sign or broadcast transactions, please send the token approve transaction first and try again.\n', + ); + process.exit(0); + } + } + + const deposit = await Deposit.createNote({ currency, amount, netId }); + + const { note, noteHex, commitmentHex } = deposit; + + console.log(`New deposit: ${deposit.toString()}\n`); + + await writeFile(`./backup-tornado-${currency}-${amount}-${netId}-${noteHex.slice(0, 10)}.txt`, note, { + encoding: 'utf8', + }); + + await programSendTransaction({ + signer, + options, + populatedTransaction: await TornadoProxy.deposit.populateTransaction(instanceAddress, commitmentHex, '0x', { + value: isEth ? denomination : BigInt(0), + }), + }); + + process.exit(0); + }); + + program + .command('depositInvoice') + .description( + 'Submit a deposit of tornado invoice from default eth account and return the resulting note. \n\n' + + 'Useful to deposit on online computer without exposing note', + ) + .argument('', 'Tornado Cash Invoice generated from create command') + .action(async (invoiceString: string, cmdOptions: commonProgramOptions) => { + const { options, fetchDataOptions } = await getProgramOptions(cmdOptions); + const { rpc } = options; + + const { currency, amount, netId, commitment } = new Invoice(invoiceString); + + const config = networkConfig[`netId${netId}`]; + + const { + multicall: multicallAddress, + routerContract, + nativeCurrency, + tokens: { [currency]: currencyConfig }, + } = config; + + const { + decimals, + tokenAddress, + instanceAddress: { [amount]: instanceAddress }, + } = currencyConfig; + + const isEth = nativeCurrency === currency; + const denomination = parseUnits(amount, decimals); + + const provider = getProgramProvider(netId, rpc, config, { + ...fetchDataOptions, + }); + + const signer = getProgramSigner({ + options, + provider, + }); + + if (!signer) { + throw new Error( + 'Signer not defined, make sure you have either viewOnly address, mnemonic, or private key configured', + ); + } + + const TornadoProxy = TornadoRouter__factory.connect(routerContract, signer); + const Multicall = Multicall__factory.connect(multicallAddress, provider); + const Token = tokenAddress ? ERC20__factory.connect(tokenAddress, signer) : undefined; + + const [ethBalance, tokenBalance, tokenApprovals] = await multicall(Multicall, [ + { + contract: Multicall, + name: 'getEthBalance', + params: [signer.address], + }, + /* eslint-disable prettier/prettier */ + ...(!isEth + ? [ + { + contract: Token as ERC20, + name: 'balanceOf', + params: [signer.address], + }, + { + contract: Token as ERC20, + name: 'allowance', + params: [signer.address, routerContract], + }, + ] + : []), + /* eslint-enable prettier/prettier */ + ]); + + if (isEth && denomination > ethBalance) { + const errMsg = `Invalid ${currency.toUpperCase()} balance, wants ${amount} have ${formatUnits(ethBalance, decimals)}`; + throw new Error(errMsg); + } else if (!isEth && denomination > tokenBalance) { + const errMsg = `Invalid ${currency.toUpperCase()} balance, wants ${amount} have ${formatUnits(tokenBalance, decimals)}`; + throw new Error(errMsg); + } + + if (!isEth && denomination > tokenApprovals) { + // token approval + await programSendTransaction({ + signer, + options, + populatedTransaction: await (Token as ERC20).approve.populateTransaction(routerContract, MaxUint256), + }); + + // wait until signer sends the approve transaction offline + if (signer instanceof VoidSigner) { + console.log( + 'Signer can not sign transactions, please send the token approve transaction first and try again.\n', + ); + process.exit(0); + } + } + + await programSendTransaction({ + signer, + options, + populatedTransaction: await TornadoProxy.deposit.populateTransaction(instanceAddress, commitment, '0x', { + value: isEth ? denomination : BigInt(0), + }), + }); + + process.exit(0); + }); + + program + .command('withdraw') + .description( + 'Withdraw a note to a recipient account using relayer or specified private key. \n\n' + + 'You can exchange some of your deposit`s tokens to ETH during the withdrawal by ' + + 'specifing ETH_purchase (e.g. 0.01) to pay for gas in future transactions. \n\n' + + 'Also see the --relayer option.\n\n', + ) + .argument('', 'Tornado Cash Deposit Note') + .argument('', 'Recipient to receive withdrawn amount', parseAddress) + .argument('[ETH_purchase]', 'ETH to purchase', parseNumber) + .action( + async (note: string, recipient: string, ethPurchase: number | undefined, cmdOptions: commonProgramOptions) => { + const { options, fetchDataOptions } = await getProgramOptions(cmdOptions); + const { rpc, walletWithdrawal } = options; + + const deposit = await Deposit.parseNote(note); + + const { netId, currency, amount, commitmentHex, nullifierHex, nullifier, secret } = deposit; + + const config = networkConfig[`netId${netId}`]; + + const { + tornadoSubgraph, + deployedBlock, + nativeCurrency, + routerContract, + multicall: multicallAddress, + ovmGasPriceOracleContract, + tokens: { [currency]: currencyConfig }, + } = config; + + const { + decimals, + tokenAddress, + gasLimit: instanceGasLimit, + tokenGasLimit, + instanceAddress: { [amount]: instanceAddress }, + } = currencyConfig; + + const isEth = nativeCurrency === currency; + const denomination = parseUnits(amount, decimals); + const firstAmount = Object.keys(currencyConfig.instanceAddress).sort((a, b) => Number(a) - Number(b))[0]; + const isFirstAmount = Number(amount) === Number(firstAmount); + + const provider = getProgramProvider(netId, rpc, config, { + ...fetchDataOptions, + }); + + const signer = getProgramSigner({ + options, + provider, + }); + const noSigner = Boolean(!signer || signer instanceof VoidSigner); + + if (walletWithdrawal && noSigner) { + throw new Error('Wallet withdrawal is configured however could not find any wallets'); + } + + const graphApi = getProgramGraphAPI(options, config); + + const Tornado = Tornado__factory.connect(instanceAddress, provider); + const TornadoProxy = TornadoRouter__factory.connect(routerContract, !walletWithdrawal ? provider : signer); + const Multicall = Multicall__factory.connect(multicallAddress, provider); + + const tornadoFeeOracle = new TornadoFeeOracle( + ovmGasPriceOracleContract + ? OvmGasPriceOracle__factory.connect(ovmGasPriceOracleContract, provider) + : undefined, + ); + const tokenPriceOracle = new TokenPriceOracle( + provider, + Multicall, + OffchainOracle__factory.connect(TOKEN_PRICE_ORACLE, provider), + ); + + const depositsServiceConstructor = { + netId, + provider, + graphApi, + subgraphName: tornadoSubgraph, + Tornado, + amount, + currency, + deployedBlock, + fetchDataOptions, + cacheDirectory: EVENTS_DIR, + userDirectory: SAVED_DIR, + }; + + const depositsService = new NodeDepositsService({ + ...depositsServiceConstructor, + type: 'Deposit', + }); + + const withdrawalsService = new NodeDepositsService({ + ...depositsServiceConstructor, + type: 'Withdrawal', + }); + + const merkleTreeService = new MerkleTreeService({ + netId, + amount, + currency, + Tornado, + merkleWorkerPath: MERKLE_WORKER_PATH, + }); + + const depositEvents = (await depositsService.updateEvents()).events as DepositsEvents[]; + + // If we have MERKLE_WORKER_PATH run worker at background otherwise resolve it here + const depositTreeInitiator = await (async () => { + if (MERKLE_WORKER_PATH) { + return () => merkleTreeService.verifyTree({ events: depositEvents }) as Promise; + } + return (await merkleTreeService.verifyTree({ events: depositEvents })) as MerkleTree; + })(); + + let depositTreePromise: Promise | MerkleTree; + + if (typeof depositTreeInitiator === 'function') { + depositTreePromise = depositTreeInitiator(); + } else { + depositTreePromise = depositTreeInitiator; + } + + const withdrawalEvents = (await withdrawalsService.updateEvents()).events as WithdrawalsEvents[]; + + const depositEvent = depositEvents.find(({ commitment }) => commitment === commitmentHex); + + const withdrawalEvent = withdrawalEvents.find(({ nullifierHash }) => nullifierHash === nullifierHex); + + if (!depositEvent) { + throw new Error('Deposit not found'); + } + + const complianceTable = new Table(); + + const depositDate = new Date(depositEvent.timestamp * 1000); + + complianceTable.push( + [{ colSpan: 2, content: 'Deposit', hAlign: 'center' }], + ['Deposit', `${amount} ${currency.toUpperCase()}`], + [ + 'Date', + `${depositDate.toLocaleDateString()} ${depositDate.toLocaleTimeString()} (${moment.unix(depositEvent.timestamp).fromNow()})`, + ], + ['From', depositEvent.from], + ['Transaction', depositEvent.transactionHash], + ['Commitment', commitmentHex], + ['Spent', Boolean(withdrawalEvent)], + ); + + if (withdrawalEvent) { + const withdrawalDate = new Date(withdrawalEvent.timestamp * 1000); + + complianceTable.push( + [{ colSpan: 2, content: 'Withdraw', hAlign: 'center' }], + ['Withdrawal', `${amount} ${currency.toUpperCase()}`], + ['Relayer Fee', `${formatUnits(withdrawalEvent.fee, decimals)} ${currency.toUpperCase()}`], + [ + 'Date', + `${withdrawalDate.toLocaleDateString()} ${withdrawalDate.toLocaleTimeString()} (${moment.unix(withdrawalEvent.timestamp).fromNow()})`, + ], + ['To', withdrawalEvent.to], + ['Transaction', withdrawalEvent.transactionHash], + ['Nullifier', nullifierHex], + ); + } + + console.log('\n\n' + complianceTable.toString() + '\n'); + + if (withdrawalEvent) { + throw new Error('Note is already spent'); + } + + const [circuit, provingKey, tree, relayerClient, l1Fee, tokenPriceInWei, feeData] = await Promise.all([ + readFile(CIRCUIT_PATH, { encoding: 'utf8' }).then((s) => JSON.parse(s)), + readFile(KEY_PATH).then((b) => new Uint8Array(b).buffer), + depositTreePromise, + /* eslint-disable prettier/prettier */ + !walletWithdrawal + ? getProgramRelayer({ + options, + fetchDataOptions, + netId, + }).then(({ relayerClient }) => relayerClient) + : undefined, + /* eslint-enable prettier/prettier */ + tornadoFeeOracle.fetchL1OptimismFee(), + !isEth ? tokenPriceOracle.fetchPrices([tokenAddress as string]).then((p) => p[0]) : BigInt(0), + provider.getFeeData(), + ]); + + if (!walletWithdrawal && !relayerClient?.selectedRelayer) { + throw new Error( + 'No valid relayer found for the network, you can either try again, or find any relayers using the relayers command and set with --relayer option', + ); + } + + const { url, rewardAccount, tornadoServiceFee } = relayerClient?.selectedRelayer || {}; + + let gasPrice: bigint = feeData.maxFeePerGas + ? feeData.maxFeePerGas + (feeData.maxPriorityFeePerGas || BigInt(0)) + : (feeData.gasPrice as bigint); + + if (netId === 56 && gasPrice < parseUnits('3.3', 'gwei')) { + gasPrice = parseUnits('3.3', 'gwei'); + } + + // If the config overrides default gas limit we override + const defaultGasLimit = instanceGasLimit ? BigInt(instanceGasLimit) : BigInt(DEFAULT_GAS_LIMIT); + let gasLimit = defaultGasLimit; + + // If the denomination is small only refund small amount otherwise use the default value + const refundGasLimit = isFirstAmount && tokenGasLimit ? BigInt(tokenGasLimit) : undefined; + + const ethRefund = ethPurchase + ? parseEther(`${ethPurchase}`) + : !isEth + ? tornadoFeeOracle.defaultEthRefund(gasPrice, refundGasLimit) + : BigInt(0); + + if (isEth && ethRefund) { + throw new Error('Can not purchase native assets on native asset withdrawal'); + } + + async function getProof() { + let relayerFee = BigInt(0); + + if (!walletWithdrawal) { + relayerFee = tornadoFeeOracle.calculateRelayerFee({ + gasPrice, + gasLimit, + l1Fee, + denomination, + ethRefund, + tokenPriceInWei, + tokenDecimals: decimals, + relayerFeePercent: tornadoServiceFee, + isEth, + }); + + if (relayerFee > denomination) { + const errMsg = + `Relayer fee ${formatUnits(relayerFee, decimals)} ${currency.toUpperCase()} ` + + `exceeds the deposit amount ${amount} ${currency.toUpperCase()}.`; + throw new Error(errMsg); + } + } + + const { pathElements, pathIndices } = tree.path((depositEvent as DepositsEvents).leafIndex); + + const { proof, args } = await calculateSnarkProof( + { + root: tree.root, + nullifierHex, + recipient, + relayer: !walletWithdrawal ? (rewardAccount as string) : ZeroAddress, + fee: relayerFee, + refund: ethRefund, + nullifier, + secret, + pathElements, + pathIndices, + }, + circuit, + provingKey, + ); + + return { + relayerFee, + proof, + args, + }; + } + + let { relayerFee, proof, args } = await getProof(); + + const withdrawOverrides = { + from: !walletWithdrawal ? (rewardAccount as string) : (signer?.address as string), + value: args[5] || 0, + }; + + gasLimit = await TornadoProxy.withdraw.estimateGas(instanceAddress, proof, ...args, withdrawOverrides); + + if (gasLimit > defaultGasLimit) { + ({ relayerFee, proof, args } = await getProof()); + gasLimit = await TornadoProxy.withdraw.estimateGas(instanceAddress, proof, ...args, withdrawOverrides); + } + + const withdrawTable = new Table(); + withdrawTable.push([{ colSpan: 2, content: 'Withdrawal Info', hAlign: 'center' }]); + + // withdraw using relayer + if (!walletWithdrawal && relayerClient) { + withdrawTable.push( + [{ colSpan: 2, content: 'Withdraw', hAlign: 'center' }], + ['Withdrawal', `${amount} ${currency.toUpperCase()}`], + ['Relayer', `${url}`], + [ + 'Relayer Fee', + `${formatUnits(relayerFee, decimals)} ${currency.toUpperCase()} ` + + `(${((Number(relayerFee) / Number(denomination)) * 100).toFixed(5)}%)`, + ], + ['Relayer Fee Percent', `${tornadoServiceFee}%`], + [ + 'Amount to receive', + `${Number(formatUnits(denomination - relayerFee, decimals)).toFixed(5)} ${currency.toUpperCase()}`, + ], + [`${nativeCurrency.toUpperCase()} purchase`, `${formatEther(ethRefund)} ${nativeCurrency.toUpperCase()}`], + ['To', recipient], + ['Nullifier', nullifierHex], + ); + + console.log('\n' + withdrawTable.toString() + '\n'); + + await promptConfirmation(options.nonInteractive); + + console.log('Sending withdraw transaction through relay\n'); + + await relayerClient.tornadoWithdraw({ + contract: instanceAddress, + proof, + args, + }); + } else { + // withdraw from wallet + + const txFee = gasPrice * gasLimit; + const txFeeInToken = !isEth + ? tornadoFeeOracle.calculateTokenAmount(txFee, tokenPriceInWei, decimals) + : BigInt(0); + const txFeeString = !isEth + ? `(${Number(formatUnits(txFeeInToken, decimals)).toFixed(5)} ${currency.toUpperCase()} worth) ` + + `(${((Number(formatUnits(txFeeInToken, decimals)) / Number(amount)) * 100).toFixed(5)}%)` + : `(${((Number(formatUnits(txFee, decimals)) / Number(amount)) * 100).toFixed(5)}%)`; + + withdrawTable.push( + [{ colSpan: 2, content: 'Withdraw', hAlign: 'center' }], + ['Withdrawal', `${amount} ${currency.toUpperCase()}`], + ['From', `${signer?.address}`], + [ + 'Transaction Fee', + `${Number(formatEther(txFee)).toFixed(5)} ${nativeCurrency.toUpperCase()} ` + txFeeString, + ], + ['Amount to receive', `${amount} ${currency.toUpperCase()}`], + ['To', recipient], + ['Nullifier', nullifierHex], + ); + + console.log('\n' + withdrawTable.toString() + '\n'); + + await promptConfirmation(options.nonInteractive); + + console.log('Sending withdraw transaction through wallet\n'); + + await programSendTransaction({ + signer: signer as TornadoVoidSigner | TornadoWallet, + options, + populatedTransaction: await TornadoProxy.withdraw.populateTransaction(instanceAddress, proof, ...args), + }); + } + + process.exit(0); + }, + ); + + program + .command('compliance') + .description( + 'Shows the deposit and withdrawal of the provided note. \n\n' + + 'This might be necessary to show the origin of assets held in your withdrawal address. \n\n', + ) + .argument('', 'Tornado Cash Deposit Note') + .action(async (note: string, cmdOptions: commonProgramOptions) => { + const { options, fetchDataOptions } = await getProgramOptions(cmdOptions); + const { rpc } = options; + + const deposit = await Deposit.parseNote(note); + const { netId, currency, amount, commitmentHex, nullifierHex } = deposit; + + const config = networkConfig[`netId${netId}`]; + + const { + tornadoSubgraph, + deployedBlock, + tokens: { [currency]: currencyConfig }, + } = config; + + const { + decimals, + instanceAddress: { [amount]: instanceAddress }, + } = currencyConfig; + + const provider = getProgramProvider(netId, rpc, config, { + ...fetchDataOptions, + }); + + const graphApi = getProgramGraphAPI(options, config); + + const Tornado = Tornado__factory.connect(instanceAddress, provider); + + const depositsServiceConstructor = { + netId, + provider, + graphApi, + subgraphName: tornadoSubgraph, + Tornado, + amount, + currency, + deployedBlock, + fetchDataOptions, + cacheDirectory: EVENTS_DIR, + userDirectory: SAVED_DIR, + }; + + const depositsService = new NodeDepositsService({ + ...depositsServiceConstructor, + type: 'Deposit', + }); + + const withdrawalsService = new NodeDepositsService({ + ...depositsServiceConstructor, + type: 'Withdrawal', + }); + + const merkleTreeService = new MerkleTreeService({ + netId, + amount, + currency, + Tornado, + merkleWorkerPath: MERKLE_WORKER_PATH, + }); + + const depositEvents = (await depositsService.updateEvents()).events as DepositsEvents[]; + + // If we have MERKLE_WORKER_PATH run worker at background otherwise resolve it here + const depositTreePromise = await (async () => { + if (MERKLE_WORKER_PATH) { + return () => merkleTreeService.verifyTree({ events: depositEvents }) as Promise; + } + return (await merkleTreeService.verifyTree({ events: depositEvents })) as MerkleTree; + })(); + + const [withdrawalEvents] = await Promise.all([ + withdrawalsService.updateEvents().then(({ events }) => events as WithdrawalsEvents[]), + typeof depositTreePromise === 'function' ? depositTreePromise() : depositTreePromise, + ]); + + const depositEvent = depositEvents.find(({ commitment }) => commitment === commitmentHex); + + const withdrawalEvent = withdrawalEvents.find(({ nullifierHash }) => nullifierHash === nullifierHex); + + const complianceTable = new Table(); + complianceTable.push([{ colSpan: 2, content: 'Compliance Info', hAlign: 'center' }]); + + if (!depositEvent) { + complianceTable.push([{ colSpan: 2, content: 'Deposit', hAlign: 'center' }], ['Deposit', 'Not Found']); + } else { + const depositDate = new Date(depositEvent.timestamp * 1000); + + complianceTable.push( + [{ colSpan: 2, content: 'Deposit', hAlign: 'center' }], + ['Deposit', `${amount} ${currency.toUpperCase()}`], + [ + 'Date', + `${depositDate.toLocaleDateString()} ${depositDate.toLocaleTimeString()} (${moment.unix(depositEvent.timestamp).fromNow()})`, + ], + ['From', depositEvent.from], + ['Transaction', depositEvent.transactionHash], + ['Commitment', commitmentHex], + ['Spent', Boolean(withdrawalEvent)], + ); + } + + if (withdrawalEvent) { + const withdrawalDate = new Date(withdrawalEvent.timestamp * 1000); + + complianceTable.push( + [{ colSpan: 2, content: 'Withdraw', hAlign: 'center' }], + ['Withdrawal', `${amount} ${currency.toUpperCase()}`], + ['Relayer Fee', `${formatUnits(withdrawalEvent.fee, decimals)} ${currency.toUpperCase()}`], + [ + 'Date', + `${withdrawalDate.toLocaleDateString()} ${withdrawalDate.toLocaleTimeString()} (${moment.unix(withdrawalEvent.timestamp).fromNow()})`, + ], + ['To', withdrawalEvent.to], + ['Transaction', withdrawalEvent.transactionHash], + ['Nullifier', nullifierHex], + ); + } + + console.log('\n\n' + complianceTable.toString() + '\n'); + + process.exit(0); + }); + + program + .command('syncEvents') + .description('Sync the local cache file of tornado cash events.\n\n') + .argument('[netId]', 'Network Chain ID to connect with (see https://chainlist.org for examples)', parseNumber) + .argument('[currency]', 'Currency to sync events') + .action( + async ( + netIdOpts: number | string | undefined, + currencyOpts: string | undefined, + cmdOptions: commonProgramOptions, + ) => { + const { options, fetchDataOptions } = await getProgramOptions(cmdOptions); + const { rpc } = options; + + const networks = netIdOpts ? [netIdOpts] : enabledChains; + + for (const netId of networks) { + const config = networkConfig[`netId${netId}`]; + const { + tornadoSubgraph, + registrySubgraph, + tokens, + routerContract, + registryContract, + ['governance.contract.tornadocash.eth']: governanceContract, + deployedBlock, + constants: { GOVERNANCE_BLOCK, REGISTRY_BLOCK, ENCRYPTED_NOTES_BLOCK }, + } = config; + + const provider = getProgramProvider(netId, rpc, config, { + ...fetchDataOptions, + }); + const graphApi = getProgramGraphAPI(options, config); + + if (governanceContract) { + const governanceService = new NodeGovernanceService({ + netId, + provider, + // to-do connect governance with subgraph + graphApi: '', + subgraphName: '', + Governance: Governance__factory.connect(governanceContract, provider), + deployedBlock: GOVERNANCE_BLOCK, + fetchDataOptions, + cacheDirectory: EVENTS_DIR, + userDirectory: SAVED_DIR, + }); + + await governanceService.updateEvents(); + } + + if (registryContract) { + const registryService = new NodeRegistryService({ + netId, + provider, + graphApi, + subgraphName: registrySubgraph, + RelayerRegistry: RelayerRegistry__factory.connect(registryContract, provider), + deployedBlock: REGISTRY_BLOCK, + fetchDataOptions, + cacheDirectory: EVENTS_DIR, + userDirectory: SAVED_DIR, + }); + + await registryService.updateEvents(); + } + + const encryptedNotesService = new NodeEncryptedNotesService({ + netId, + provider, + graphApi, + subgraphName: tornadoSubgraph, + Router: TornadoRouter__factory.connect(routerContract, provider), + deployedBlock: ENCRYPTED_NOTES_BLOCK, + fetchDataOptions, + cacheDirectory: EVENTS_DIR, + userDirectory: SAVED_DIR, + }); + + await encryptedNotesService.updateEvents(); + + const currencies = currencyOpts ? [currencyOpts.toLowerCase()] : Object.keys(tokens); + + for (const currency of currencies) { + const currencyConfig = tokens[currency]; + // Now load the denominations and address + const amounts = Object.keys(currencyConfig.instanceAddress); + + // And now sync + for (const amount of amounts) { + const instanceAddress = currencyConfig.instanceAddress[amount]; + const Tornado = Tornado__factory.connect(instanceAddress, provider); + + const depositsServiceConstructor = { + netId, + provider, + graphApi, + subgraphName: tornadoSubgraph, + Tornado, + amount, + currency, + deployedBlock, + fetchDataOptions, + cacheDirectory: EVENTS_DIR, + userDirectory: SAVED_DIR, + }; + + const depositsService = new NodeDepositsService({ + ...depositsServiceConstructor, + type: 'Deposit', + }); + + const withdrawalsService = new NodeDepositsService({ + ...depositsServiceConstructor, + type: 'Withdrawal', + }); + + const merkleTreeService = new MerkleTreeService({ + netId, + amount, + currency, + Tornado, + merkleWorkerPath: MERKLE_WORKER_PATH, + }); + + const depositEvents = (await depositsService.updateEvents()).events; + + // If we have MERKLE_WORKER_PATH run worker at background otherwise resolve it here + const depositTreePromise = await (async () => { + if (MERKLE_WORKER_PATH) { + return () => + merkleTreeService.verifyTree({ events: depositEvents as DepositsEvents[] }) as Promise; + } + return (await merkleTreeService.verifyTree({ + events: depositEvents as DepositsEvents[], + })) as MerkleTree; + })(); + + await Promise.all([ + withdrawalsService.updateEvents(), + typeof depositTreePromise === 'function' ? depositTreePromise() : depositTreePromise, + ]); + } + } + } + + process.exit(0); + }, + ); + + program + .command('relayers') + .description('List all registered relayers from the tornado cash registry.\n\n') + .argument('', 'Network Chain ID to connect with (see https://chainlist.org for examples)', parseNumber) + .action(async (netIdOpts: number | string, cmdOptions: commonProgramOptions) => { + const { options, fetchDataOptions } = await getProgramOptions(cmdOptions); + + const allRelayers = await getProgramRelayer({ + options, + fetchDataOptions, + netId: netIdOpts, + }); + + const validRelayers = allRelayers.validRelayers as RelayerInfo[]; + const invalidRelayers = allRelayers.invalidRelayers as RelayerError[]; + + const relayersTable = new Table(); + + relayersTable.push( + [{ colSpan: 8, content: 'Relayers', hAlign: 'center' }], + [ + 'netId', + 'url', + 'ensName', + 'stakeBalance', + 'relayerAddress', + 'rewardAccount', + 'currentQueue', + 'serviceFee', + ].map((content) => ({ content: colors.red.bold(content) })), + ...validRelayers.map( + ({ netId, url, ensName, stakeBalance, relayerAddress, rewardAccount, currentQueue, tornadoServiceFee }) => { + return [ + netId, + url, + ensName, + stakeBalance ? `${Number(formatEther(stakeBalance)).toFixed(5)} TORN` : '', + relayerAddress, + rewardAccount, + currentQueue, + `${tornadoServiceFee}%`, + ]; + }, + ), + ); + + const invalidRelayersTable = new Table(); + + invalidRelayersTable.push( + [{ colSpan: 3, content: 'Invalid Relayers', hAlign: 'center' }], + ['hostname', 'relayerAddress', 'errorMessage'].map((content) => ({ content: colors.red.bold(content) })), + ...invalidRelayers.map(({ hostname, relayerAddress, errorMessage }) => { + return [hostname, relayerAddress, errorMessage ? substring(errorMessage, 40) : '']; + }), + ); + + console.log(relayersTable.toString() + '\n'); + console.log(invalidRelayersTable.toString() + '\n'); + + process.exit(0); + }); + + program + .command('send') + .description('Send ETH or ERC20 token to address.\n\n') + .argument('', 'Network Chain ID to connect with (see https://chainlist.org for examples)', parseNumber) + .argument('', 'To address', parseAddress) + .argument('[amount]', 'Sending amounts', parseNumber) + .argument('[token]', 'ERC20 Token Contract to check Token Balance', parseAddress) + .action( + async ( + netId: string | number, + to: string, + amountArgs: number | undefined, + tokenArgs: string | undefined, + cmdOptions: commonProgramOptions, + ) => { + const { options, fetchDataOptions } = await getProgramOptions(cmdOptions); + const { rpc, token: tokenOpts } = options; + + const config = networkConfig[`netId${netId}`]; + + const { currencyName, multicall: multicallAddress } = config; + + const provider = getProgramProvider(netId, rpc, config, { + ...fetchDataOptions, + }); + + const signer = getProgramSigner({ options, provider }); + + if (!signer) { + throw new Error( + 'Signer not defined, make sure you have either viewOnly address, mnemonic, or private key configured', + ); + } + + const tokenAddress = tokenArgs ? parseAddress(tokenArgs) : tokenOpts; + + const Multicall = Multicall__factory.connect(multicallAddress, provider); + const Token = (tokenAddress ? ERC20__factory.connect(tokenAddress, signer) : undefined) as ERC20; + + // Fetching feeData or nonce is unnecessary however we do this to estimate transfer amounts + const [feeData, nonce, [{ balance: ethBalance }, tokenResults]] = await Promise.all([ + provider.getFeeData(), + provider.getTransactionCount(signer.address, 'pending'), + getTokenBalances({ + provider, + Multicall, + currencyName, + userAddress: signer.address, + tokenAddresses: tokenAddress ? [tokenAddress] : [], + }), + ]); + + const { + symbol: tokenSymbol, + decimals: tokenDecimals, + balance: tokenBalance, + }: tokenBalances = tokenResults || {}; + + const txType = feeData.maxFeePerGas ? 2 : 0; + const txGasPrice = feeData.maxFeePerGas + ? feeData.maxFeePerGas + (feeData.maxPriorityFeePerGas || BigInt(0)) + : feeData.gasPrice || BigInt(0); + /* eslint-disable prettier/prettier */ + const txFees = feeData.maxFeePerGas + ? { + maxFeePerGas: feeData.maxFeePerGas, + maxPriorityFeePerGas: feeData.maxPriorityFeePerGas, + } + : { + gasPrice: feeData.gasPrice, + }; + + let toSend: bigint; + + if (amountArgs) { + if (tokenAddress) { + toSend = parseUnits(`${amountArgs}`, tokenDecimals); + + if (toSend > tokenBalance) { + const errMsg = `Invalid ${tokenSymbol} balance, wants ${amountArgs} have ${formatUnits(tokenBalance, tokenDecimals)}`; + throw new Error(errMsg); + } + } else { + toSend = parseEther(`${amountArgs}`); + + if (toSend > ethBalance) { + const errMsg = `Invalid ${currencyName} balance, wants ${amountArgs} have ${formatEther(ethBalance)}`; + throw new Error(errMsg); + } + } + } else { + if (tokenAddress) { + toSend = tokenBalance; + } else { + const initCost = txGasPrice * BigInt('400000'); + toSend = ethBalance - initCost; + + const estimatedGas = await provider.estimateGas({ + type: txType, + from: signer.address, + to, + value: toSend, + nonce, + ...txFees, + }); + + const bumpedGas = (estimatedGas !== BigInt(21000) && signer.gasLimitBump + ? (estimatedGas * (BigInt(10000) + BigInt(signer.gasLimitBump))) / BigInt(10000) + : estimatedGas + ); + + toSend = ethBalance - txGasPrice * bumpedGas; + } + } + + await programSendTransaction({ + signer, + options, + populatedTransaction: tokenAddress + ? await Token.transfer.populateTransaction(to, toSend) + : await signer.populateTransaction({ + type: txType, + from: signer.address, + to, + value: toSend, + nonce, + ...txFees, + }), + }); + /* eslint-enable prettier/prettier */ + + process.exit(0); + }, + ); + + program + .command('balance') + .description('Check ETH and ERC20 balance.\n\n') + .argument('', 'Network Chain ID to connect with (see https://chainlist.org for examples)', parseNumber) + .argument('[address]', 'ETH Address to check balance', parseAddress) + .argument('[token]', 'ERC20 Token Contract to check Token Balance', parseAddress) + .action( + async ( + netId: string | number, + addressArgs: string | undefined, + tokenArgs: string | undefined, + cmdOptions: commonProgramOptions, + ) => { + const { options, fetchDataOptions } = await getProgramOptions(cmdOptions); + const { rpc, token: tokenOpts } = options; + + const config = networkConfig[`netId${netId}`]; + + const { + currencyName, + multicall: multicallAddress, + ['torn.contract.tornadocash.eth']: tornTokenAddress, + tokens, + } = config; + + const provider = getProgramProvider(netId, rpc, config, { + ...fetchDataOptions, + }); + + const userAddress = addressArgs ? parseAddress(addressArgs) : getProgramSigner({ options, provider })?.address; + const tokenAddress = tokenArgs ? parseAddress(tokenArgs) : tokenOpts; + + if (!userAddress) { + throw new Error('Address is required however no user address is supplied'); + } + + const Multicall = Multicall__factory.connect(multicallAddress, provider); + + const tokenAddresses = Object.values(tokens) + .map(({ tokenAddress }) => tokenAddress) + .filter((t) => t) as string[]; + + if (tornTokenAddress) { + tokenAddresses.push(tornTokenAddress); + } + + const tokenBalances = await getTokenBalances({ + provider, + Multicall, + currencyName, + userAddress, + tokenAddresses: [...(tokenAddress ? [tokenAddress] : tokenAddresses)], + }); + + const balanceTable = new Table({ head: ['Token', 'Contract Address', 'Balance'] }); + + balanceTable.push( + [{ colSpan: 3, content: `User: ${userAddress}`, hAlign: 'center' }], + ...tokenBalances.map(({ address, name, symbol, decimals, balance }) => { + return [`${name} (${symbol})`, address, `${formatUnits(balance, decimals)} ${symbol}`]; + }), + ); + + console.log(balanceTable.toString()); + + process.exit(0); + }, + ); + + program + .command('sign') + .description('Sign unsigned transaction with signer.\n\n') + .argument('', 'Unsigned Transaction') + .action(async (unsignedTx: string, cmdOptions: commonProgramOptions) => { + const { options, fetchDataOptions } = await getProgramOptions(cmdOptions); + const { rpc } = options; + + const deserializedTx = Transaction.from(unsignedTx).toJSON(); + + const netId = Number(deserializedTx.chainId); + + const config = networkConfig[`netId${netId}`]; + + const provider = getProgramProvider(netId, rpc, config, { + ...fetchDataOptions, + }); + + const signer = getProgramSigner({ options, provider }); + + if (!signer || signer instanceof VoidSigner) { + throw new Error('Signer not defined or not signable signer'); + } + + await programSendTransaction({ + signer, + options, + populatedTransaction: deserializedTx, + }); + /* eslint-enable prettier/prettier */ + + process.exit(0); + }); + + program + .command('broadcast') + .description('Broadcast signed transaction.\n\n') + .argument('', 'Signed Transaction') + .action(async (signedTx: string, cmdOptions: commonProgramOptions) => { + const { options, fetchDataOptions } = await getProgramOptions(cmdOptions); + const { rpc } = options; + + const netId = Number(Transaction.from(signedTx).chainId); + + if (!netId) { + throw new Error('NetId for the transaction is invalid, this command only supports EIP-155 transactions'); + } + + const config = networkConfig[`netId${netId}`]; + + const provider = getProgramProvider(netId, rpc, config, { + ...fetchDataOptions, + }); + + const { hash } = await provider.broadcastTransaction(signedTx); + + console.log(`\nBroadcastd tx: ${hash}\n`); + + process.exit(0); + }); + + // common options + /* eslint-disable prettier/prettier */ + program.commands.forEach((cmd) => { + cmd.option('-r, --rpc ', 'The RPC that CLI should interact with', parseUrl); + cmd.option('-e, --eth-rpc ', 'The Ethereum Mainnet RPC that CLI should interact with', parseUrl); + cmd.option('-g, --graph ', 'The Subgraph API that CLI should interact with', parseUrl); + cmd.option('-G, --eth-graph ', 'The Ethereum Mainnet Subgraph API that CLI should interact with', parseUrl); + cmd.option('-d, --disable-graph', 'Disable Graph API - Does not enable Subgraph API and use only local RPC as an event source'); + cmd.option('-R, --relayer ', 'Withdraw via relayer (Should be either .eth name or URL)', parseRelayer); + cmd.option('-w, --wallet-withdrawal', 'Withdrawal via wallet (Should not be linked with deposits)'); + cmd.option('-T, --tor-port ', 'Optional tor port', parseNumber); + cmd.option('-t, --token ', 'Token Contract address to view token balance', parseAddress); + cmd.option( + '-v, --view-only ', + 'Wallet address to view balance or to create unsigned transactions', + parseAddress, + ); + cmd.option( + '-m, --mnemonic ', + 'Wallet BIP39 Mnemonic Phrase - If you didn\'t add it to .env file and it is needed for operation', + parseMnemonic, + ); + cmd.option('-i, --mnemonic-index ', 'Optional wallet mnemonic index', parseNumber); + cmd.option( + '-p, --private-key ', + 'Wallet private key - If you didn\'t add it to .env file and it is needed for operation', + parseKey, + ); + cmd.option( + '-n, --non-interactive', + 'No confirmation mode - Does not show prompt for confirmation and allow to use scripts non-interactive', + ); + cmd.option('-l, --local-rpc', 'Local node mode - Does not submit signed transaction to the node'); + }); + /* eslint-enable prettier/prettier */ + + return program; +} diff --git a/src/services/batch.ts b/src/services/batch.ts new file mode 100644 index 0000000..aee3532 --- /dev/null +++ b/src/services/batch.ts @@ -0,0 +1,339 @@ +import type { Provider, BlockTag, Block, TransactionResponse, BaseContract, ContractEventName, EventLog } from 'ethers'; +import { chunk, sleep } from './utils'; + +export interface BatchBlockServiceConstructor { + provider: Provider; + onProgress?: BatchBlockOnProgress; + concurrencySize?: number; + batchSize?: number; + shouldRetry?: boolean; + retryMax?: number; + retryOn?: number; +} + +export type BatchBlockOnProgress = ({ + percentage, + currentIndex, + totalIndex, +}: { + percentage: number; + currentIndex?: number; + totalIndex?: number; +}) => void; + +/** + * Fetch blocks from web3 provider on batches + */ +export class BatchBlockService { + provider: Provider; + onProgress?: BatchBlockOnProgress; + concurrencySize: number; + batchSize: number; + shouldRetry: boolean; + retryMax: number; + retryOn: number; + constructor({ + provider, + onProgress, + concurrencySize = 10, + batchSize = 10, + shouldRetry = true, + retryMax = 5, + retryOn = 500, + }: BatchBlockServiceConstructor) { + this.provider = provider; + this.onProgress = onProgress; + this.concurrencySize = concurrencySize; + this.batchSize = batchSize; + this.shouldRetry = shouldRetry; + this.retryMax = retryMax; + this.retryOn = retryOn; + } + + async getBlock(blockTag: BlockTag): Promise { + const blockObject = await this.provider.getBlock(blockTag); + + // if the provider returns null (which they have corrupted block data for one of their nodes) throw and retry + if (!blockObject) { + const errMsg = `No block for ${blockTag}`; + throw new Error(errMsg); + } + + return blockObject; + } + + createBatchRequest(batchArray: BlockTag[][]): Promise[] { + return batchArray.map(async (blocks: BlockTag[], index: number) => { + // send batch requests on milliseconds to avoid including them on a single batch request + await sleep(20 * index); + + return (async () => { + let retries = 0; + let err; + + // eslint-disable-next-line no-unmodified-loop-condition + while ((!this.shouldRetry && retries === 0) || (this.shouldRetry && retries < this.retryMax)) { + try { + return await Promise.all(blocks.map((b) => this.getBlock(b))); + } catch (e) { + retries++; + err = e; + + // retry on 0.5 seconds + await sleep(this.retryOn); + } + } + + throw err; + })(); + }); + } + + async getBatchBlocks(blocks: BlockTag[]): Promise { + let blockCount = 0; + const results: Block[] = []; + + for (const chunks of chunk(blocks, this.concurrencySize * this.batchSize)) { + const chunksResult = (await Promise.all(this.createBatchRequest(chunk(chunks, this.batchSize)))).flat(); + + results.push(...chunksResult); + + blockCount += chunks.length; + + if (typeof this.onProgress === 'function') { + this.onProgress({ + percentage: blockCount / blocks.length, + currentIndex: blockCount, + totalIndex: blocks.length, + }); + } + } + + return results; + } +} + +/** + * Fetch transactions from web3 provider on batches + */ +export class BatchTransactionService { + provider: Provider; + onProgress?: BatchBlockOnProgress; + concurrencySize: number; + batchSize: number; + shouldRetry: boolean; + retryMax: number; + retryOn: number; + constructor({ + provider, + onProgress, + concurrencySize = 10, + batchSize = 10, + shouldRetry = true, + retryMax = 5, + retryOn = 500, + }: BatchBlockServiceConstructor) { + this.provider = provider; + this.onProgress = onProgress; + this.concurrencySize = concurrencySize; + this.batchSize = batchSize; + this.shouldRetry = shouldRetry; + this.retryMax = retryMax; + this.retryOn = retryOn; + } + + async getTransaction(txHash: string): Promise { + const txObject = await this.provider.getTransaction(txHash); + + if (!txObject) { + const errMsg = `No transaction for ${txHash}`; + throw new Error(errMsg); + } + + return txObject; + } + + createBatchRequest(batchArray: string[][]): Promise[] { + return batchArray.map(async (txs: string[], index: number) => { + await sleep(20 * index); + + return (async () => { + let retries = 0; + let err; + + // eslint-disable-next-line no-unmodified-loop-condition + while ((!this.shouldRetry && retries === 0) || (this.shouldRetry && retries < this.retryMax)) { + try { + return await Promise.all(txs.map((tx) => this.getTransaction(tx))); + } catch (e) { + retries++; + err = e; + + // retry on 0.5 seconds + await sleep(this.retryOn); + } + } + + throw err; + })(); + }); + } + + async getBatchTransactions(txs: string[]): Promise { + let txCount = 0; + const results = []; + + for (const chunks of chunk(txs, this.concurrencySize * this.batchSize)) { + const chunksResult = (await Promise.all(this.createBatchRequest(chunk(chunks, this.batchSize)))).flat(); + + results.push(...chunksResult); + + txCount += chunks.length; + + if (typeof this.onProgress === 'function') { + this.onProgress({ percentage: txCount / txs.length, currentIndex: txCount, totalIndex: txs.length }); + } + } + + return results; + } +} + +export interface BatchEventServiceConstructor { + provider: Provider; + contract: BaseContract; + onProgress?: BatchEventOnProgress; + concurrencySize?: number; + blocksPerRequest?: number; + shouldRetry?: boolean; + retryMax?: number; + retryOn?: number; +} + +export type BatchEventOnProgress = ({ + percentage, + type, + fromBlock, + toBlock, + count, +}: { + percentage: number; + type?: ContractEventName; + fromBlock?: number; + toBlock?: number; + count?: number; +}) => void; + +// To enable iteration only numbers are accepted for fromBlock input +export type EventInput = { + fromBlock: number; + toBlock: number; + type: ContractEventName; +}; + +/** + * Fetch events from web3 provider on bulk + */ +export class BatchEventsService { + provider: Provider; + contract: BaseContract; + onProgress?: BatchEventOnProgress; + concurrencySize: number; + blocksPerRequest: number; + shouldRetry: boolean; + retryMax: number; + retryOn: number; + constructor({ + provider, + contract, + onProgress, + concurrencySize = 10, + blocksPerRequest = 2000, + shouldRetry = true, + retryMax = 5, + retryOn = 500, + }: BatchEventServiceConstructor) { + this.provider = provider; + this.contract = contract; + this.onProgress = onProgress; + this.concurrencySize = concurrencySize; + this.blocksPerRequest = blocksPerRequest; + this.shouldRetry = shouldRetry; + this.retryMax = retryMax; + this.retryOn = retryOn; + } + + async getPastEvents({ fromBlock, toBlock, type }: EventInput): Promise { + let err; + let retries = 0; + + // eslint-disable-next-line no-unmodified-loop-condition + while ((!this.shouldRetry && retries === 0) || (this.shouldRetry && retries < this.retryMax)) { + try { + return (await this.contract.queryFilter(type, fromBlock, toBlock)) as EventLog[]; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (e: any) { + err = e; + retries++; + + // If provider.getBlockNumber returned last block that isn't accepted (happened on Avalanche/Gnosis), + // get events to last accepted block + if (e.message.includes('after last accepted block')) { + const acceptedBlock = parseInt(e.message.split('after last accepted block ')[1]); + toBlock = acceptedBlock; + } + + // retry on 0.5 seconds + await sleep(this.retryOn); + } + } + + throw err; + } + + createBatchRequest(batchArray: EventInput[]): Promise[] { + return batchArray.map(async (event: EventInput, index: number) => { + await sleep(20 * index); + + return this.getPastEvents(event); + }); + } + + async getBatchEvents({ fromBlock, toBlock, type = '*' }: EventInput): Promise { + if (!toBlock) { + toBlock = await this.provider.getBlockNumber(); + } + + const eventsToSync = []; + + for (let i = fromBlock; i < toBlock; i += this.blocksPerRequest) { + const j = i + this.blocksPerRequest - 1 > toBlock ? toBlock : i + this.blocksPerRequest - 1; + + eventsToSync.push({ fromBlock: i, toBlock: j, type }); + } + + const events = []; + const eventChunk = chunk(eventsToSync, this.concurrencySize); + + let chunkCount = 0; + + for (const chunk of eventChunk) { + chunkCount++; + + const fetchedEvents = (await Promise.all(this.createBatchRequest(chunk))).flat(); + events.push(...fetchedEvents); + + if (typeof this.onProgress === 'function') { + this.onProgress({ + percentage: chunkCount / eventChunk.length, + type, + fromBlock: chunk[0].fromBlock, + toBlock: chunk[chunk.length - 1].toBlock, + count: fetchedEvents.length, + }); + } + } + + return events; + } +} diff --git a/src/services/data.ts b/src/services/data.ts new file mode 100644 index 0000000..e8b186c --- /dev/null +++ b/src/services/data.ts @@ -0,0 +1,147 @@ +import path from 'path'; +import { stat, mkdir, readFile, writeFile } from 'fs/promises'; +import { zip, unzip, AsyncZippable, Unzipped } from 'fflate'; +import { BaseEvents, MinimalEvents } from './events'; + +export async function existsAsync(fileOrDir: string): Promise { + try { + await stat(fileOrDir); + + return true; + } catch { + return false; + } +} + +export function zipAsync(file: AsyncZippable): Promise { + return new Promise((res, rej) => { + zip(file, { mtime: new Date('1/1/1980') }, (err, data) => { + if (err) { + rej(err); + return; + } + res(data); + }); + }); +} + +export function unzipAsync(data: Uint8Array): Promise { + return new Promise((res, rej) => { + unzip(data, {}, (err, data) => { + if (err) { + rej(err); + return; + } + res(data); + }); + }); +} + +export async function saveEvents({ + name, + userDirectory, + events, +}: { + name: string; + userDirectory: string; + events: T[]; +}) { + const fileName = `${name}.json`.toLowerCase(); + const filePath = path.join(userDirectory, fileName); + + const stringEvents = JSON.stringify(events, null, 2) + '\n'; + + const payload = await zipAsync({ + [fileName]: new TextEncoder().encode(stringEvents), + }); + + if (!(await existsAsync(userDirectory))) { + await mkdir(userDirectory, { recursive: true }); + } + + await writeFile(filePath + '.zip', payload); + await writeFile(filePath, stringEvents); +} + +export async function loadSavedEvents({ + name, + userDirectory, + deployedBlock, +}: { + name: string; + userDirectory: string; + deployedBlock: number; +}): Promise> { + const filePath = path.join(userDirectory, `${name}.json`.toLowerCase()); + + if (!(await existsAsync(filePath))) { + return { + events: [] as T[], + lastBlock: null, + }; + } + + try { + const events = JSON.parse(await readFile(filePath, { encoding: 'utf8' })) as T[]; + + return { + events, + lastBlock: events && events.length ? events[events.length - 1].blockNumber : deployedBlock, + }; + } catch (err) { + console.log('Method loadSavedEvents has error'); + console.log(err); + return { + events: [], + lastBlock: deployedBlock, + }; + } +} + +export async function download({ name, cacheDirectory }: { name: string; cacheDirectory: string }) { + const fileName = `${name}.json`.toLowerCase(); + const zipName = `${fileName}.zip`; + const zipPath = path.join(cacheDirectory, zipName); + + const data = await readFile(zipPath); + const { [fileName]: content } = await unzipAsync(data); + + return new TextDecoder().decode(content); +} + +export async function loadCachedEvents({ + name, + cacheDirectory, + deployedBlock, +}: { + name: string; + cacheDirectory: string; + deployedBlock: number; +}): Promise> { + try { + const module = await download({ cacheDirectory, name }); + + if (module) { + const events = JSON.parse(module); + + const lastBlock = events && events.length ? events[events.length - 1].blockNumber : deployedBlock; + + return { + events, + lastBlock, + }; + } + + return { + events: [], + lastBlock: deployedBlock, + }; + } catch (err) { + console.log('Method loadCachedEvents has error'); + console.log(err); + return { + events: [], + lastBlock: deployedBlock, + }; + } +} diff --git a/src/services/deposits.ts b/src/services/deposits.ts new file mode 100644 index 0000000..1edf379 --- /dev/null +++ b/src/services/deposits.ts @@ -0,0 +1,246 @@ +import { bnToBytes, bytesToBN, leBuff2Int, leInt2Buff, rBigInt, toFixedHex } from './utils'; +import { buffPedersenHash } from './pedersen'; + +export type DepositType = { + currency: string; + amount: string; + netId: string | number; +}; + +export type createDepositParams = { + nullifier: bigint; + secret: bigint; +}; + +export type createDepositObject = { + preimage: Uint8Array; + noteHex: string; + commitment: bigint; + commitmentHex: string; + nullifierHash: bigint; + nullifierHex: string; +}; + +export type createNoteParams = DepositType & { + nullifier?: bigint; + secret?: bigint; +}; + +export type parsedNoteExec = DepositType & { + note: string; +}; + +export type depositTx = { + from: string; + transactionHash: string; +}; + +export type withdrawalTx = { + to: string; + transactionHash: string; +}; + +export async function createDeposit({ nullifier, secret }: createDepositParams): Promise { + const preimage = new Uint8Array([...leInt2Buff(nullifier), ...leInt2Buff(secret)]); + const noteHex = toFixedHex(bytesToBN(preimage), 62); + const commitment = BigInt(await buffPedersenHash(preimage)); + const commitmentHex = toFixedHex(commitment); + const nullifierHash = BigInt(await buffPedersenHash(leInt2Buff(nullifier))); + const nullifierHex = toFixedHex(nullifierHash); + + return { + preimage, + noteHex, + commitment, + commitmentHex, + nullifierHash, + nullifierHex, + }; +} + +export interface DepositConstructor { + currency: string; + amount: string; + netId: number; + nullifier: bigint; + secret: bigint; + note: string; + noteHex: string; + invoice: string; + commitmentHex: string; + nullifierHex: string; +} + +export class Deposit { + currency: string; + amount: string; + netId: number; + + nullifier: bigint; + secret: bigint; + + note: string; + noteHex: string; + invoice: string; + + commitmentHex: string; + nullifierHex: string; + + constructor({ + currency, + amount, + netId, + nullifier, + secret, + note, + noteHex, + invoice, + commitmentHex, + nullifierHex, + }: DepositConstructor) { + this.currency = currency; + this.amount = amount; + this.netId = netId; + + this.nullifier = nullifier; + this.secret = secret; + + this.note = note; + this.noteHex = noteHex; + this.invoice = invoice; + + this.commitmentHex = commitmentHex; + this.nullifierHex = nullifierHex; + } + + toString() { + return JSON.stringify( + { + currency: this.currency, + amount: this.amount, + netId: this.netId, + nullifier: this.nullifier, + secret: this.secret, + note: this.note, + noteHex: this.noteHex, + invoice: this.invoice, + commitmentHex: this.commitmentHex, + nullifierHex: this.nullifierHex, + }, + null, + 2, + ); + } + + static async createNote({ currency, amount, netId, nullifier, secret }: createNoteParams): Promise { + if (!nullifier) { + nullifier = rBigInt(31); + } + if (!secret) { + secret = rBigInt(31); + } + + const depositObject = await createDeposit({ + nullifier, + secret, + }); + + const newDeposit = new Deposit({ + currency: currency.toLowerCase(), + amount: amount, + netId: Number(netId), + note: `tornado-${currency.toLowerCase()}-${amount}-${netId}-${depositObject.noteHex}`, + noteHex: depositObject.noteHex, + invoice: `tornadoInvoice-${currency.toLowerCase()}-${amount}-${netId}-${depositObject.commitmentHex}`, + nullifier: nullifier, + secret: secret, + commitmentHex: depositObject.commitmentHex, + nullifierHex: depositObject.nullifierHex, + }); + + return newDeposit; + } + + static async parseNote(noteString: string): Promise { + const noteRegex = /tornado-(?\w+)-(?[\d.]+)-(?\d+)-0x(?[0-9a-fA-F]{124})/g; + const match = noteRegex.exec(noteString); + if (!match) { + throw new Error('The note has invalid format'); + } + const matchGroup = match?.groups as unknown as parsedNoteExec; + + const currency = matchGroup.currency.toLowerCase(); + const amount = matchGroup.amount; + const netId = Number(matchGroup.netId); + + const bytes = bnToBytes('0x' + matchGroup.note); + const nullifier = BigInt(leBuff2Int(bytes.slice(0, 31)).toString()); + const secret = BigInt(leBuff2Int(bytes.slice(31, 62)).toString()); + + const depositObject = await createDeposit({ nullifier, secret }); + + const invoice = `tornadoInvoice-${currency}-${amount}-${netId}-${depositObject.commitmentHex}`; + + const newDeposit = new Deposit({ + currency: currency, + amount: amount, + netId: netId, + note: noteString, + noteHex: depositObject.noteHex, + invoice: invoice, + nullifier: nullifier, + secret: secret, + commitmentHex: depositObject.commitmentHex, + nullifierHex: depositObject.nullifierHex, + }); + + return newDeposit; + } +} + +export type parsedInvoiceExec = DepositType & { + commitment: string; +}; + +export class Invoice { + currency: string; + amount: string; + netId: number; + commitment: string; + invoice: string; + + constructor(invoiceString: string) { + const invoiceRegex = + /tornadoInvoice-(?\w+)-(?[\d.]+)-(?\d+)-0x(?[0-9a-fA-F]{64})/g; + const match = invoiceRegex.exec(invoiceString); + if (!match) { + throw new Error('The note has invalid format'); + } + const matchGroup = match?.groups as unknown as parsedInvoiceExec; + + const currency = matchGroup.currency.toLowerCase(); + const amount = matchGroup.amount; + const netId = Number(matchGroup.netId); + + this.currency = currency; + this.amount = amount; + this.netId = netId; + + this.commitment = '0x' + matchGroup.commitment; + this.invoice = invoiceString; + } + + toString() { + return JSON.stringify( + { + currency: this.currency, + amount: this.amount, + netId: this.netId, + commitment: this.commitment, + invoice: this.invoice, + }, + null, + 2, + ); + } +} diff --git a/src/services/events/base.ts b/src/services/events/base.ts new file mode 100644 index 0000000..21183fd --- /dev/null +++ b/src/services/events/base.ts @@ -0,0 +1,719 @@ +import { BaseContract, Provider, EventLog, TransactionResponse, getAddress, Block, ContractEventName } from 'ethers'; +import type { Tornado, TornadoRouter, TornadoProxyLight, Governance, RelayerRegistry } from '@tornado/contracts'; +import * as graph from '../graphql'; +import { + BatchEventsService, + BatchBlockService, + BatchTransactionService, + BatchEventOnProgress, + BatchBlockOnProgress, +} from '../batch'; +import { fetchDataOptions } from '../providers'; +import type { + BaseEvents, + MinimalEvents, + DepositsEvents, + WithdrawalsEvents, + EncryptedNotesEvents, + GovernanceProposalCreatedEvents, + GovernanceVotedEvents, + GovernanceDelegatedEvents, + GovernanceUndelegatedEvents, + RegistersEvents, + BaseGraphEvents, +} from './types'; + +export const DEPOSIT = 'deposit'; +export const WITHDRAWAL = 'withdrawal'; + +export type BaseEventsServiceConstructor = { + netId: number | string; + provider: Provider; + graphApi?: string; + subgraphName?: string; + contract: BaseContract; + type?: string; + deployedBlock?: number; + fetchDataOptions?: fetchDataOptions; +}; + +export type BatchGraphOnProgress = ({ + type, + fromBlock, + toBlock, + count, +}: { + type?: ContractEventName; + fromBlock?: number; + toBlock?: number; + count?: number; +}) => void; + +export type BaseGraphParams = { + graphApi: string; + subgraphName: string; + fetchDataOptions?: fetchDataOptions; + onProgress?: BatchGraphOnProgress; +}; + +export class BaseEventsService { + netId: number | string; + provider: Provider; + graphApi?: string; + subgraphName?: string; + contract: BaseContract; + type: string; + deployedBlock: number; + batchEventsService: BatchEventsService; + fetchDataOptions?: fetchDataOptions; + + constructor({ + netId, + provider, + graphApi, + subgraphName, + contract, + type = '', + deployedBlock = 0, + fetchDataOptions, + }: BaseEventsServiceConstructor) { + this.netId = netId; + this.provider = provider; + this.graphApi = graphApi; + this.subgraphName = subgraphName; + this.fetchDataOptions = fetchDataOptions; + + this.contract = contract; + this.type = type; + this.deployedBlock = deployedBlock; + + this.batchEventsService = new BatchEventsService({ + provider, + contract, + onProgress: this.updateEventProgress, + }); + } + + getInstanceName(): string { + return ''; + } + + getType(): string { + return this.type || ''; + } + + getGraphMethod(): string { + return ''; + } + + getGraphParams(): BaseGraphParams { + return { + graphApi: this.graphApi || '', + subgraphName: this.subgraphName || '', + fetchDataOptions: this.fetchDataOptions, + onProgress: this.updateGraphProgress, + }; + } + + /* eslint-disable @typescript-eslint/no-unused-vars */ + updateEventProgress({ percentage, type, fromBlock, toBlock, count }: Parameters[0]) {} + + updateBlockProgress({ percentage, currentIndex, totalIndex }: Parameters[0]) {} + + updateTransactionProgress({ percentage, currentIndex, totalIndex }: Parameters[0]) {} + + updateGraphProgress({ type, fromBlock, toBlock, count }: Parameters[0]) {} + /* eslint-enable @typescript-eslint/no-unused-vars */ + + async formatEvents(events: EventLog[]): Promise { + // eslint-disable-next-line no-return-await + return await new Promise((resolve) => resolve(events as unknown as EventType[])); + } + + /** + * Get saved or cached events + */ + + async getEventsFromDB(): Promise> { + return { + events: [], + lastBlock: null, + }; + } + + async getEventsFromCache(): Promise> { + return { + events: [], + lastBlock: null, + }; + } + + async getSavedEvents(): Promise> { + let cachedEvents = await this.getEventsFromDB(); + + if (!cachedEvents || !cachedEvents.events.length) { + cachedEvents = await this.getEventsFromCache(); + } + + return cachedEvents; + } + + /** + * Get latest events + */ + + async getEventsFromGraph({ + fromBlock, + methodName = '', + }: { + fromBlock: number; + methodName?: string; + }): Promise> { + if (!this.graphApi || !this.subgraphName) { + return { + events: [], + lastBlock: fromBlock, + }; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { events, lastSyncBlock } = (await (graph as any)[methodName || this.getGraphMethod()]({ + fromBlock, + ...this.getGraphParams(), + })) as BaseGraphEvents; + return { + events, + lastBlock: lastSyncBlock, + }; + } + + async getEventsFromRpc({ + fromBlock, + toBlock, + }: { + fromBlock: number; + toBlock?: number; + }): Promise> { + try { + if (!toBlock) { + toBlock = await this.provider.getBlockNumber(); + } + + if (fromBlock >= toBlock) { + return { + events: [], + lastBlock: toBlock, + }; + } + + this.updateEventProgress({ percentage: 0, type: this.getType() }); + + const events = await this.formatEvents( + await this.batchEventsService.getBatchEvents({ fromBlock, toBlock, type: this.getType() }), + ); + + if (!events.length) { + return { + events, + lastBlock: toBlock, + }; + } + + return { + events, + lastBlock: toBlock, + }; + } catch (err) { + console.log(err); + return { + events: [], + lastBlock: fromBlock, + }; + } + } + + async getLatestEvents({ fromBlock }: { fromBlock: number }): Promise> { + const allEvents = []; + const graphEvents = await this.getEventsFromGraph({ fromBlock }); + const lastSyncBlock = + graphEvents.lastBlock && graphEvents.lastBlock >= fromBlock ? graphEvents.lastBlock : fromBlock; + const rpcEvents = await this.getEventsFromRpc({ fromBlock: lastSyncBlock }); + allEvents.push(...graphEvents.events); + allEvents.push(...rpcEvents.events); + const lastBlock = rpcEvents + ? rpcEvents.lastBlock + : allEvents[allEvents.length - 1] + ? allEvents[allEvents.length - 1].blockNumber + : fromBlock; + + return { + events: allEvents, + lastBlock, + }; + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + validateEvents({ events, lastBlock }: BaseEvents) {} + + /** + * Handle saving events + */ + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async saveEvents({ events, lastBlock }: BaseEvents) {} + + /** + * Trigger saving and receiving latest events + */ + + async updateEvents() { + const savedEvents = await this.getSavedEvents(); + + let fromBlock = this.deployedBlock; + + if (savedEvents && savedEvents.lastBlock) { + fromBlock = savedEvents.lastBlock + 1; + } + + const newEvents = await this.getLatestEvents({ fromBlock }); + + const eventSet = new Set(); + + let allEvents: EventType[] = []; + + allEvents.push(...savedEvents.events); + allEvents.push(...newEvents.events); + + allEvents = allEvents + .sort((a, b) => { + if (a.blockNumber === b.blockNumber) { + return a.logIndex - b.logIndex; + } + return a.blockNumber - b.blockNumber; + }) + .filter(({ transactionHash, logIndex }) => { + const eventKey = `${transactionHash}_${logIndex}`; + const hasEvent = eventSet.has(eventKey); + eventSet.add(eventKey); + return !hasEvent; + }); + const lastBlock = newEvents + ? newEvents.lastBlock + : allEvents[allEvents.length - 1] + ? allEvents[allEvents.length - 1].blockNumber + : null; + + this.validateEvents({ events: allEvents, lastBlock }); + + await this.saveEvents({ events: allEvents, lastBlock }); + + return { + events: allEvents, + lastBlock, + }; + } +} + +export type BaseDepositsServiceConstructor = { + netId: number | string; + provider: Provider; + graphApi?: string; + subgraphName?: string; + Tornado: Tornado; + type: string; + amount: string; + currency: string; + deployedBlock?: number; + fetchDataOptions?: fetchDataOptions; +}; + +export type DepositsGraphParams = BaseGraphParams & { + amount: string; + currency: string; +}; + +export class BaseDepositsService extends BaseEventsService { + amount: string; + currency: string; + batchTransactionService: BatchTransactionService; + batchBlockService: BatchBlockService; + + constructor({ + netId, + provider, + graphApi, + subgraphName, + Tornado, + type, + amount, + currency, + deployedBlock, + fetchDataOptions, + }: BaseDepositsServiceConstructor) { + super({ netId, provider, graphApi, subgraphName, contract: Tornado, type, deployedBlock, fetchDataOptions }); + + this.amount = amount; + this.currency = currency; + + this.batchTransactionService = new BatchTransactionService({ + provider, + onProgress: this.updateTransactionProgress, + }); + + this.batchBlockService = new BatchBlockService({ + provider, + onProgress: this.updateBlockProgress, + }); + } + + getInstanceName(): string { + return `${this.getType().toLowerCase()}s_${this.netId}_${this.currency}_${this.amount}`; + } + + getGraphMethod(): string { + return `getAll${this.getType()}s`; + } + + getGraphParams(): DepositsGraphParams { + return { + graphApi: this.graphApi || '', + subgraphName: this.subgraphName || '', + amount: this.amount, + currency: this.currency, + fetchDataOptions: this.fetchDataOptions, + onProgress: this.updateGraphProgress, + }; + } + + async formatEvents(events: EventLog[]): Promise<(DepositsEvents | WithdrawalsEvents)[]> { + const type = this.getType().toLowerCase(); + if (type === DEPOSIT) { + const formattedEvents = events.map(({ blockNumber, index: logIndex, transactionHash, args }) => { + const { commitment, leafIndex, timestamp } = args; + + return { + blockNumber, + logIndex, + transactionHash, + commitment: commitment as string, + leafIndex: Number(leafIndex), + timestamp: Number(timestamp), + }; + }); + + const txs = await this.batchTransactionService.getBatchTransactions([ + ...new Set(formattedEvents.map(({ transactionHash }) => transactionHash)), + ]); + + return formattedEvents.map((event) => { + const { from } = txs.find(({ hash }) => hash === event.transactionHash) as TransactionResponse; + + return { + ...event, + from, + }; + }); + } else { + const formattedEvents = events.map(({ blockNumber, index: logIndex, transactionHash, args }) => { + const { nullifierHash, to, fee } = args; + + return { + blockNumber, + logIndex, + transactionHash, + nullifierHash: String(nullifierHash), + to: getAddress(to), + fee: String(fee), + }; + }); + + const blocks = await this.batchBlockService.getBatchBlocks([ + ...new Set(formattedEvents.map(({ blockNumber }) => blockNumber)), + ]); + + return formattedEvents.map((event) => { + const { timestamp } = blocks.find(({ number }) => number === event.blockNumber) as Block; + + return { + ...event, + timestamp, + }; + }); + } + } + + validateEvents({ events }: { events: (DepositsEvents | WithdrawalsEvents)[] }) { + if (events.length && this.getType().toLowerCase() === DEPOSIT) { + const lastEvent = events[events.length - 1] as DepositsEvents; + + if (lastEvent.leafIndex !== events.length - 1) { + const errMsg = `Deposit events invalid wants ${events.length - 1} leafIndex have ${lastEvent.leafIndex}`; + throw new Error(errMsg); + } + } + } +} + +export type BaseEncryptedNotesServiceConstructor = { + netId: number | string; + provider: Provider; + graphApi?: string; + subgraphName?: string; + Router: TornadoRouter | TornadoProxyLight; + deployedBlock?: number; + fetchDataOptions?: fetchDataOptions; +}; + +export class BaseEncryptedNotesService extends BaseEventsService { + constructor({ + netId, + provider, + graphApi, + subgraphName, + Router, + deployedBlock, + fetchDataOptions, + }: BaseEncryptedNotesServiceConstructor) { + super({ netId, provider, graphApi, subgraphName, contract: Router, deployedBlock, fetchDataOptions }); + } + + getInstanceName(): string { + return `encrypted_notes_${this.netId}`; + } + + getType(): string { + return 'EncryptedNote'; + } + + getGraphMethod(): string { + return 'getAllEncryptedNotes'; + } + + async formatEvents(events: EventLog[]) { + return events + .map(({ blockNumber, index: logIndex, transactionHash, args }) => { + const { encryptedNote } = args; + + if (encryptedNote) { + const eventObjects = { + blockNumber, + logIndex, + transactionHash, + }; + + return { + ...eventObjects, + encryptedNote, + }; + } + }) + .filter((e) => e) as EncryptedNotesEvents[]; + } +} + +export type BaseGovernanceEventTypes = + | GovernanceProposalCreatedEvents + | GovernanceVotedEvents + | GovernanceDelegatedEvents + | GovernanceUndelegatedEvents; + +export type BaseGovernanceServiceConstructor = { + netId: number | string; + provider: Provider; + graphApi?: string; + subgraphName?: string; + Governance: Governance; + deployedBlock?: number; + fetchDataOptions?: fetchDataOptions; +}; + +export class BaseGovernanceService extends BaseEventsService { + batchTransactionService: BatchTransactionService; + + constructor({ + netId, + provider, + graphApi, + subgraphName, + Governance, + deployedBlock, + fetchDataOptions, + }: BaseGovernanceServiceConstructor) { + super({ netId, provider, graphApi, subgraphName, contract: Governance, deployedBlock, fetchDataOptions }); + + this.batchTransactionService = new BatchTransactionService({ + provider, + onProgress: this.updateTransactionProgress, + }); + } + + getInstanceName() { + return `governance_${this.netId}`; + } + + getType() { + return '*'; + } + + getGraphMethod() { + return 'governanceEvents'; + } + + async formatEvents(events: EventLog[]): Promise { + const formattedEvents = events + .map(({ blockNumber, index: logIndex, transactionHash, args, eventName: event }) => { + const eventObjects = { + blockNumber, + logIndex, + transactionHash, + event, + }; + + if (event === 'ProposalCreated') { + const { id, proposer, target, startTime, endTime, description } = args; + return { + ...eventObjects, + id, + proposer, + target, + startTime, + endTime, + description, + } as GovernanceProposalCreatedEvents; + } + + if (event === 'Voted') { + const { proposalId, voter, support, votes } = args; + return { + ...eventObjects, + proposalId, + voter, + support, + votes, + } as GovernanceVotedEvents; + } + + if (event === 'Delegated') { + const { account, to: delegateTo } = args; + return { + ...eventObjects, + account, + delegateTo, + } as GovernanceDelegatedEvents; + } + + if (event === 'Undelegated') { + const { account, from: delegateFrom } = args; + return { + ...eventObjects, + account, + delegateFrom, + } as GovernanceUndelegatedEvents; + } + }) + .filter((e) => e) as BaseGovernanceEventTypes[]; + + type GovernanceVotedEventsIndexed = GovernanceVotedEvents & { + index: number; + }; + + const votedEvents = formattedEvents + .map((event, index) => ({ ...event, index })) + .filter(({ event }) => event === 'Voted') as GovernanceVotedEventsIndexed[]; + + if (votedEvents.length) { + this.updateTransactionProgress({ percentage: 0 }); + + const txs = await this.batchTransactionService.getBatchTransactions([ + ...new Set(votedEvents.map(({ transactionHash }) => transactionHash)), + ]); + + votedEvents.forEach((event) => { + // eslint-disable-next-line prefer-const + let { data: input, from } = txs.find((t) => t.hash === event.transactionHash) as TransactionResponse; + + // Filter spammy txs + if (!input || input.length > 2048) { + input = ''; + } + + // @ts-expect-error check formattedEvents types later + formattedEvents[event.index].from = from; + // @ts-expect-error check formattedEvents types later + formattedEvents[event.index].input = input; + }); + } + + return formattedEvents; + } + + async getEventsFromGraph({ fromBlock }: { fromBlock: number }): Promise> { + // TheGraph doesn't support governance subgraphs + if (!this.graphApi || this.graphApi.includes('api.thegraph.com')) { + return { + events: [], + lastBlock: fromBlock, + }; + } + + return super.getEventsFromGraph({ fromBlock }); + } +} + +export type BaseRegistryServiceConstructor = { + netId: number | string; + provider: Provider; + graphApi?: string; + subgraphName?: string; + RelayerRegistry: RelayerRegistry; + deployedBlock?: number; + fetchDataOptions?: fetchDataOptions; +}; + +export class BaseRegistryService extends BaseEventsService { + constructor({ + netId, + provider, + graphApi, + subgraphName, + RelayerRegistry, + deployedBlock, + fetchDataOptions, + }: BaseRegistryServiceConstructor) { + super({ netId, provider, graphApi, subgraphName, contract: RelayerRegistry, deployedBlock, fetchDataOptions }); + } + + getInstanceName() { + return `registered_${this.netId}`; + } + + // Name of type used for events + getType() { + return 'RelayerRegistered'; + } + + // Name of method used for graph + getGraphMethod() { + return 'getAllRegisters'; + } + + async formatEvents(events: EventLog[]) { + return events.map(({ blockNumber, index: logIndex, transactionHash, args }) => { + const eventObjects = { + blockNumber, + logIndex, + transactionHash, + }; + + return { + ...eventObjects, + ensName: args.ensName, + relayerAddress: args.relayerAddress, + }; + }); + } + + async fetchRelayers(): Promise { + return (await this.updateEvents()).events; + } +} diff --git a/src/services/events/index.ts b/src/services/events/index.ts new file mode 100644 index 0000000..c386db6 --- /dev/null +++ b/src/services/events/index.ts @@ -0,0 +1,3 @@ +export * from './types'; +export * from './base'; +export * from './node'; diff --git a/src/services/events/node.ts b/src/services/events/node.ts new file mode 100644 index 0000000..9efc4a2 --- /dev/null +++ b/src/services/events/node.ts @@ -0,0 +1,626 @@ +import Table from 'cli-table3'; +import moment from 'moment'; +import { BatchBlockOnProgress, BatchEventOnProgress } from '../batch'; +import { saveEvents, loadSavedEvents, loadCachedEvents } from '../data'; +import { + BaseDepositsService, + BaseEncryptedNotesService, + BaseGovernanceService, + BaseRegistryService, + BaseDepositsServiceConstructor, + BaseEncryptedNotesServiceConstructor, + BaseGovernanceServiceConstructor, + BaseRegistryServiceConstructor, + BaseGovernanceEventTypes, +} from './base'; +import type { BaseEvents, DepositsEvents, WithdrawalsEvents, EncryptedNotesEvents, RegistersEvents } from './types'; + +export type NodeDepositsServiceConstructor = BaseDepositsServiceConstructor & { + cacheDirectory?: string; + userDirectory?: string; +}; + +export class NodeDepositsService extends BaseDepositsService { + cacheDirectory?: string; + userDirectory?: string; + + constructor({ + netId, + provider, + graphApi, + subgraphName, + Tornado, + type, + amount, + currency, + deployedBlock, + fetchDataOptions, + cacheDirectory, + userDirectory, + }: NodeDepositsServiceConstructor) { + super({ + netId, + provider, + graphApi, + subgraphName, + Tornado, + type, + amount, + currency, + deployedBlock, + fetchDataOptions, + }); + + this.cacheDirectory = cacheDirectory; + this.userDirectory = userDirectory; + } + + updateEventProgress({ type, fromBlock, toBlock, count }: Parameters[0]) { + if (toBlock) { + console.log(`fromBlock - ${fromBlock}`); + console.log(`toBlock - ${toBlock}`); + + if (count) { + console.log(`downloaded ${type} events count - ${count}`); + console.log('____________________________________________'); + console.log(`Fetched ${type} events from ${fromBlock} to ${toBlock}\n`); + } + } + } + + updateTransactionProgress({ currentIndex, totalIndex }: Parameters[0]) { + if (totalIndex) { + console.log(`Fetched ${currentIndex} deposit txs of ${totalIndex}`); + } + } + + updateBlockProgress({ currentIndex, totalIndex }: Parameters[0]) { + if (totalIndex) { + console.log(`Fetched ${currentIndex} withdrawal blocks of ${totalIndex}`); + } + } + + updateGraphProgress({ type, fromBlock, toBlock, count }: Parameters[0]) { + if (toBlock) { + console.log(`fromBlock - ${fromBlock}`); + console.log(`toBlock - ${toBlock}`); + + if (count) { + console.log(`downloaded ${type} events from graph node count - ${count}`); + console.log('____________________________________________'); + console.log(`Fetched ${type} events from graph node ${fromBlock} to ${toBlock}\n`); + } + } + } + + async getEventsFromDB() { + if (!this.userDirectory) { + console.log( + 'Updating events for', + this.amount, + this.currency.toUpperCase(), + `${this.getType().toLowerCase()}s\n`, + ); + console.log(`savedEvents count - ${0}`); + console.log(`savedEvents lastBlock - ${this.deployedBlock}\n`); + + return { + events: [], + lastBlock: this.deployedBlock, + }; + } + + const savedEvents = await loadSavedEvents({ + name: this.getInstanceName(), + userDirectory: this.userDirectory, + deployedBlock: this.deployedBlock, + }); + + console.log('Updating events for', this.amount, this.currency.toUpperCase(), `${this.getType().toLowerCase()}s\n`); + console.log(`savedEvents count - ${savedEvents.events.length}`); + console.log(`savedEvents lastBlock - ${savedEvents.lastBlock}\n`); + + return savedEvents; + } + + async getEventsFromCache() { + if (!this.cacheDirectory) { + console.log(`cachedEvents count - ${0}`); + console.log(`cachedEvents lastBlock - ${this.deployedBlock}\n`); + + return { + events: [], + lastBlock: this.deployedBlock, + }; + } + + const cachedEvents = await loadCachedEvents({ + name: this.getInstanceName(), + cacheDirectory: this.cacheDirectory, + deployedBlock: this.deployedBlock, + }); + + console.log(`cachedEvents count - ${cachedEvents.events.length}`); + console.log(`cachedEvents lastBlock - ${cachedEvents.lastBlock}\n`); + + return cachedEvents; + } + + async saveEvents({ events, lastBlock }: BaseEvents) { + const instanceName = this.getInstanceName(); + + console.log('\ntotalEvents count - ', events.length); + console.log( + `totalEvents lastBlock - ${events[events.length - 1] ? events[events.length - 1].blockNumber : lastBlock}\n`, + ); + + const eventTable = new Table(); + + eventTable.push( + [{ colSpan: 2, content: `${this.getType()}s`, hAlign: 'center' }], + ['Instance', `${this.netId} chain ${this.amount} ${this.currency.toUpperCase()}`], + ['Anonymity set', `${events.length} equal user ${this.getType().toLowerCase()}s`], + [{ colSpan: 2, content: `Latest ${this.getType().toLowerCase()}s` }], + ...events + .slice(events.length - 10) + .reverse() + .map(({ timestamp }, index) => { + const eventIndex = events.length - index; + const eventTime = moment.unix(timestamp).fromNow(); + + return [eventIndex, eventTime]; + }), + ); + + console.log(eventTable.toString() + '\n'); + + if (this.userDirectory) { + await saveEvents({ + name: instanceName, + userDirectory: this.userDirectory, + events, + }); + } + } +} + +export type NodeEncryptedNotesServiceConstructor = BaseEncryptedNotesServiceConstructor & { + cacheDirectory?: string; + userDirectory?: string; +}; + +export class NodeEncryptedNotesService extends BaseEncryptedNotesService { + cacheDirectory?: string; + userDirectory?: string; + + constructor({ + netId, + provider, + graphApi, + subgraphName, + Router, + deployedBlock, + fetchDataOptions, + cacheDirectory, + userDirectory, + }: NodeEncryptedNotesServiceConstructor) { + super({ + netId, + provider, + graphApi, + subgraphName, + Router, + deployedBlock, + fetchDataOptions, + }); + + this.cacheDirectory = cacheDirectory; + this.userDirectory = userDirectory; + } + + updateEventProgress({ type, fromBlock, toBlock, count }: Parameters[0]) { + if (toBlock) { + console.log(`fromBlock - ${fromBlock}`); + console.log(`toBlock - ${toBlock}`); + + if (count) { + console.log(`downloaded ${type} events count - ${count}`); + console.log('____________________________________________'); + console.log(`Fetched ${type} events from ${fromBlock} to ${toBlock}\n`); + } + } + } + + updateGraphProgress({ type, fromBlock, toBlock, count }: Parameters[0]) { + if (toBlock) { + console.log(`fromBlock - ${fromBlock}`); + console.log(`toBlock - ${toBlock}`); + + if (count) { + console.log(`downloaded ${type} events from graph node count - ${count}`); + console.log('____________________________________________'); + console.log(`Fetched ${type} events from graph node ${fromBlock} to ${toBlock}\n`); + } + } + } + + async getEventsFromDB() { + if (!this.userDirectory) { + console.log(`Updating events for ${this.netId} chain encrypted events\n`); + console.log(`savedEvents count - ${0}`); + console.log(`savedEvents lastBlock - ${this.deployedBlock}\n`); + + return { + events: [], + lastBlock: this.deployedBlock, + }; + } + + const savedEvents = await loadSavedEvents({ + name: this.getInstanceName(), + userDirectory: this.userDirectory, + deployedBlock: this.deployedBlock, + }); + + console.log(`Updating events for ${this.netId} chain encrypted events\n`); + console.log(`savedEvents count - ${savedEvents.events.length}`); + console.log(`savedEvents lastBlock - ${savedEvents.lastBlock}\n`); + + return savedEvents; + } + + async getEventsFromCache() { + if (!this.cacheDirectory) { + console.log(`cachedEvents count - ${0}`); + console.log(`cachedEvents lastBlock - ${this.deployedBlock}\n`); + + return { + events: [], + lastBlock: this.deployedBlock, + }; + } + + const cachedEvents = await loadCachedEvents({ + name: this.getInstanceName(), + cacheDirectory: this.cacheDirectory, + deployedBlock: this.deployedBlock, + }); + + console.log(`cachedEvents count - ${cachedEvents.events.length}`); + console.log(`cachedEvents lastBlock - ${cachedEvents.lastBlock}\n`); + + return cachedEvents; + } + + async saveEvents({ events, lastBlock }: BaseEvents) { + const instanceName = this.getInstanceName(); + + console.log('\ntotalEvents count - ', events.length); + console.log( + `totalEvents lastBlock - ${events[events.length - 1] ? events[events.length - 1].blockNumber : lastBlock}\n`, + ); + + const eventTable = new Table(); + + eventTable.push( + [{ colSpan: 2, content: 'Encrypted Notes', hAlign: 'center' }], + ['Network', `${this.netId} chain`], + ['Events', `${events.length} events`], + [{ colSpan: 2, content: 'Latest events' }], + ...events + .slice(events.length - 10) + .reverse() + .map(({ blockNumber }, index) => { + const eventIndex = events.length - index; + + return [eventIndex, blockNumber]; + }), + ); + + console.log(eventTable.toString() + '\n'); + + if (this.userDirectory) { + await saveEvents({ + name: instanceName, + userDirectory: this.userDirectory, + events, + }); + } + } +} + +export type NodeGovernanceServiceConstructor = BaseGovernanceServiceConstructor & { + cacheDirectory?: string; + userDirectory?: string; +}; + +export class NodeGovernanceService extends BaseGovernanceService { + cacheDirectory?: string; + userDirectory?: string; + + constructor({ + netId, + provider, + graphApi, + subgraphName, + Governance, + deployedBlock, + fetchDataOptions, + cacheDirectory, + userDirectory, + }: NodeGovernanceServiceConstructor) { + super({ + netId, + provider, + graphApi, + subgraphName, + Governance, + deployedBlock, + fetchDataOptions, + }); + + this.cacheDirectory = cacheDirectory; + this.userDirectory = userDirectory; + } + + updateEventProgress({ type, fromBlock, toBlock, count }: Parameters[0]) { + if (toBlock) { + console.log(`fromBlock - ${fromBlock}`); + console.log(`toBlock - ${toBlock}`); + + if (count) { + console.log(`downloaded ${type} events count - ${count}`); + console.log('____________________________________________'); + console.log(`Fetched ${type} events from ${fromBlock} to ${toBlock}\n`); + } + } + } + + updateGraphProgress({ type, fromBlock, toBlock, count }: Parameters[0]) { + if (toBlock) { + console.log(`fromBlock - ${fromBlock}`); + console.log(`toBlock - ${toBlock}`); + + if (count) { + console.log(`downloaded ${type} events from graph node count - ${count}`); + console.log('____________________________________________'); + console.log(`Fetched ${type} events from graph node ${fromBlock} to ${toBlock}\n`); + } + } + } + + updateTransactionProgress({ currentIndex, totalIndex }: Parameters[0]) { + if (totalIndex) { + console.log(`Fetched ${currentIndex} governance txs of ${totalIndex}`); + } + } + + async getEventsFromDB() { + if (!this.userDirectory) { + console.log(`Updating events for ${this.netId} chain governance events\n`); + console.log(`savedEvents count - ${0}`); + console.log(`savedEvents lastBlock - ${this.deployedBlock}\n`); + + return { + events: [], + lastBlock: this.deployedBlock, + }; + } + + const savedEvents = await loadSavedEvents({ + name: this.getInstanceName(), + userDirectory: this.userDirectory, + deployedBlock: this.deployedBlock, + }); + + console.log(`Updating events for ${this.netId} chain governance events\n`); + console.log(`savedEvents count - ${savedEvents.events.length}`); + console.log(`savedEvents lastBlock - ${savedEvents.lastBlock}\n`); + + return savedEvents; + } + + async getEventsFromCache() { + if (!this.cacheDirectory) { + console.log(`cachedEvents count - ${0}`); + console.log(`cachedEvents lastBlock - ${this.deployedBlock}\n`); + + return { + events: [], + lastBlock: this.deployedBlock, + }; + } + + const cachedEvents = await loadCachedEvents({ + name: this.getInstanceName(), + cacheDirectory: this.cacheDirectory, + deployedBlock: this.deployedBlock, + }); + + console.log(`cachedEvents count - ${cachedEvents.events.length}`); + console.log(`cachedEvents lastBlock - ${cachedEvents.lastBlock}\n`); + + return cachedEvents; + } + + async saveEvents({ events, lastBlock }: BaseEvents) { + const instanceName = this.getInstanceName(); + + console.log('\ntotalEvents count - ', events.length); + console.log( + `totalEvents lastBlock - ${events[events.length - 1] ? events[events.length - 1].blockNumber : lastBlock}\n`, + ); + + const eventTable = new Table(); + + eventTable.push( + [{ colSpan: 2, content: 'Governance Events', hAlign: 'center' }], + ['Network', `${this.netId} chain`], + ['Events', `${events.length} events`], + [{ colSpan: 2, content: 'Latest events' }], + ...events + .slice(events.length - 10) + .reverse() + .map(({ blockNumber }, index) => { + const eventIndex = events.length - index; + + return [eventIndex, blockNumber]; + }), + ); + + console.log(eventTable.toString() + '\n'); + + if (this.userDirectory) { + await saveEvents({ + name: instanceName, + userDirectory: this.userDirectory, + events, + }); + } + } +} + +export type NodeRegistryServiceConstructor = BaseRegistryServiceConstructor & { + cacheDirectory?: string; + userDirectory?: string; +}; + +export class NodeRegistryService extends BaseRegistryService { + cacheDirectory?: string; + userDirectory?: string; + + constructor({ + netId, + provider, + graphApi, + subgraphName, + RelayerRegistry, + deployedBlock, + fetchDataOptions, + cacheDirectory, + userDirectory, + }: NodeRegistryServiceConstructor) { + super({ + netId, + provider, + graphApi, + subgraphName, + RelayerRegistry, + deployedBlock, + fetchDataOptions, + }); + + this.cacheDirectory = cacheDirectory; + this.userDirectory = userDirectory; + } + + updateEventProgress({ type, fromBlock, toBlock, count }: Parameters[0]) { + if (toBlock) { + console.log(`fromBlock - ${fromBlock}`); + console.log(`toBlock - ${toBlock}`); + + if (count) { + console.log(`downloaded ${type} events count - ${count}`); + console.log('____________________________________________'); + console.log(`Fetched ${type} events from ${fromBlock} to ${toBlock}\n`); + } + } + } + + updateGraphProgress({ type, fromBlock, toBlock, count }: Parameters[0]) { + if (toBlock) { + console.log(`fromBlock - ${fromBlock}`); + console.log(`toBlock - ${toBlock}`); + + if (count) { + console.log(`downloaded ${type} events from graph node count - ${count}`); + console.log('____________________________________________'); + console.log(`Fetched ${type} events from graph node ${fromBlock} to ${toBlock}\n`); + } + } + } + + async getEventsFromDB() { + if (!this.userDirectory) { + console.log(`Updating events for ${this.netId} chain registry events\n`); + console.log(`savedEvents count - ${0}`); + console.log(`savedEvents lastBlock - ${this.deployedBlock}\n`); + + return { + events: [], + lastBlock: this.deployedBlock, + }; + } + + const savedEvents = await loadSavedEvents({ + name: this.getInstanceName(), + userDirectory: this.userDirectory, + deployedBlock: this.deployedBlock, + }); + + console.log(`Updating events for ${this.netId} chain registry events\n`); + console.log(`savedEvents count - ${savedEvents.events.length}`); + console.log(`savedEvents lastBlock - ${savedEvents.lastBlock}\n`); + + return savedEvents; + } + + async getEventsFromCache() { + if (!this.cacheDirectory) { + console.log(`cachedEvents count - ${0}`); + console.log(`cachedEvents lastBlock - ${this.deployedBlock}\n`); + + return { + events: [], + lastBlock: this.deployedBlock, + }; + } + + const cachedEvents = await loadCachedEvents({ + name: this.getInstanceName(), + cacheDirectory: this.cacheDirectory, + deployedBlock: this.deployedBlock, + }); + + console.log(`cachedEvents count - ${cachedEvents.events.length}`); + console.log(`cachedEvents lastBlock - ${cachedEvents.lastBlock}\n`); + + return cachedEvents; + } + + async saveEvents({ events, lastBlock }: BaseEvents) { + const instanceName = this.getInstanceName(); + + console.log('\ntotalEvents count - ', events.length); + console.log( + `totalEvents lastBlock - ${events[events.length - 1] ? events[events.length - 1].blockNumber : lastBlock}\n`, + ); + + const eventTable = new Table(); + + eventTable.push( + [{ colSpan: 2, content: 'Registered Relayers', hAlign: 'center' }], + ['Network', `${this.netId} chain`], + ['Events', `${events.length} events`], + [{ colSpan: 2, content: 'Latest events' }], + ...events + .slice(events.length - 10) + .reverse() + .map(({ blockNumber }, index) => { + const eventIndex = events.length - index; + + return [eventIndex, blockNumber]; + }), + ); + + console.log(eventTable.toString() + '\n'); + + if (this.userDirectory) { + await saveEvents({ + name: instanceName, + userDirectory: this.userDirectory, + events, + }); + } + } +} diff --git a/src/services/events/types.ts b/src/services/events/types.ts new file mode 100644 index 0000000..00f8ac5 --- /dev/null +++ b/src/services/events/types.ts @@ -0,0 +1,69 @@ +import { RelayerParams } from '../relayerClient'; + +export interface BaseEvents { + events: T[]; + lastBlock: number | null; +} + +export interface BaseGraphEvents { + events: T[]; + lastSyncBlock: number; +} + +export interface MinimalEvents { + blockNumber: number; + logIndex: number; + transactionHash: string; +} + +export type GovernanceEvents = MinimalEvents & { + event: string; +}; + +export type GovernanceProposalCreatedEvents = GovernanceEvents & { + id: number; + proposer: string; + target: string; + startTime: number; + endTime: number; + description: string; +}; + +export type GovernanceVotedEvents = GovernanceEvents & { + proposalId: number; + voter: string; + support: boolean; + votes: string; + from: string; + input: string; +}; + +export type GovernanceDelegatedEvents = GovernanceEvents & { + account: string; + delegateTo: string; +}; + +export type GovernanceUndelegatedEvents = GovernanceEvents & { + account: string; + delegateFrom: string; +}; + +export type RegistersEvents = MinimalEvents & RelayerParams; + +export type DepositsEvents = MinimalEvents & { + commitment: string; + leafIndex: number; + timestamp: number; + from: string; +}; + +export type WithdrawalsEvents = MinimalEvents & { + nullifierHash: string; + to: string; + fee: string; + timestamp: number; +}; + +export type EncryptedNotesEvents = MinimalEvents & { + encryptedNote: string; +}; diff --git a/src/services/fees.ts b/src/services/fees.ts new file mode 100644 index 0000000..d7716bf --- /dev/null +++ b/src/services/fees.ts @@ -0,0 +1,124 @@ +import { Transaction, parseUnits } from 'ethers'; +import type { BigNumberish, TransactionLike } from 'ethers'; +import { OvmGasPriceOracle } from '../typechain'; + +const DUMMY_ADDRESS = '0x1111111111111111111111111111111111111111'; + +const DUMMY_NONCE = '0x1111111111111111111111111111111111111111111111111111111111111111'; + +const DUMMY_WITHDRAW_DATA = + '0x0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111'; + +/** + * Example: + * + * amountInWei (0.1 ETH) * tokenDecimals (18) * tokenPriceInWei (0.0008) = 125 TOKEN + */ +export function convertETHToTokenAmount( + amountInWei: BigNumberish, + tokenPriceInWei: BigNumberish, + tokenDecimals: number = 18, +): bigint { + const tokenDecimalsMultiplier = BigInt(10 ** Number(tokenDecimals)); + return (BigInt(amountInWei) * tokenDecimalsMultiplier) / BigInt(tokenPriceInWei); +} + +export interface RelayerFeeParams { + gasPrice: BigNumberish; + gasLimit?: BigNumberish; + l1Fee?: BigNumberish; + denomination: BigNumberish; + ethRefund: BigNumberish; + tokenPriceInWei: BigNumberish; + tokenDecimals: number; + relayerFeePercent?: number; + isEth?: boolean; + premiumPercent?: number; +} + +export class TornadoFeeOracle { + ovmGasPriceOracle?: OvmGasPriceOracle; + + constructor(ovmGasPriceOracle?: OvmGasPriceOracle) { + if (ovmGasPriceOracle) { + this.ovmGasPriceOracle = ovmGasPriceOracle; + } + } + + /** + * Calculate L1 fee for op-stack chains + * + * This is required since relayers would pay the full transaction fees for users + */ + fetchL1OptimismFee(tx?: TransactionLike): Promise { + if (!this.ovmGasPriceOracle) { + return new Promise((resolve) => resolve(BigInt(0))); + } + + if (!tx) { + // this tx is only used to simulate bytes size of the encoded tx so has nothing to with the accuracy + // inspired by the old style classic-ui calculation + tx = { + type: 0, + gasLimit: 1_000_000, + nonce: Number(DUMMY_NONCE), + data: DUMMY_WITHDRAW_DATA, + gasPrice: parseUnits('1', 'gwei'), + from: DUMMY_ADDRESS, + to: DUMMY_ADDRESS, + }; + } + + return this.ovmGasPriceOracle.getL1Fee.staticCall(Transaction.from(tx).unsignedSerialized); + } + + /** + * We don't need to distinguish default refunds by tokens since most users interact with other defi protocols after withdrawal + * So we default with 1M gas which is enough for two or three swaps + * Using 30 gwei for default but it is recommended to supply cached gasPrice value from the UI + */ + defaultEthRefund(gasPrice?: BigNumberish, gasLimit?: BigNumberish): bigint { + return (gasPrice ? BigInt(gasPrice) : parseUnits('30', 'gwei')) * BigInt(gasLimit || 1_000_000); + } + + /** + * Calculates token amount for required ethRefund purchases required to calculate fees + */ + calculateTokenAmount(ethRefund: BigNumberish, tokenPriceInEth: BigNumberish, tokenDecimals?: number): bigint { + return convertETHToTokenAmount(ethRefund, tokenPriceInEth, tokenDecimals); + } + + /** + * Warning: For tokens you need to check if the fees are above denomination + * (Usually happens for small denomination pool or if the gas price is high) + */ + calculateRelayerFee({ + gasPrice, + gasLimit = 600_000, + l1Fee = 0, + denomination, + ethRefund = BigInt(0), + tokenPriceInWei, + tokenDecimals = 18, + relayerFeePercent = 0.33, + isEth = true, + premiumPercent = 20, + }: RelayerFeeParams): bigint { + const gasCosts = BigInt(gasPrice) * BigInt(gasLimit) + BigInt(l1Fee); + + const relayerFee = (BigInt(denomination) * BigInt(Math.floor(10000 * relayerFeePercent))) / BigInt(10000 * 100); + + if (isEth) { + // Add 20% premium + return ((gasCosts + relayerFee) * BigInt(premiumPercent ? 100 + premiumPercent : 100)) / BigInt(100); + } + + const feeInEth = gasCosts + BigInt(ethRefund); + + return ( + ((convertETHToTokenAmount(feeInEth, tokenPriceInWei, tokenDecimals) + relayerFee) * + BigInt(premiumPercent ? 100 + premiumPercent : 100)) / + BigInt(100) + ); + } +} diff --git a/src/services/graphql/index.ts b/src/services/graphql/index.ts new file mode 100644 index 0000000..2fb69f9 --- /dev/null +++ b/src/services/graphql/index.ts @@ -0,0 +1,784 @@ +import { getAddress } from 'ethers'; +import { fetchData, fetchDataOptions } from '../providers'; + +import type { + BaseGraphEvents, + RegistersEvents, + DepositsEvents, + WithdrawalsEvents, + EncryptedNotesEvents, + BatchGraphOnProgress, +} from '../events'; +import { + _META, + GET_DEPOSITS, + GET_STATISTIC, + GET_REGISTERED, + GET_WITHDRAWALS, + GET_NOTE_ACCOUNTS, + GET_ENCRYPTED_NOTES, +} from './queries'; + +export * from './queries'; + +const isEmptyArray = (arr: object) => !Array.isArray(arr) || !arr.length; + +const first = 1000; + +export type queryGraphParams = { + graphApi: string; + subgraphName: string; + query: string; + variables?: { + [key: string]: string | number; + }; + fetchDataOptions?: fetchDataOptions; +}; + +export async function queryGraph({ + graphApi, + subgraphName, + query, + variables, + fetchDataOptions, +}: queryGraphParams): Promise { + const graphUrl = `${graphApi}/subgraphs/name/${subgraphName}`; + + const { data, errors } = await fetchData(graphUrl, { + ...fetchDataOptions, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + query, + variables, + }), + }); + + if (errors) { + throw new Error(JSON.stringify(errors)); + } + + if (data?._meta?.hasIndexingErrors) { + throw new Error('Subgraph has indexing errors'); + } + + return data; +} + +export interface GraphStatistic { + deposits: { + index: string; + timestamp: string; + blockNumber: string; + }[]; + _meta: { + block: { + number: number; + }; + hasIndexingErrors: boolean; + }; +} + +export interface getStatisticParams { + graphApi: string; + subgraphName: string; + currency: string; + amount: string; + fetchDataOptions?: fetchDataOptions; +} + +export interface getStatisticReturns { + events: { + timestamp: number; + leafIndex: number; + blockNumber: number; + }[]; + lastSyncBlock: null | number; +} + +export async function getStatistic({ + graphApi, + subgraphName, + currency, + amount, + fetchDataOptions, +}: getStatisticParams): Promise { + try { + const { + deposits, + _meta: { + block: { number: lastSyncBlock }, + }, + } = await queryGraph({ + graphApi, + subgraphName, + query: GET_STATISTIC, + variables: { + currency, + first: 10, + orderBy: 'index', + orderDirection: 'desc', + amount, + }, + fetchDataOptions, + }); + + const events = deposits + .map((e) => ({ + timestamp: Number(e.timestamp), + leafIndex: Number(e.index), + blockNumber: Number(e.blockNumber), + })) + .reverse(); + + const [lastEvent] = events.slice(-1); + + return { + events, + lastSyncBlock: lastEvent && lastEvent.blockNumber >= lastSyncBlock ? lastEvent.blockNumber + 1 : lastSyncBlock, + }; + } catch (err) { + console.log('Error from getStatistic query'); + console.log(err); + return { + events: [], + lastSyncBlock: null, + }; + } +} + +export interface GraphMeta { + _meta: { + block: { + number: number; + }; + hasIndexingErrors: boolean; + }; +} + +export interface getMetaParams { + graphApi: string; + subgraphName: string; + fetchDataOptions?: fetchDataOptions; +} + +export interface getMetaReturns { + lastSyncBlock: null | number; + hasIndexingErrors: null | boolean; +} + +export async function getMeta({ graphApi, subgraphName, fetchDataOptions }: getMetaParams): Promise { + try { + const { + _meta: { + block: { number: lastSyncBlock }, + hasIndexingErrors, + }, + } = await queryGraph({ + graphApi, + subgraphName, + query: _META, + fetchDataOptions, + }); + + return { + lastSyncBlock, + hasIndexingErrors, + }; + } catch (err) { + console.log('Error from getMeta query'); + console.log(err); + return { + lastSyncBlock: null, + hasIndexingErrors: null, + }; + } +} + +export interface GraphRegisters { + relayers: { + id: string; + address: string; + ensName: string; + blockRegistration: string; + }[]; + _meta: { + block: { + number: number; + }; + hasIndexingErrors: boolean; + }; +} + +export interface getRegistersParams { + graphApi: string; + subgraphName: string; + fromBlock: number; + fetchDataOptions?: fetchDataOptions; + onProgress?: BatchGraphOnProgress; +} + +export function getRegisters({ + graphApi, + subgraphName, + fromBlock, + fetchDataOptions, +}: getRegistersParams): Promise { + return queryGraph({ + graphApi, + subgraphName, + query: GET_REGISTERED, + variables: { + first, + fromBlock, + }, + fetchDataOptions, + }); +} + +export async function getAllRegisters({ + graphApi, + subgraphName, + fromBlock, + fetchDataOptions, + onProgress, +}: getRegistersParams): Promise> { + try { + const events = []; + let lastSyncBlock = fromBlock; + + // eslint-disable-next-line no-constant-condition + while (true) { + let { + relayers: result, + _meta: { + // eslint-disable-next-line prefer-const + block: { number: currentBlock }, + }, + } = await getRegisters({ graphApi, subgraphName, fromBlock, fetchDataOptions }); + + lastSyncBlock = currentBlock; + + if (isEmptyArray(result)) { + break; + } + + const [firstEvent] = result; + const [lastEvent] = result.slice(-1); + + if (typeof onProgress === 'function') { + onProgress({ + type: 'Registers', + fromBlock: Number(firstEvent.blockRegistration), + toBlock: Number(lastEvent.blockRegistration), + count: result.length, + }); + } + + if (result.length < 900) { + events.push(...result); + break; + } + + result = result.filter(({ blockRegistration }) => blockRegistration !== lastEvent.blockRegistration); + fromBlock = Number(lastEvent.blockRegistration); + + events.push(...result); + } + + if (!events.length) { + return { + events: [], + lastSyncBlock, + }; + } + + const result = events.map(({ id, address, ensName, blockRegistration }) => { + const [transactionHash, logIndex] = id.split('-'); + + return { + blockNumber: Number(blockRegistration), + logIndex: Number(logIndex), + transactionHash, + ensName, + relayerAddress: getAddress(address), + }; + }); + + return { + events: result, + lastSyncBlock, + }; + } catch (err) { + console.log('Error from getAllRegisters query'); + console.log(err); + return { events: [], lastSyncBlock: fromBlock }; + } +} + +export interface GraphDeposits { + deposits: { + id: string; + blockNumber: string; + commitment: string; + index: string; + timestamp: string; + from: string; + }[]; + _meta: { + block: { + number: number; + }; + hasIndexingErrors: boolean; + }; +} + +export interface getDepositsParams { + graphApi: string; + subgraphName: string; + currency: string; + amount: string; + fromBlock: number; + fetchDataOptions?: fetchDataOptions; + onProgress?: BatchGraphOnProgress; +} + +export function getDeposits({ + graphApi, + subgraphName, + currency, + amount, + fromBlock, + fetchDataOptions, +}: getDepositsParams): Promise { + return queryGraph({ + graphApi, + subgraphName, + query: GET_DEPOSITS, + variables: { + currency, + amount, + first, + fromBlock, + }, + fetchDataOptions, + }); +} + +export async function getAllDeposits({ + graphApi, + subgraphName, + currency, + amount, + fromBlock, + fetchDataOptions, + onProgress, +}: getDepositsParams): Promise> { + try { + const events = []; + let lastSyncBlock = fromBlock; + + // eslint-disable-next-line no-constant-condition + while (true) { + let { + deposits: result, + _meta: { + // eslint-disable-next-line prefer-const + block: { number: currentBlock }, + }, + } = await getDeposits({ graphApi, subgraphName, currency, amount, fromBlock, fetchDataOptions }); + + lastSyncBlock = currentBlock; + + if (isEmptyArray(result)) { + break; + } + + const [firstEvent] = result; + const [lastEvent] = result.slice(-1); + + if (typeof onProgress === 'function') { + onProgress({ + type: 'Deposits', + fromBlock: Number(firstEvent.blockNumber), + toBlock: Number(lastEvent.blockNumber), + count: result.length, + }); + } + + if (result.length < 900) { + events.push(...result); + break; + } + + result = result.filter(({ blockNumber }) => blockNumber !== lastEvent.blockNumber); + fromBlock = Number(lastEvent.blockNumber); + + events.push(...result); + } + + if (!events.length) { + return { + events: [], + lastSyncBlock, + }; + } + + const result = events.map(({ id, blockNumber, commitment, index, timestamp, from }) => { + const [transactionHash, logIndex] = id.split('-'); + + return { + blockNumber: Number(blockNumber), + logIndex: Number(logIndex), + transactionHash, + commitment, + leafIndex: Number(index), + timestamp: Number(timestamp), + from: getAddress(from), + }; + }); + + const [lastEvent] = result.slice(-1); + + return { + events: result, + lastSyncBlock: lastEvent && lastEvent.blockNumber >= lastSyncBlock ? lastEvent.blockNumber + 1 : lastSyncBlock, + }; + } catch (err) { + console.log('Error from getAllDeposits query'); + console.log(err); + return { + events: [], + lastSyncBlock: fromBlock, + }; + } +} + +export interface GraphWithdrawals { + withdrawals: { + id: string; + blockNumber: string; + nullifier: string; + to: string; + fee: string; + timestamp: string; + }[]; + _meta: { + block: { + number: number; + }; + hasIndexingErrors: boolean; + }; +} + +export interface getWithdrawalParams { + graphApi: string; + subgraphName: string; + currency: string; + amount: string; + fromBlock: number; + fetchDataOptions?: fetchDataOptions; + onProgress?: BatchGraphOnProgress; +} + +export function getWithdrawals({ + graphApi, + subgraphName, + currency, + amount, + fromBlock, + fetchDataOptions, +}: getWithdrawalParams): Promise { + return queryGraph({ + graphApi, + subgraphName, + query: GET_WITHDRAWALS, + variables: { + currency, + amount, + first, + fromBlock, + }, + fetchDataOptions, + }); +} + +export async function getAllWithdrawals({ + graphApi, + subgraphName, + currency, + amount, + fromBlock, + fetchDataOptions, + onProgress, +}: getWithdrawalParams): Promise> { + try { + const events = []; + let lastSyncBlock = fromBlock; + + // eslint-disable-next-line no-constant-condition + while (true) { + let { + withdrawals: result, + _meta: { + // eslint-disable-next-line prefer-const + block: { number: currentBlock }, + }, + } = await getWithdrawals({ graphApi, subgraphName, currency, amount, fromBlock, fetchDataOptions }); + + lastSyncBlock = currentBlock; + + if (isEmptyArray(result)) { + break; + } + + const [firstEvent] = result; + const [lastEvent] = result.slice(-1); + + if (typeof onProgress === 'function') { + onProgress({ + type: 'Withdrawals', + fromBlock: Number(firstEvent.blockNumber), + toBlock: Number(lastEvent.blockNumber), + count: result.length, + }); + } + + if (result.length < 900) { + events.push(...result); + break; + } + + result = result.filter(({ blockNumber }) => blockNumber !== lastEvent.blockNumber); + fromBlock = Number(lastEvent.blockNumber); + + events.push(...result); + } + + if (!events.length) { + return { + events: [], + lastSyncBlock, + }; + } + + const result = events.map(({ id, blockNumber, nullifier, to, fee, timestamp }) => { + const [transactionHash, logIndex] = id.split('-'); + + return { + blockNumber: Number(blockNumber), + logIndex: Number(logIndex), + transactionHash, + nullifierHash: nullifier, + to: getAddress(to), + fee, + timestamp: Number(timestamp), + }; + }); + + const [lastEvent] = result.slice(-1); + + return { + events: result, + lastSyncBlock: lastEvent && lastEvent.blockNumber >= lastSyncBlock ? lastEvent.blockNumber + 1 : lastSyncBlock, + }; + } catch (err) { + console.log('Error from getAllWithdrawals query'); + console.log(err); + return { + events: [], + lastSyncBlock: fromBlock, + }; + } +} + +export interface GraphNoteAccounts { + noteAccounts: { + id: string; + index: string; + address: string; + encryptedAccount: string; + }[]; + _meta: { + block: { + number: number; + }; + hasIndexingErrors: boolean; + }; +} + +export interface getNoteAccountsParams { + graphApi: string; + subgraphName: string; + address: string; + fetchDataOptions?: fetchDataOptions; +} + +export interface getNoteAccountsReturns { + events: { + id: string; + index: string; + address: string; + encryptedAccount: string; + }[]; + lastSyncBlock: null | number; +} + +export async function getNoteAccounts({ + graphApi, + subgraphName, + address, + fetchDataOptions, +}: getNoteAccountsParams): Promise { + try { + const { + noteAccounts: events, + _meta: { + block: { number: lastSyncBlock }, + }, + } = await queryGraph({ + graphApi, + subgraphName, + query: GET_NOTE_ACCOUNTS, + variables: { + address, + }, + fetchDataOptions, + }); + + return { + events, + lastSyncBlock, + }; + } catch (err) { + console.log('Error from getNoteAccounts query'); + console.log(err); + return { + events: [], + lastSyncBlock: null, + }; + } +} + +export interface GraphEncryptedNotes { + encryptedNotes: { + blockNumber: string; + index: string; + transactionHash: string; + encryptedNote: string; + }[]; + _meta: { + block: { + number: number; + }; + hasIndexingErrors: boolean; + }; +} + +export interface getEncryptedNotesParams { + graphApi: string; + subgraphName: string; + fromBlock: number; + fetchDataOptions?: fetchDataOptions; + onProgress?: BatchGraphOnProgress; +} + +export function getEncryptedNotes({ + graphApi, + subgraphName, + fromBlock, + fetchDataOptions, +}: getEncryptedNotesParams): Promise { + return queryGraph({ + graphApi, + subgraphName, + query: GET_ENCRYPTED_NOTES, + variables: { + first, + fromBlock, + }, + fetchDataOptions, + }); +} + +export async function getAllEncryptedNotes({ + graphApi, + subgraphName, + fromBlock, + fetchDataOptions, + onProgress, +}: getEncryptedNotesParams): Promise> { + try { + const events = []; + let lastSyncBlock = fromBlock; + + // eslint-disable-next-line no-constant-condition + while (true) { + let { + encryptedNotes: result, + _meta: { + // eslint-disable-next-line prefer-const + block: { number: currentBlock }, + }, + } = await getEncryptedNotes({ graphApi, subgraphName, fromBlock, fetchDataOptions }); + + lastSyncBlock = currentBlock; + + if (isEmptyArray(result)) { + break; + } + + const [firstEvent] = result; + const [lastEvent] = result.slice(-1); + + if (typeof onProgress === 'function') { + onProgress({ + type: 'EncryptedNotes', + fromBlock: Number(firstEvent.blockNumber), + toBlock: Number(lastEvent.blockNumber), + count: result.length, + }); + } + + if (result.length < 900) { + events.push(...result); + break; + } + + result = result.filter(({ blockNumber }) => blockNumber !== lastEvent.blockNumber); + fromBlock = Number(lastEvent.blockNumber); + + events.push(...result); + } + + if (!events.length) { + return { + events: [], + lastSyncBlock, + }; + } + + const result = events.map((e) => ({ + blockNumber: Number(e.blockNumber), + logIndex: Number(e.index), + transactionHash: e.transactionHash, + encryptedNote: e.encryptedNote, + })); + + const [lastEvent] = result.slice(-1); + + return { + events: result, + lastSyncBlock: lastEvent && lastEvent.blockNumber >= lastSyncBlock ? lastEvent.blockNumber + 1 : lastSyncBlock, + }; + } catch (err) { + console.log('Error from getAllEncryptedNotes query'); + console.log(err); + return { + events: [], + lastSyncBlock: fromBlock, + }; + } +} diff --git a/src/services/graphql/queries.ts b/src/services/graphql/queries.ts new file mode 100644 index 0000000..fcb3a97 --- /dev/null +++ b/src/services/graphql/queries.ts @@ -0,0 +1,125 @@ +export const GET_STATISTIC = ` + query getStatistic($currency: String!, $amount: String!, $first: Int, $orderBy: BigInt, $orderDirection: String) { + deposits(first: $first, orderBy: $orderBy, orderDirection: $orderDirection, where: { currency: $currency, amount: $amount }) { + index + timestamp + blockNumber + } + _meta { + block { + number + } + hasIndexingErrors + } + } +`; + +export const _META = ` + query getMeta { + _meta { + block { + number + } + hasIndexingErrors + } + } +`; + +export const GET_REGISTERED = ` + query getRegistered($first: Int, $fromBlock: Int) { + relayers(first: $first, orderBy: blockRegistration, orderDirection: asc, where: { + blockRegistration_gte: $fromBlock + }) { + id + address + ensName + blockRegistration + } + _meta { + block { + number + } + hasIndexingErrors + } + } +`; + +export const GET_DEPOSITS = ` + query getDeposits($currency: String!, $amount: String!, $first: Int, $fromBlock: Int) { + deposits(first: $first, orderBy: index, orderDirection: asc, where: { + amount: $amount, + currency: $currency, + blockNumber_gte: $fromBlock + }) { + id + blockNumber + commitment + index + timestamp + from + } + _meta { + block { + number + } + hasIndexingErrors + } + } +`; + +export const GET_WITHDRAWALS = ` + query getWithdrawals($currency: String!, $amount: String!, $first: Int, $fromBlock: Int!) { + withdrawals(first: $first, orderBy: blockNumber, orderDirection: asc, where: { + currency: $currency, + amount: $amount, + blockNumber_gte: $fromBlock + }) { + id + blockNumber + nullifier + to + fee + timestamp + } + _meta { + block { + number + } + hasIndexingErrors + } + } +`; + +export const GET_NOTE_ACCOUNTS = ` + query getNoteAccount($address: String!) { + noteAccounts(where: { address: $address }) { + id + index + address + encryptedAccount + } + _meta { + block { + number + } + hasIndexingErrors + } + } +`; + +export const GET_ENCRYPTED_NOTES = ` + query getEncryptedNotes($first: Int, $fromBlock: Int) { + encryptedNotes(first: $first, orderBy: blockNumber, orderDirection: asc, where: { blockNumber_gte: $fromBlock }) { + blockNumber + index + transactionHash + encryptedNote + } + _meta { + block { + number + } + hasIndexingErrors + } + } +`; diff --git a/src/services/index.ts b/src/services/index.ts new file mode 100644 index 0000000..764eed9 --- /dev/null +++ b/src/services/index.ts @@ -0,0 +1,19 @@ +export * from './events'; +export * from './graphql'; +export * from './schemas'; +export * from './batch'; +export * from './data'; +export * from './deposits'; +export * from './fees'; +export * from './merkleTree'; +export * from './mimc'; +export * from './multicall'; +export * from './networkConfig'; +export * from './parser'; +export * from './pedersen'; +export * from './prices'; +export * from './providers'; +export * from './relayerClient'; +export * from './tokens'; +export * from './utils'; +export * from './websnark'; diff --git a/src/services/merkleTree.ts b/src/services/merkleTree.ts new file mode 100644 index 0000000..b186119 --- /dev/null +++ b/src/services/merkleTree.ts @@ -0,0 +1,135 @@ +import { Worker as NodeWorker } from 'worker_threads'; +import { MerkleTree, Element } from '@tornado/fixed-merkle-tree'; +import type { Tornado } from '@tornado/contracts'; +import { isNode, toFixedHex } from './utils'; +import { mimc } from './mimc'; +import type { DepositType } from './deposits'; +import type { DepositsEvents } from './events'; + +export type MerkleTreeConstructor = DepositType & { + Tornado: Tornado; + commitment?: string; + merkleTreeHeight?: number; + emptyElement?: string; + merkleWorkerPath?: string; +}; + +export class MerkleTreeService { + currency: string; + amount: string; + netId: number; + Tornado: Tornado; + commitment?: string; + instanceName: string; + + merkleTreeHeight: number; + emptyElement: string; + + merkleWorkerPath?: string; + + constructor({ + netId, + amount, + currency, + Tornado, + commitment, + merkleTreeHeight = 20, + emptyElement = '21663839004416932945382355908790599225266501822907911457504978515578255421292', + merkleWorkerPath, + }: MerkleTreeConstructor) { + const instanceName = `${netId}_${currency}_${amount}`; + + this.currency = currency; + this.amount = amount; + this.netId = Number(netId); + + this.Tornado = Tornado; + this.instanceName = instanceName; + this.commitment = commitment; + + this.merkleTreeHeight = merkleTreeHeight; + this.emptyElement = emptyElement; + this.merkleWorkerPath = merkleWorkerPath; + } + + async createTree({ events }: { events: Element[] }) { + const { hash: hashFunction } = await mimc.getHash(); + + if (this.merkleWorkerPath) { + console.log('Using merkleWorker\n'); + + try { + if (isNode) { + const merkleWorkerPromise = new Promise((resolve, reject) => { + const worker = new NodeWorker(this.merkleWorkerPath as string, { + workerData: { + merkleTreeHeight: this.merkleTreeHeight, + elements: events, + zeroElement: this.emptyElement, + }, + }); + worker.on('message', resolve); + worker.on('error', reject); + worker.on('exit', (code) => { + if (code !== 0) { + reject(new Error(`Worker stopped with exit code ${code}`)); + } + }); + }) as Promise; + + return MerkleTree.deserialize(JSON.parse(await merkleWorkerPromise), hashFunction); + } else { + const merkleWorkerPromise = new Promise((resolve, reject) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const worker = new (Worker as any)(this.merkleWorkerPath); + + worker.onmessage = (e: { data: string }) => { + resolve(e.data); + }; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + worker.onerror = (e: any) => { + reject(e); + }; + + worker.postMessage({ + merkleTreeHeight: this.merkleTreeHeight, + elements: events, + zeroElement: this.emptyElement, + }); + }) as Promise; + + return MerkleTree.deserialize(JSON.parse(await merkleWorkerPromise), hashFunction); + } + } catch (err) { + console.log('merkleWorker failed, falling back to synchronous merkle tree'); + console.log(err); + } + } + + return new MerkleTree(this.merkleTreeHeight, events, { + zeroElement: this.emptyElement, + hashFunction, + }); + } + + async verifyTree({ events }: { events: DepositsEvents[] }) { + console.log( + `\nCreating deposit tree for ${this.netId} ${this.amount} ${this.currency.toUpperCase()} would take a while\n`, + ); + + console.time('Created tree in'); + const tree = await this.createTree({ events: events.map(({ commitment }) => BigInt(commitment).toString()) }); + console.timeEnd('Created tree in'); + console.log(''); + + const isKnownRoot = await this.Tornado.isKnownRoot(toFixedHex(BigInt(tree.root))); + + if (!isKnownRoot) { + const errMsg = `Deposit Event ${this.netId} ${this.amount} ${this.currency} is invalid`; + throw new Error(errMsg); + } + + return tree; + } +} diff --git a/src/services/mimc.ts b/src/services/mimc.ts new file mode 100644 index 0000000..e731137 --- /dev/null +++ b/src/services/mimc.ts @@ -0,0 +1,28 @@ +import { MimcSponge, buildMimcSponge } from 'circomlibjs'; +import type { Element, HashFunction } from '@tornado/fixed-merkle-tree'; + +export class Mimc { + sponge?: MimcSponge; + hash?: HashFunction; + mimcPromise: Promise; + + constructor() { + this.mimcPromise = this.initMimc(); + } + + async initMimc() { + this.sponge = await buildMimcSponge(); + this.hash = (left, right) => this.sponge?.F.toString(this.sponge?.multiHash([BigInt(left), BigInt(right)])); + } + + async getHash() { + await this.mimcPromise; + + return { + sponge: this.sponge, + hash: this.hash, + }; + } +} + +export const mimc = new Mimc(); diff --git a/src/services/multicall.ts b/src/services/multicall.ts new file mode 100644 index 0000000..61c0ba2 --- /dev/null +++ b/src/services/multicall.ts @@ -0,0 +1,37 @@ +import { BaseContract, Interface } from 'ethers'; +import { Multicall } from '../typechain'; + +export interface Call3 { + contract?: BaseContract; + address?: string; + interface?: Interface; + name: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + params?: any[]; + allowFailure?: boolean; +} + +export async function multicall(Multicall: Multicall, calls: Call3[]) { + const calldata = calls.map((call) => { + const target = (call.contract?.target || call.address) as string; + const callInterface = (call.contract?.interface || call.interface) as Interface; + + return { + target, + callData: callInterface.encodeFunctionData(call.name, call.params), + allowFailure: call.allowFailure ?? false, + }; + }); + + const returnData = await Multicall.aggregate3.staticCall(calldata); + + const res = returnData.map((call, i) => { + const callInterface = (calls[i].contract?.interface || calls[i].interface) as Interface; + const [result, data] = call; + const decodeResult = + result && data && data !== '0x' ? callInterface.decodeFunctionResult(calls[i].name, data) : null; + return !decodeResult ? null : decodeResult.length === 1 ? decodeResult[0] : decodeResult; + }); + + return res; +} diff --git a/src/services/networkConfig.ts b/src/services/networkConfig.ts new file mode 100644 index 0000000..425a969 --- /dev/null +++ b/src/services/networkConfig.ts @@ -0,0 +1,741 @@ +export interface RpcUrl { + name: string; + url: string; +} + +export type RpcUrls = { + [key in string]: RpcUrl; +}; + +export interface SubgraphUrl { + name: string; + url: string; +} + +export type SubgraphUrls = { + [key in string]: SubgraphUrl; +}; + +export type TornadoInstance = { + instanceAddress: { + [key in string]: string; + }; + optionalInstances?: string[]; + tokenAddress?: string; + tokenGasLimit?: number; + symbol: string; + decimals: number; + gasLimit?: number; +}; + +export type TokenInstances = { + [key in string]: TornadoInstance; +}; + +export type Config = { + rpcCallRetryAttempt?: number; + // Should be in gwei + gasPrices: { + // fallback gasPrice / maxFeePerGas value + instant: number; + fast?: number; + standard?: number; + low?: number; + // fallback EIP-1559 params + maxPriorityFeePerGas?: number; + }; + nativeCurrency: string; + currencyName: string; + explorerUrl: { + tx: string; + address: string; + block: string; + }; + merkleTreeHeight: number; + emptyElement: string; + networkName: string; + deployedBlock: number; + rpcUrls: RpcUrls; + multicall: string; + routerContract: string; + registryContract?: string; + echoContract: string; + aggregatorContract?: string; + reverseRecordsContract?: string; + gasPriceOracleContract?: string; + gasStationApi?: string; + ovmGasPriceOracleContract?: string; + tornadoSubgraph: string; + registrySubgraph?: string; + subgraphs: SubgraphUrls; + tokens: TokenInstances; + optionalTokens?: string[]; + ensSubdomainKey: string; + // Should be in seconds + pollInterval: number; + constants: { + GOVERNANCE_BLOCK?: number; + NOTE_ACCOUNT_BLOCK?: number; + ENCRYPTED_NOTES_BLOCK?: number; + REGISTRY_BLOCK?: number; + // Should be in seconds + MINING_BLOCK_TIME?: number; + }; + 'torn.contract.tornadocash.eth'?: string; + 'governance.contract.tornadocash.eth'?: string; + 'staking-rewards.contract.tornadocash.eth'?: string; + 'tornado-router.contract.tornadocash.eth'?: string; + 'tornado-proxy-light.contract.tornadocash.eth'?: string; +}; + +export type networkConfig = { + [key in string]: Config; +}; + +export const blockSyncInterval = 10000; +export const enabledChains = ['1', '10', '56', '100', '137', '42161', '43114', '11155111']; + +const theGraph = { + name: 'Hosted Graph', + url: 'https://api.thegraph.com', +}; +const tornado = { + name: 'Tornado Subgraphs', + url: 'https://tornadocash-rpc.com', +}; + +export const networkConfig: networkConfig = { + netId1: { + rpcCallRetryAttempt: 15, + gasPrices: { + instant: 80, + fast: 50, + standard: 25, + low: 8, + }, + nativeCurrency: 'eth', + currencyName: 'ETH', + explorerUrl: { + tx: 'https://etherscan.io/tx/', + address: 'https://etherscan.io/address/', + block: 'https://etherscan.io/block/', + }, + merkleTreeHeight: 20, + emptyElement: '21663839004416932945382355908790599225266501822907911457504978515578255421292', + networkName: 'Ethereum Mainnet', + deployedBlock: 9116966, + rpcUrls: { + tornado: { + name: 'Tornado RPC', + url: 'https://tornadocash-rpc.com', + }, + chainnodes: { + name: 'Tornado RPC', + url: 'https://mainnet.chainnodes.org/d692ae63-0a7e-43e0-9da9-fe4f4cc6c607', + }, + mevblockerRPC: { + name: 'MevblockerRPC', + url: 'https://rpc.mevblocker.io', + }, + stackup: { + name: 'Stackup RPC', + url: 'https://public.stackup.sh/api/v1/node/ethereum-mainnet', + }, + noderealRPC: { + name: 'NodeReal RPC', + url: 'https://eth-mainnet.nodereal.io/v1/1659dfb40aa24bbb8153a677b98064d7', + }, + notadegenRPC: { + name: 'NotADegen RPC', + url: 'https://rpc.notadegen.com/eth', + }, + keydonixRPC: { + name: 'Keydonix RPC', + url: 'https://ethereum.keydonix.com/v1/mainnet', + }, + oneRPC: { + name: '1RPC', + url: 'https://1rpc.io/eth', + }, + }, + multicall: '0xcA11bde05977b3631167028862bE2a173976CA11', + routerContract: '0xd90e2f925DA726b50C4Ed8D0Fb90Ad053324F31b', + registryContract: '0x58E8dCC13BE9780fC42E8723D8EaD4CF46943dF2', + echoContract: '0x9B27DD5Bb15d42DC224FCD0B7caEbBe16161Df42', + aggregatorContract: '0xE8F47A78A6D52D317D0D2FFFac56739fE14D1b49', + reverseRecordsContract: '0x3671aE578E63FdF66ad4F3E12CC0c0d71Ac7510C', + tornadoSubgraph: 'tornadocash/mainnet-tornado-subgraph', + registrySubgraph: 'tornadocash/tornado-relayer-registry', + subgraphs: { + tornado, + theGraph, + }, + tokens: { + eth: { + instanceAddress: { + '0.1': '0x12D66f87A04A9E220743712cE6d9bB1B5616B8Fc', + '1': '0x47CE0C6eD5B0Ce3d3A51fdb1C52DC66a7c3c2936', + '10': '0x910Cbd523D972eb0a6f4cAe4618aD62622b39DbF', + '100': '0xA160cdAB225685dA1d56aa342Ad8841c3b53f291', + }, + symbol: 'ETH', + decimals: 18, + }, + dai: { + instanceAddress: { + '100': '0xD4B88Df4D29F5CedD6857912842cff3b20C8Cfa3', + '1000': '0xFD8610d20aA15b7B2E3Be39B396a1bC3516c7144', + '10000': '0x07687e702b410Fa43f4cB4Af7FA097918ffD2730', + '100000': '0x23773E65ed146A459791799d01336DB287f25334', + }, + tokenAddress: '0x6B175474E89094C44Da98b954EedeAC495271d0F', + tokenGasLimit: 70_000, + symbol: 'DAI', + decimals: 18, + gasLimit: 700_000, + }, + cdai: { + instanceAddress: { + '5000': '0x22aaA7720ddd5388A3c0A3333430953C68f1849b', + '50000': '0x03893a7c7463AE47D46bc7f091665f1893656003', + '500000': '0x2717c5e28cf931547B621a5dddb772Ab6A35B701', + '5000000': '0xD21be7248e0197Ee08E0c20D4a96DEBdaC3D20Af', + }, + tokenAddress: '0x5d3a536E4D6DbD6114cc1Ead35777bAB948E3643', + tokenGasLimit: 200_000, + symbol: 'cDAI', + decimals: 8, + gasLimit: 700_000, + }, + usdc: { + instanceAddress: { + '100': '0xd96f2B1c14Db8458374d9Aca76E26c3D18364307', + '1000': '0x4736dCf1b7A3d580672CcE6E7c65cd5cc9cFBa9D', + }, + tokenAddress: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + tokenGasLimit: 70_000, + symbol: 'USDC', + decimals: 6, + gasLimit: 700_000, + }, + usdt: { + instanceAddress: { + '100': '0x169AD27A470D064DEDE56a2D3ff727986b15D52B', + '1000': '0x0836222F2B2B24A3F36f98668Ed8F0B38D1a872f', + }, + tokenAddress: '0xdAC17F958D2ee523a2206206994597C13D831ec7', + tokenGasLimit: 70_000, + symbol: 'USDT', + decimals: 6, + gasLimit: 700_000, + }, + wbtc: { + instanceAddress: { + '0.1': '0x178169B423a011fff22B9e3F3abeA13414dDD0F1', + '1': '0x610B717796ad172B316836AC95a2ffad065CeaB4', + '10': '0xbB93e510BbCD0B7beb5A853875f9eC60275CF498', + }, + tokenAddress: '0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599', + tokenGasLimit: 70_000, + symbol: 'WBTC', + decimals: 8, + gasLimit: 700_000, + }, + }, + ensSubdomainKey: 'mainnet-tornado', + pollInterval: 15, + constants: { + GOVERNANCE_BLOCK: 11474695, + NOTE_ACCOUNT_BLOCK: 11842486, + ENCRYPTED_NOTES_BLOCK: 14248730, + REGISTRY_BLOCK: 14173129, + MINING_BLOCK_TIME: 15, + }, + 'torn.contract.tornadocash.eth': '0x77777FeDdddFfC19Ff86DB637967013e6C6A116C', + 'governance.contract.tornadocash.eth': '0x5efda50f22d34F262c29268506C5Fa42cB56A1Ce', + 'tornado-router.contract.tornadocash.eth': '0xd90e2f925DA726b50C4Ed8D0Fb90Ad053324F31b', + 'staking-rewards.contract.tornadocash.eth': '0x5B3f656C80E8ddb9ec01Dd9018815576E9238c29', + }, + netId56: { + rpcCallRetryAttempt: 15, + gasPrices: { + instant: 5, + fast: 5, + standard: 5, + low: 5, + }, + nativeCurrency: 'bnb', + currencyName: 'BNB', + explorerUrl: { + tx: 'https://bscscan.com/tx/', + address: 'https://bscscan.com/address/', + block: 'https://bscscan.com/block/', + }, + merkleTreeHeight: 20, + emptyElement: '21663839004416932945382355908790599225266501822907911457504978515578255421292', + networkName: 'Binance Smart Chain', + deployedBlock: 8158799, + multicall: '0xcA11bde05977b3631167028862bE2a173976CA11', + echoContract: '0xa75BF2815618872f155b7C4B0C81bF990f5245E4', + routerContract: '0x0D5550d52428E7e3175bfc9550207e4ad3859b17', + tornadoSubgraph: 'tornadocash/bsc-tornado-subgraph', + subgraphs: { + tornado, + theGraph, + }, + rpcUrls: { + tornado: { + name: 'Tornado RPC', + url: 'https://tornadocash-rpc.com/bsc', + }, + chainnodes: { + name: 'Tornado RPC', + url: 'https://bsc-mainnet.chainnodes.org/d692ae63-0a7e-43e0-9da9-fe4f4cc6c607', + }, + stackup: { + name: 'Stackup RPC', + url: 'https://public.stackup.sh/api/v1/node/bsc-mainnet', + }, + noderealRPC: { + name: 'NodeReal RPC', + url: 'https://bsc-mainnet.nodereal.io/v1/64a9df0874fb4a93b9d0a3849de012d3', + }, + oneRPC: { + name: '1RPC', + url: 'https://1rpc.io/bnb', + }, + }, + tokens: { + bnb: { + instanceAddress: { + '0.1': '0x84443CFd09A48AF6eF360C6976C5392aC5023a1F', + '1': '0xd47438C816c9E7f2E2888E060936a499Af9582b3', + '10': '0x330bdFADE01eE9bF63C209Ee33102DD334618e0a', + '100': '0x1E34A77868E19A6647b1f2F47B51ed72dEDE95DD', + }, + symbol: 'BNB', + decimals: 18, + }, + }, + ensSubdomainKey: 'bsc-tornado', + pollInterval: 10, + constants: { + NOTE_ACCOUNT_BLOCK: 8159269, + ENCRYPTED_NOTES_BLOCK: 8159269, + }, + 'tornado-proxy-light.contract.tornadocash.eth': '0x0D5550d52428E7e3175bfc9550207e4ad3859b17', + }, + netId137: { + rpcCallRetryAttempt: 15, + gasPrices: { + instant: 100, + fast: 75, + standard: 50, + low: 30, + }, + nativeCurrency: 'matic', + currencyName: 'MATIC', + explorerUrl: { + tx: 'https://polygonscan.com/tx/', + address: 'https://polygonscan.com/address/', + block: 'https://polygonscan.com/block/', + }, + merkleTreeHeight: 20, + emptyElement: '21663839004416932945382355908790599225266501822907911457504978515578255421292', + networkName: 'Polygon (Matic) Network', + deployedBlock: 16257962, + multicall: '0xcA11bde05977b3631167028862bE2a173976CA11', + echoContract: '0xa75BF2815618872f155b7C4B0C81bF990f5245E4', + routerContract: '0x0D5550d52428E7e3175bfc9550207e4ad3859b17', + gasPriceOracleContract: '0xF81A8D8D3581985D3969fe53bFA67074aDFa8F3C', + tornadoSubgraph: 'tornadocash/matic-tornado-subgraph', + subgraphs: { + tornado, + theGraph, + }, + rpcUrls: { + chainnodes: { + name: 'Tornado RPC', + url: 'https://polygon-mainnet.chainnodes.org/d692ae63-0a7e-43e0-9da9-fe4f4cc6c607', + }, + stackup: { + name: 'Stackup RPC', + url: 'https://public.stackup.sh/api/v1/node/polygon-mainnet', + }, + oneRpc: { + name: '1RPC', + url: 'https://1rpc.io/matic', + }, + }, + tokens: { + matic: { + instanceAddress: { + '100': '0x1E34A77868E19A6647b1f2F47B51ed72dEDE95DD', + '1000': '0xdf231d99Ff8b6c6CBF4E9B9a945CBAcEF9339178', + '10000': '0xaf4c0B70B2Ea9FB7487C7CbB37aDa259579fe040', + '100000': '0xa5C2254e4253490C54cef0a4347fddb8f75A4998', + }, + symbol: 'MATIC', + decimals: 18, + }, + }, + ensSubdomainKey: 'polygon-tornado', + pollInterval: 10, + constants: { + NOTE_ACCOUNT_BLOCK: 16257996, + ENCRYPTED_NOTES_BLOCK: 16257996, + }, + 'tornado-proxy-light.contract.tornadocash.eth': '0x0D5550d52428E7e3175bfc9550207e4ad3859b17', + }, + netId10: { + rpcCallRetryAttempt: 15, + gasPrices: { + instant: 0.001, + fast: 0.001, + standard: 0.001, + low: 0.001, + }, + nativeCurrency: 'eth', + currencyName: 'ETH', + explorerUrl: { + tx: 'https://optimistic.etherscan.io/tx/', + address: 'https://optimistic.etherscan.io/address/', + block: 'https://optimistic.etherscan.io/block/', + }, + merkleTreeHeight: 20, + emptyElement: '21663839004416932945382355908790599225266501822907911457504978515578255421292', + networkName: 'Optimism', + deployedBlock: 2243689, + multicall: '0xcA11bde05977b3631167028862bE2a173976CA11', + echoContract: '0xa75BF2815618872f155b7C4B0C81bF990f5245E4', + routerContract: '0x0D5550d52428E7e3175bfc9550207e4ad3859b17', + ovmGasPriceOracleContract: '0x420000000000000000000000000000000000000F', + tornadoSubgraph: 'tornadocash/optimism-tornado-subgraph', + subgraphs: { + tornado, + theGraph, + }, + rpcUrls: { + tornado: { + name: 'Tornado RPC', + url: 'https://tornadocash-rpc.com/op', + }, + chainnodes: { + name: 'Tornado RPC', + url: 'https://optimism-mainnet.chainnodes.org/d692ae63-0a7e-43e0-9da9-fe4f4cc6c607', + }, + optimism: { + name: 'Optimism RPC', + url: 'https://mainnet.optimism.io', + }, + stackup: { + name: 'Stackup RPC', + url: 'https://public.stackup.sh/api/v1/node/optimism-mainnet', + }, + oneRpc: { + name: '1RPC', + url: 'https://1rpc.io/op', + }, + }, + tokens: { + eth: { + instanceAddress: { + '0.1': '0x84443CFd09A48AF6eF360C6976C5392aC5023a1F', + '1': '0xd47438C816c9E7f2E2888E060936a499Af9582b3', + '10': '0x330bdFADE01eE9bF63C209Ee33102DD334618e0a', + '100': '0x1E34A77868E19A6647b1f2F47B51ed72dEDE95DD', + }, + symbol: 'ETH', + decimals: 18, + }, + }, + ensSubdomainKey: 'optimism-tornado', + pollInterval: 15, + constants: { + NOTE_ACCOUNT_BLOCK: 2243694, + ENCRYPTED_NOTES_BLOCK: 2243694, + }, + 'tornado-proxy-light.contract.tornadocash.eth': '0x0D5550d52428E7e3175bfc9550207e4ad3859b17', + }, + netId42161: { + rpcCallRetryAttempt: 15, + gasPrices: { + instant: 4, + fast: 3, + standard: 2.52, + low: 2.29, + }, + nativeCurrency: 'eth', + currencyName: 'ETH', + explorerUrl: { + tx: 'https://arbiscan.io/tx/', + address: 'https://arbiscan.io/address/', + block: 'https://arbiscan.io/block/', + }, + merkleTreeHeight: 20, + emptyElement: '21663839004416932945382355908790599225266501822907911457504978515578255421292', + networkName: 'Arbitrum One', + deployedBlock: 3430648, + multicall: '0xcA11bde05977b3631167028862bE2a173976CA11', + echoContract: '0xa75BF2815618872f155b7C4B0C81bF990f5245E4', + routerContract: '0x0D5550d52428E7e3175bfc9550207e4ad3859b17', + tornadoSubgraph: 'tornadocash/arbitrum-tornado-subgraph', + subgraphs: { + tornado, + theGraph, + }, + rpcUrls: { + tornado: { + name: 'Tornado RPC', + url: 'https://tornadocash-rpc.com/arbitrum', + }, + chainnodes: { + name: 'Tornado RPC', + url: 'https://arbitrum-one.chainnodes.org/d692ae63-0a7e-43e0-9da9-fe4f4cc6c607', + }, + arbitrum: { + name: 'Arbitrum RPC', + url: 'https://arb1.arbitrum.io/rpc', + }, + stackup: { + name: 'Stackup RPC', + url: 'https://public.stackup.sh/api/v1/node/arbitrum-one', + }, + oneRpc: { + name: '1rpc', + url: 'https://1rpc.io/arb', + }, + }, + tokens: { + eth: { + instanceAddress: { + '0.1': '0x84443CFd09A48AF6eF360C6976C5392aC5023a1F', + '1': '0xd47438C816c9E7f2E2888E060936a499Af9582b3', + '10': '0x330bdFADE01eE9bF63C209Ee33102DD334618e0a', + '100': '0x1E34A77868E19A6647b1f2F47B51ed72dEDE95DD', + }, + symbol: 'ETH', + decimals: 18, + }, + }, + ensSubdomainKey: 'arbitrum-tornado', + pollInterval: 15, + constants: { + NOTE_ACCOUNT_BLOCK: 3430605, + ENCRYPTED_NOTES_BLOCK: 3430605, + }, + 'tornado-proxy-light.contract.tornadocash.eth': '0x0D5550d52428E7e3175bfc9550207e4ad3859b17', + }, + netId100: { + rpcCallRetryAttempt: 15, + gasPrices: { + instant: 6, + fast: 5, + standard: 4, + low: 1, + }, + nativeCurrency: 'xdai', + currencyName: 'xDAI', + explorerUrl: { + tx: 'https://blockscout.com/xdai/mainnet/tx/', + address: 'https://blockscout.com/xdai/mainnet/address/', + block: 'https://blockscout.com/xdai/mainnet/block/', + }, + merkleTreeHeight: 20, + emptyElement: '21663839004416932945382355908790599225266501822907911457504978515578255421292', + networkName: 'Gnosis Chain', + deployedBlock: 17754561, + multicall: '0xcA11bde05977b3631167028862bE2a173976CA11', + echoContract: '0xa75BF2815618872f155b7C4B0C81bF990f5245E4', + routerContract: '0x0D5550d52428E7e3175bfc9550207e4ad3859b17', + tornadoSubgraph: 'tornadocash/xdai-tornado-subgraph', + subgraphs: { + tornado, + theGraph, + }, + rpcUrls: { + tornado: { + name: 'Tornado RPC', + url: 'https://tornadocash-rpc.com/gnosis', + }, + chainnodes: { + name: 'Tornado RPC', + url: 'https://gnosis-mainnet.chainnodes.org/d692ae63-0a7e-43e0-9da9-fe4f4cc6c607', + }, + gnosis: { + name: 'Gnosis RPC', + url: 'https://rpc.gnosischain.com', + }, + stackup: { + name: 'Stackup RPC', + url: 'https://public.stackup.sh/api/v1/node/arbitrum-one', + }, + blockPi: { + name: 'BlockPi', + url: 'https://gnosis.blockpi.network/v1/rpc/public', + }, + }, + tokens: { + xdai: { + instanceAddress: { + '100': '0x1E34A77868E19A6647b1f2F47B51ed72dEDE95DD', + '1000': '0xdf231d99Ff8b6c6CBF4E9B9a945CBAcEF9339178', + '10000': '0xaf4c0B70B2Ea9FB7487C7CbB37aDa259579fe040', + '100000': '0xa5C2254e4253490C54cef0a4347fddb8f75A4998', + }, + symbol: 'xDAI', + decimals: 18, + }, + }, + ensSubdomainKey: 'gnosis-tornado', + pollInterval: 15, + constants: { + NOTE_ACCOUNT_BLOCK: 17754564, + ENCRYPTED_NOTES_BLOCK: 17754564, + }, + 'tornado-proxy-light.contract.tornadocash.eth': '0x0D5550d52428E7e3175bfc9550207e4ad3859b17', + }, + netId43114: { + rpcCallRetryAttempt: 15, + gasPrices: { + instant: 225, + fast: 35, + standard: 25, + low: 25, + }, + nativeCurrency: 'avax', + currencyName: 'AVAX', + explorerUrl: { + tx: 'https://snowtrace.io/tx/', + address: 'https://snowtrace.io/address/', + block: 'https://snowtrace.io/block/', + }, + merkleTreeHeight: 20, + emptyElement: '21663839004416932945382355908790599225266501822907911457504978515578255421292', + networkName: 'Avalanche Mainnet', + deployedBlock: 4429818, + multicall: '0xcA11bde05977b3631167028862bE2a173976CA11', + echoContract: '0xa75BF2815618872f155b7C4B0C81bF990f5245E4', + routerContract: '0x0D5550d52428E7e3175bfc9550207e4ad3859b17', + tornadoSubgraph: 'tornadocash/avalanche-tornado-subgraph', + subgraphs: { + theGraph, + }, + rpcUrls: { + oneRPC: { + name: 'OneRPC', + url: 'https://1rpc.io/avax/c', + }, + avalancheRPC: { + name: 'Avalanche RPC', + url: 'https://api.avax.network/ext/bc/C/rpc', + }, + meowRPC: { + name: 'Meow RPC', + url: 'https://avax.meowrpc.com', + }, + }, + tokens: { + avax: { + instanceAddress: { + '10': '0x330bdFADE01eE9bF63C209Ee33102DD334618e0a', + '100': '0x1E34A77868E19A6647b1f2F47B51ed72dEDE95DD', + '500': '0xaf8d1839c3c67cf571aa74B5c12398d4901147B3', + }, + symbol: 'AVAX', + decimals: 18, + }, + }, + ensSubdomainKey: 'avalanche-tornado', + pollInterval: 10, + constants: { + NOTE_ACCOUNT_BLOCK: 4429813, + ENCRYPTED_NOTES_BLOCK: 4429813, + }, + 'tornado-proxy-light.contract.tornadocash.eth': '0x0D5550d52428E7e3175bfc9550207e4ad3859b17', + }, + netId11155111: { + rpcCallRetryAttempt: 15, + gasPrices: { + instant: 2, + fast: 2, + standard: 2, + low: 2, + }, + nativeCurrency: 'eth', + currencyName: 'SepoliaETH', + explorerUrl: { + tx: 'https://sepolia.etherscan.io/tx/', + address: 'https://sepolia.etherscan.io/address/', + block: 'https://sepolia.etherscan.io/block/', + }, + merkleTreeHeight: 20, + emptyElement: '21663839004416932945382355908790599225266501822907911457504978515578255421292', + networkName: 'Ethereum Sepolia', + deployedBlock: 5594395, + multicall: '0xcA11bde05977b3631167028862bE2a173976CA11', + routerContract: '0x1572AFE6949fdF51Cb3E0856216670ae9Ee160Ee', + registryContract: '0x1428e5d2356b13778A13108b10c440C83011dfB8', + echoContract: '0xcDD1fc3F5ac2782D83449d3AbE80D6b7B273B0e5', + aggregatorContract: '0x4088712AC9fad39ea133cdb9130E465d235e9642', + reverseRecordsContract: '0xEc29700C0283e5Be64AcdFe8077d6cC95dE23C23', + tornadoSubgraph: 'tornadocash/sepolia-tornado-subgraph', + subgraphs: { + tornado, + }, + rpcUrls: { + tornado: { + name: 'Tornado RPC', + url: 'https://tornadocash-rpc.com/sepolia', + }, + sepolia: { + name: 'Sepolia RPC', + url: 'https://rpc.sepolia.org', + }, + chainnodes: { + name: 'Chainnodes RPC', + url: 'https://sepolia.chainnodes.org/d692ae63-0a7e-43e0-9da9-fe4f4cc6c607', + }, + }, + tokens: { + eth: { + instanceAddress: { + '0.1': '0x8C4A04d872a6C1BE37964A21ba3a138525dFF50b', + '1': '0x8cc930096B4Df705A007c4A039BDFA1320Ed2508', + '10': '0x8D10d506D29Fc62ABb8A290B99F66dB27Fc43585', + '100': '0x44c5C92ed73dB43888210264f0C8b36Fd68D8379', + }, + symbol: 'ETH', + decimals: 18, + }, + dai: { + instanceAddress: { + '100': '0x6921fd1a97441dd603a997ED6DDF388658daf754', + '1000': '0x50a637770F5d161999420F7d70d888DE47207145', + '10000': '0xecD649870407cD43923A816Cc6334a5bdf113621', + '100000': '0x73B4BD04bF83206B6e979BE2507098F92EDf4F90', + }, + tokenAddress: '0xFF34B3d4Aee8ddCd6F9AFFFB6Fe49bD371b8a357', + tokenGasLimit: 70_000, + symbol: 'DAI', + decimals: 18, + gasLimit: 700_000, + }, + }, + ensSubdomainKey: 'sepolia-tornado', + pollInterval: 15, + constants: { + GOVERNANCE_BLOCK: 5594395, + NOTE_ACCOUNT_BLOCK: 5594395, + ENCRYPTED_NOTES_BLOCK: 5594395, + MINING_BLOCK_TIME: 15, + }, + 'torn.contract.tornadocash.eth': '0x3AE6667167C0f44394106E197904519D808323cA', + 'governance.contract.tornadocash.eth': '0xe5324cD7602eeb387418e594B87aCADee08aeCAD', + 'tornado-router.contract.tornadocash.eth': '0x1572AFE6949fdF51Cb3E0856216670ae9Ee160Ee', + }, +}; + +export const subdomains = enabledChains.map((chain) => networkConfig[`netId${chain}`].ensSubdomainKey); + +export default networkConfig; diff --git a/src/services/parser.ts b/src/services/parser.ts new file mode 100644 index 0000000..af068a4 --- /dev/null +++ b/src/services/parser.ts @@ -0,0 +1,62 @@ +import { InvalidArgumentError } from 'commander'; +import { computeAddress, getAddress, Mnemonic } from 'ethers'; +import { validateUrl } from './utils'; + +export function parseNumber(value?: string | number): number { + if (!value || isNaN(Number(value))) { + throw new InvalidArgumentError('Invalid Number'); + } + return Number(value); +} + +export function parseUrl(value?: string): string { + if (!value || !validateUrl(value, ['http:', 'https:'])) { + throw new InvalidArgumentError('Invalid URL'); + } + return value; +} + +export function parseRelayer(value?: string): string { + if (!value || !(value.endsWith('.eth') || validateUrl(value, ['http:', 'https:']))) { + throw new InvalidArgumentError('Invalid Relayer ETH address or URL'); + } + return value; +} + +export function parseAddress(value?: string): string { + if (!value) { + throw new InvalidArgumentError('Invalid Address'); + } + try { + return getAddress(value); + } catch { + throw new InvalidArgumentError('Invalid Address'); + } +} + +export function parseMnemonic(value?: string): string { + if (!value) { + throw new InvalidArgumentError('Invalid Mnemonic'); + } + try { + Mnemonic.fromPhrase(value); + } catch { + throw new InvalidArgumentError('Invalid Mnemonic'); + } + return value; +} + +export function parseKey(value?: string): string { + if (!value) { + throw new InvalidArgumentError('Invalid Private Key'); + } + if (value.length === 64) { + value = '0x' + value; + } + try { + computeAddress(value); + } catch { + throw new InvalidArgumentError('Invalid Private Key'); + } + return value; +} diff --git a/src/services/pedersen.ts b/src/services/pedersen.ts new file mode 100644 index 0000000..8440621 --- /dev/null +++ b/src/services/pedersen.ts @@ -0,0 +1,32 @@ +import { BabyJub, PedersenHash, Point, buildPedersenHash } from 'circomlibjs'; + +export class Pedersen { + pedersenHash?: PedersenHash; + babyJub?: BabyJub; + pedersenPromise: Promise; + + constructor() { + this.pedersenPromise = this.initPedersen(); + } + + async initPedersen() { + this.pedersenHash = await buildPedersenHash(); + this.babyJub = this.pedersenHash.babyJub; + } + + async unpackPoint(buffer: Uint8Array) { + await this.pedersenPromise; + return this.babyJub?.unpackPoint(this.pedersenHash?.hash(buffer) as Uint8Array); + } + + toStringBuffer(buffer: Uint8Array): string { + return this.babyJub?.F.toString(buffer); + } +} + +export const pedersen = new Pedersen(); + +export async function buffPedersenHash(buffer: Uint8Array): Promise { + const [hash] = (await pedersen.unpackPoint(buffer)) as Point; + return pedersen.toStringBuffer(hash); +} diff --git a/src/services/prices.ts b/src/services/prices.ts new file mode 100644 index 0000000..c63efdc --- /dev/null +++ b/src/services/prices.ts @@ -0,0 +1,31 @@ +import { parseEther, type Provider } from 'ethers'; +import type { OffchainOracle, Multicall } from '../typechain'; +import { multicall } from './multicall'; + +export class TokenPriceOracle { + oracle?: OffchainOracle; + multicall: Multicall; + provider: Provider; + + constructor(provider: Provider, multicall: Multicall, oracle?: OffchainOracle) { + this.provider = provider; + this.multicall = multicall; + this.oracle = oracle; + } + + fetchPrices(tokens: string[]): Promise { + // setup mock price for testnets + if (!this.oracle) { + return new Promise((resolve) => resolve(tokens.map(() => parseEther('0.0001')))); + } + + return multicall( + this.multicall, + tokens.map((token) => ({ + contract: this.oracle, + name: 'getRateToEth', + params: [token, true], + })), + ); + } +} diff --git a/src/services/providers.ts b/src/services/providers.ts new file mode 100644 index 0000000..d91900a --- /dev/null +++ b/src/services/providers.ts @@ -0,0 +1,646 @@ +import type { EventEmitter } from 'stream'; +import type { RequestOptions } from 'http'; +import crossFetch from 'cross-fetch'; +import { HttpProxyAgent } from 'http-proxy-agent'; +import { HttpsProxyAgent } from 'https-proxy-agent'; +import { SocksProxyAgent } from 'socks-proxy-agent'; +import { + FetchRequest, + JsonRpcApiProvider, + JsonRpcProvider, + Wallet, + HDNodeWallet, + FetchGetUrlFunc, + Provider, + SigningKey, + TransactionRequest, + JsonRpcSigner, + BrowserProvider, + Networkish, + Eip1193Provider, + VoidSigner, + Network, + parseUnits, + FetchUrlFeeDataNetworkPlugin, + BigNumberish, + FeeData, + EnsPlugin, + GasCostPlugin, +} from 'ethers'; +import type { RequestInfo, RequestInit, Response, HeadersInit } from 'node-fetch'; +import { GasPriceOracle, GasPriceOracle__factory, Multicall, Multicall__factory } from '../typechain'; +import { isNode, sleep } from './utils'; +import type { Config } from './networkConfig'; +import { multicall } from './multicall'; + +declare global { + interface Window { + ethereum?: Eip1193Provider & EventEmitter; + } +} + +// Update this for every Tor Browser release +export const defaultUserAgent = 'Mozilla/5.0 (Windows NT 10.0; rv:109.0) Gecko/20100101 Firefox/115.0'; + +export const fetch = crossFetch as unknown as nodeFetch; + +export type nodeFetch = (url: RequestInfo, init?: RequestInit) => Promise; + +export type fetchDataOptions = RequestInit & { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + headers?: HeadersInit | any; + maxRetry?: number; + retryOn?: number; + userAgent?: string; + timeout?: number; + proxy?: string; + torPort?: number; + // eslint-disable-next-line @typescript-eslint/ban-types + debug?: Function; + returnResponse?: boolean; +}; + +export type NodeAgent = RequestOptions['agent'] | ((parsedUrl: URL) => RequestOptions['agent']); + +export function getHttpAgent({ + fetchUrl, + proxyUrl, + torPort, + retry, +}: { + fetchUrl: string; + proxyUrl?: string; + torPort?: number; + retry: number; +}): NodeAgent | undefined { + if (torPort) { + return new SocksProxyAgent(`socks5h://tor${retry}@127.0.0.1:${torPort}`); + } + + if (!proxyUrl) { + return; + } + + const isHttps = fetchUrl.includes('https://'); + + if (proxyUrl.includes('socks://') || proxyUrl.includes('socks4://') || proxyUrl.includes('socks5://')) { + return new SocksProxyAgent(proxyUrl); + } + + if (proxyUrl.includes('http://') || proxyUrl.includes('https://')) { + if (isHttps) { + return new HttpsProxyAgent(proxyUrl); + } + return new HttpProxyAgent(proxyUrl); + } +} + +export async function fetchData(url: string, options: fetchDataOptions = {}) { + const MAX_RETRY = options.maxRetry ?? 3; + const RETRY_ON = options.retryOn ?? 500; + const userAgent = options.userAgent ?? defaultUserAgent; + + let retry = 0; + let errorObject; + + if (!options.method) { + if (!options.body) { + options.method = 'GET'; + } else { + options.method = 'POST'; + } + } + + if (!options.headers) { + options.headers = {}; + } + + if (isNode && !options.headers['User-Agent']) { + options.headers['User-Agent'] = userAgent; + } + + while (retry < MAX_RETRY + 1) { + let timeout; + + // Define promise timeout when the options.timeout is available + if (!options.signal && options.timeout) { + const controller = new AbortController(); + + options.signal = controller.signal; + + // Define timeout in seconds + timeout = setTimeout(() => { + controller.abort(); + }, options.timeout); + } + + if (!options.agent && isNode && (options.proxy || options.torPort)) { + options.agent = getHttpAgent({ + fetchUrl: url, + proxyUrl: options.proxy, + torPort: options.torPort, + retry, + }); + } + + if (options.debug && typeof options.debug === 'function') { + options.debug('request', { + url, + retry, + errorObject, + options, + }); + } + + try { + const resp = await fetch(url, { + method: options.method, + headers: options.headers, + body: options.body, + redirect: options.redirect, + signal: options.signal, + agent: options.agent, + }); + + if (options.debug && typeof options.debug === 'function') { + options.debug('response', resp); + } + + if (!resp.ok) { + const errMsg = `Request to ${url} failed with error code ${resp.status}:\n` + (await resp.text()); + throw new Error(errMsg); + } + + if (options.returnResponse) { + return resp; + } + + const contentType = resp.headers.get('content-type'); + + // If server returns JSON object, parse it and return as an object + if (contentType?.includes('application/json')) { + return await resp.json(); + } + + // Else if the server returns text parse it as a string + if (contentType?.includes('text')) { + return await resp.text(); + } + + // Return as a response object https://developer.mozilla.org/en-US/docs/Web/API/Response + return resp; + } catch (error) { + if (timeout) { + clearTimeout(timeout); + } + + errorObject = error; + + retry++; + + await sleep(RETRY_ON); + } finally { + if (timeout) { + clearTimeout(timeout); + } + } + } + + if (options.debug && typeof options.debug === 'function') { + options.debug('error', errorObject); + } + + throw errorObject; +} + +/* eslint-disable prettier/prettier, @typescript-eslint/no-explicit-any */ +export const fetchGetUrlFunc = + (options: fetchDataOptions = {}): FetchGetUrlFunc => + async (req, _signal) => { + let signal; + + if (_signal) { + const controller = new AbortController(); + signal = controller.signal; + _signal.addListener(() => { + controller.abort(); + }); + } + + const init = { + ...options, + method: req.method || 'POST', + headers: req.headers, + body: req.body || undefined, + signal, + returnResponse: true, + }; + + const resp = await fetchData(req.url, init); + + const headers = {} as { [key in string]: any }; + resp.headers.forEach((value: any, key: string) => { + headers[key.toLowerCase()] = value; + }); + + const respBody = await resp.arrayBuffer(); + const body = respBody == null ? null : new Uint8Array(respBody); + + return { + statusCode: resp.status, + statusMessage: resp.statusText, + headers, + body, + }; + }; +/* eslint-enable prettier/prettier, @typescript-eslint/no-explicit-any */ + +// caching to improve performance +const oracleMapper = new Map(); +const multicallMapper = new Map(); + +export type getProviderOptions = fetchDataOptions & { + pollingInterval?: number; + gasPriceOracle?: string; + gasStationApi?: string; +}; + +export function getGasOraclePlugin(networkKey: string, fetchOptions?: getProviderOptions) { + const gasStationApi = fetchOptions?.gasStationApi || 'https://gasstation.polygon.technology/v2'; + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + return new FetchUrlFeeDataNetworkPlugin(gasStationApi, async (fetchFeeData, provider, request) => { + if (!oracleMapper.has(networkKey)) { + oracleMapper.set(networkKey, GasPriceOracle__factory.connect(fetchOptions?.gasPriceOracle as string, provider)); + } + if (!multicallMapper.has(networkKey)) { + multicallMapper.set( + networkKey, + Multicall__factory.connect('0xcA11bde05977b3631167028862bE2a173976CA11', provider), + ); + } + const Oracle = oracleMapper.get(networkKey) as GasPriceOracle; + const Multicall = multicallMapper.get(networkKey) as Multicall; + + const [timestamp, heartbeat, feePerGas, priorityFeePerGas] = await multicall(Multicall, [ + { + contract: Oracle, + name: 'timestamp', + }, + { + contract: Oracle, + name: 'heartbeat', + }, + { + contract: Oracle, + name: 'maxFeePerGas', + }, + { + contract: Oracle, + name: 'maxPriorityFeePerGas', + }, + ]); + + const isOutdated = Number(timestamp) <= Date.now() / 1000 - Number(heartbeat); + + if (!isOutdated) { + const maxPriorityFeePerGas = (priorityFeePerGas * BigInt(13)) / BigInt(10); + const maxFeePerGas = feePerGas * BigInt(2) + maxPriorityFeePerGas; + + return { + gasPrice: maxFeePerGas, + maxFeePerGas, + maxPriorityFeePerGas, + }; + } + + const fetchReq = new FetchRequest(gasStationApi); + fetchReq.getUrlFunc = fetchGetUrlFunc(fetchOptions); + if (isNode) { + // Prevent Cloudflare from blocking our request in node.js + fetchReq.setHeader('User-Agent', 'ethers'); + } + + const [ + { + bodyJson: { fast }, + }, + { gasPrice }, + ] = await Promise.all([fetchReq.send(), fetchFeeData()]); + + return { + gasPrice, + maxFeePerGas: parseUnits(`${fast.maxFee}`, 9), + maxPriorityFeePerGas: parseUnits(`${fast.maxPriorityFee}`, 9), + }; + }); +} + +export async function getProvider(rpcUrl: string, fetchOptions?: getProviderOptions): Promise { + const fetchReq = new FetchRequest(rpcUrl); + fetchReq.getUrlFunc = fetchGetUrlFunc(fetchOptions); + // omit network plugins and mimic registerEth function (required for polygon) + const _staticNetwork = await new JsonRpcProvider(fetchReq).getNetwork(); + const ensPlugin = _staticNetwork.getPlugin('org.ethers.plugins.network.Ens'); + const gasCostPlugin = _staticNetwork.getPlugin('org.ethers.plugins.network.GasCost'); + const gasStationPlugin = ( + _staticNetwork.getPlugin('org.ethers.plugins.network.FetchUrlFeeDataPlugin') + ); + const staticNetwork = new Network(_staticNetwork.name, _staticNetwork.chainId); + if (ensPlugin) { + staticNetwork.attachPlugin(ensPlugin); + } + if (gasCostPlugin) { + staticNetwork.attachPlugin(gasCostPlugin); + } + if (fetchOptions?.gasPriceOracle) { + staticNetwork.attachPlugin(getGasOraclePlugin(`${_staticNetwork.chainId}_${rpcUrl}`, fetchOptions)); + } else if (gasStationPlugin) { + staticNetwork.attachPlugin(gasStationPlugin); + } + const provider = new JsonRpcProvider(fetchReq, staticNetwork, { + staticNetwork, + }); + provider.pollingInterval = fetchOptions?.pollingInterval || 1000; + return provider; +} + +export function getProviderWithNetId( + netId: BigNumberish, + rpcUrl: string, + config: Config, + fetchOptions?: getProviderOptions, +): JsonRpcProvider { + const { networkName, reverseRecordsContract, gasPriceOracleContract, gasStationApi, pollInterval } = config; + const hasEns = Boolean(reverseRecordsContract); + + const fetchReq = new FetchRequest(rpcUrl); + fetchReq.getUrlFunc = fetchGetUrlFunc(fetchOptions); + const staticNetwork = new Network(networkName, netId); + if (hasEns) { + staticNetwork.attachPlugin(new EnsPlugin(null, Number(netId))); + } + + staticNetwork.attachPlugin(new GasCostPlugin()); + + if (gasPriceOracleContract) { + staticNetwork.attachPlugin( + getGasOraclePlugin(`${netId}_${rpcUrl}`, { + gasPriceOracle: gasPriceOracleContract, + gasStationApi, + }), + ); + } + + const provider = new JsonRpcProvider(fetchReq, staticNetwork, { + staticNetwork, + }); + + provider.pollingInterval = fetchOptions?.pollingInterval || pollInterval * 1000; + + return provider; +} + +export const populateTransaction = async ( + signer: TornadoWallet | TornadoVoidSigner | TornadoRpcSigner, + tx: TransactionRequest, +) => { + const provider = signer.provider as Provider; + + if (!tx.from) { + tx.from = signer.address; + } else if (tx.from !== signer.address) { + const errMsg = `populateTransaction: signer mismatch for tx, wants ${tx.from} have ${signer.address}`; + throw new Error(errMsg); + } + + const [feeData, nonce] = await Promise.all([ + (async () => { + if (tx.maxFeePerGas && tx.maxPriorityFeePerGas) { + return new FeeData(null, BigInt(tx.maxFeePerGas), BigInt(tx.maxPriorityFeePerGas)); + } + + if (tx.gasPrice) { + return new FeeData(BigInt(tx.gasPrice), null, null); + } + + const fetchedFeeData = await provider.getFeeData(); + + if (fetchedFeeData.maxFeePerGas && fetchedFeeData.maxPriorityFeePerGas) { + return new FeeData( + null, + (fetchedFeeData.maxFeePerGas * (BigInt(10000) + BigInt(signer.gasPriceBump))) / BigInt(10000), + fetchedFeeData.maxPriorityFeePerGas, + ); + } else { + return new FeeData( + ((fetchedFeeData.gasPrice as bigint) * (BigInt(10000) + BigInt(signer.gasPriceBump))) / BigInt(10000), + null, + null, + ); + } + })(), + (async () => { + if (tx.nonce) { + return tx.nonce; + } + + let fetchedNonce = await provider.getTransactionCount(signer.address, 'pending'); + + // Deal with cached nonce results + if (signer.bumpNonce && signer.nonce && signer.nonce >= fetchedNonce) { + console.log( + `populateTransaction: bumping nonce from ${fetchedNonce} to ${fetchedNonce + 1} for ${signer.address}`, + ); + fetchedNonce++; + } + + return fetchedNonce; + })(), + ]); + + tx.nonce = nonce; + + // EIP-1559 + if (feeData.maxFeePerGas && feeData.maxPriorityFeePerGas) { + tx.maxFeePerGas = feeData.maxFeePerGas; + tx.maxPriorityFeePerGas = feeData.maxPriorityFeePerGas; + if (!tx.type) { + tx.type = 2; + } + delete tx.gasPrice; + } else if (feeData.gasPrice) { + tx.gasPrice = feeData.gasPrice; + if (!tx.type) { + tx.type = 0; + } + delete tx.maxFeePerGas; + delete tx.maxPriorityFeePerGas; + } + + // gasLimit + tx.gasLimit = + tx.gasLimit || + (await (async () => { + try { + const gasLimit = await provider.estimateGas(tx); + return gasLimit === BigInt(21000) + ? gasLimit + : (gasLimit * (BigInt(10000) + BigInt(signer.gasLimitBump))) / BigInt(10000); + } catch (err) { + if (signer.gasFailover) { + console.log('populateTransaction: warning gas estimation failed falling back to 3M gas'); + // Gas failover + return BigInt('3000000'); + } + throw err; + } + })()); + + return tx; +}; + +export type TornadoWalletOptions = { + gasPriceBump?: number; + gasLimitBump?: number; + gasFailover?: boolean; + bumpNonce?: boolean; +}; + +export class TornadoWallet extends Wallet { + nonce?: number | null; + gasPriceBump: number; + gasLimitBump: number; + gasFailover: boolean; + bumpNonce: boolean; + constructor( + key: string | SigningKey, + provider?: null | Provider, + { gasPriceBump, gasLimitBump, gasFailover, bumpNonce }: TornadoWalletOptions = {}, + ) { + super(key, provider); + // 10% bump from the recommended fee + this.gasPriceBump = gasPriceBump ?? 1000; + // 30% bump from the recommended gaslimit + this.gasLimitBump = gasLimitBump ?? 3000; + this.gasFailover = gasFailover ?? false; + // Disable bump nonce feature unless being used by the server environment + this.bumpNonce = bumpNonce ?? false; + } + + static fromMnemonic(mneomnic: string, provider: Provider, index = 0, options?: TornadoWalletOptions) { + const defaultPath = `m/44'/60'/0'/0/${index}`; + const { privateKey } = HDNodeWallet.fromPhrase(mneomnic, undefined, defaultPath); + return new TornadoWallet(privateKey as unknown as SigningKey, provider, options); + } + + async populateTransaction(tx: TransactionRequest) { + const txObject = await populateTransaction(this, tx); + this.nonce = txObject.nonce; + + return super.populateTransaction(txObject); + } +} + +export class TornadoVoidSigner extends VoidSigner { + nonce?: number | null; + gasPriceBump: number; + gasLimitBump: number; + gasFailover: boolean; + bumpNonce: boolean; + constructor( + address: string, + provider?: null | Provider, + { gasPriceBump, gasLimitBump, gasFailover, bumpNonce }: TornadoWalletOptions = {}, + ) { + super(address, provider); + // 10% bump from the recommended fee + this.gasPriceBump = gasPriceBump ?? 1000; + // 30% bump from the recommended gaslimit + this.gasLimitBump = gasLimitBump ?? 3000; + this.gasFailover = gasFailover ?? false; + // turn off bumpNonce feature for view only wallet + this.bumpNonce = bumpNonce ?? false; + } + + async populateTransaction(tx: TransactionRequest) { + const txObject = await populateTransaction(this, tx); + this.nonce = txObject.nonce; + + return super.populateTransaction(txObject); + } +} + +export class TornadoRpcSigner extends JsonRpcSigner { + nonce?: number | null; + gasPriceBump: number; + gasLimitBump: number; + gasFailover: boolean; + bumpNonce: boolean; + constructor( + provider: JsonRpcApiProvider, + address: string, + { gasPriceBump, gasLimitBump, gasFailover, bumpNonce }: TornadoWalletOptions = {}, + ) { + super(provider, address); + // 10% bump from the recommended fee + this.gasPriceBump = gasPriceBump ?? 1000; + // 30% bump from the recommended gaslimit + this.gasLimitBump = gasLimitBump ?? 3000; + this.gasFailover = gasFailover ?? false; + // turn off bumpNonce feature for browser wallet + this.bumpNonce = bumpNonce ?? false; + } + + async sendUncheckedTransaction(tx: TransactionRequest) { + return super.sendUncheckedTransaction(await populateTransaction(this, tx)); + } +} + +/* eslint-disable @typescript-eslint/no-explicit-any */ +export type connectWalletFunc = (...args: any[]) => Promise; + +export type handleWalletFunc = (...args: any[]) => void; +/* eslint-enable @typescript-eslint/no-explicit-any */ + +export type TornadoBrowserProviderOptions = TornadoWalletOptions & { + webChainId?: BigNumberish; + connectWallet?: connectWalletFunc; + handleNetworkChanges?: handleWalletFunc; + handleAccountChanges?: handleWalletFunc; + handleAccountDisconnect?: handleWalletFunc; +}; + +export class TornadoBrowserProvider extends BrowserProvider { + options?: TornadoBrowserProviderOptions; + constructor(ethereum: Eip1193Provider, network?: Networkish, options?: TornadoBrowserProviderOptions) { + super(ethereum, network); + this.options = options; + } + + async getSigner(address: string): Promise { + const signerAddress = (await super.getSigner(address)).address; + + if ( + this.options?.webChainId && + this.options?.connectWallet && + Number(await super.send('eth_chainId', [])) !== Number(this.options?.webChainId) + ) { + await this.options.connectWallet(); + } + + if (this.options?.handleNetworkChanges) { + window?.ethereum?.on('chainChanged', this.options.handleNetworkChanges); + } + + if (this.options?.handleAccountChanges) { + window?.ethereum?.on('accountsChanged', this.options.handleAccountChanges); + } + + if (this.options?.handleAccountDisconnect) { + window?.ethereum?.on('disconnect', this.options.handleAccountDisconnect); + } + + return new TornadoRpcSigner(this, signerAddress, this.options); + } +} diff --git a/src/services/relayerClient.ts b/src/services/relayerClient.ts new file mode 100644 index 0000000..2ad6ef1 --- /dev/null +++ b/src/services/relayerClient.ts @@ -0,0 +1,412 @@ +import { namehash, parseEther } from 'ethers'; +import type { Aggregator } from '@tornado/contracts'; +import type { RelayerStructOutput } from '@tornado/contracts/dist/contracts/Governance/Aggregator/Aggregator'; +import { sleep } from './utils'; +import type { Config } from './networkConfig'; +import { fetchData, fetchDataOptions } from './providers'; +import { ajv, jobsSchema, getStatusSchema } from './schemas'; +import type { snarkProofs } from './websnark'; + +export const MIN_STAKE_BALANCE = parseEther('500'); + +export interface RelayerParams { + ensName: string; + relayerAddress?: string; +} + +export interface Relayer { + netId: number; + url: string; + rewardAccount: string; + currentQueue: number; + tornadoServiceFee: number; +} + +export type RelayerInfo = Relayer & { + hostname: string; + ensName: string; + stakeBalance: bigint; + relayerAddress: string; + ethPrices?: { + [key in string]: string; + }; +}; + +export type RelayerError = { + hostname: string; + relayerAddress?: string; + errorMessage?: string; +}; + +export interface RelayerStatus { + url: string; + rewardAccount: string; + instances: { + [key in string]: { + instanceAddress: { + [key in string]: string; + }; + tokenAddress?: string; + symbol: string; + decimals: number; + }; + }; + gasPrices?: { + fast: number; + additionalProperties?: number; + }; + netId: number; + ethPrices?: { + [key in string]: string; + }; + tornadoServiceFee: number; + latestBlock?: number; + version: string; + health: { + status: string; + error: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + errorsLog: any[]; + }; + currentQueue: number; +} + +export interface RelayerTornadoWithdraw { + id?: string; + error?: string; +} + +export interface RelayerTornadoJobs { + error?: string; + id: string; + type?: string; + status: string; + contract?: string; + proof?: string; + args?: string[]; + txHash?: string; + confirmations?: number; + failedReason?: string; +} + +const semVerRegex = + /^(?0|[1-9]\d*)\.(?0|[1-9]\d*)\.(?0|[1-9]\d*)(?:-(?(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+(?[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/; + +export interface semanticVersion { + major: string; + minor: string; + patch: string; + prerelease?: string; + buildmetadata?: string; +} + +export function parseSemanticVersion(version: string) { + const { groups } = semVerRegex.exec(version) as RegExpExecArray; + return groups as unknown as semanticVersion; +} + +export function isRelayerUpdated(relayerVersion: string, netId: number | string) { + const { major, patch, prerelease } = parseSemanticVersion(relayerVersion); + // Save backwards compatibility with V4 relayers for Ethereum Mainnet + const requiredMajor = netId === 1 ? '4' : '5'; + const isUpdatedMajor = major === requiredMajor; + + if (prerelease) return false; + return isUpdatedMajor && (Number(patch) >= 5 || Number(netId) !== 1); // Patch checking - also backwards compatibility for Mainnet +} + +export function calculateScore({ stakeBalance, tornadoServiceFee }: RelayerInfo, minFee = 0.33, maxFee = 0.53) { + if (tornadoServiceFee < minFee) { + tornadoServiceFee = minFee; + } else if (tornadoServiceFee >= maxFee) { + return BigInt(0); + } + + const serviceFeeCoefficient = (tornadoServiceFee - minFee) ** 2; + const feeDiffCoefficient = 1 / (maxFee - minFee) ** 2; + const coefficientsMultiplier = 1 - feeDiffCoefficient * serviceFeeCoefficient; + + return BigInt(Math.floor(Number(stakeBalance) * coefficientsMultiplier)); +} + +export function getWeightRandom(weightsScores: bigint[], random: bigint) { + for (let i = 0; i < weightsScores.length; i++) { + if (random < weightsScores[i]) { + return i; + } + random = random - weightsScores[i]; + } + return Math.floor(Math.random() * weightsScores.length); +} + +export function pickWeightedRandomRelayer(relayers: RelayerInfo[], netId: string | number) { + let minFee: number, maxFee: number; + + if (Number(netId) !== 1) { + minFee = 0.01; + maxFee = 0.3; + } + + const weightsScores = relayers.map((el) => calculateScore(el, minFee, maxFee)); + const totalWeight = weightsScores.reduce((acc, curr) => { + return (acc = acc + curr); + }, BigInt('0')); + + const random = BigInt(Number(totalWeight) * Math.random()); + const weightRandomIndex = getWeightRandom(weightsScores, random); + + return relayers[weightRandomIndex]; +} + +export interface RelayerClientConstructor { + netId: number | string; + config: Config; + Aggregator: Aggregator; + fetchDataOptions?: fetchDataOptions; +} + +export type RelayerClientWithdraw = snarkProofs & { + contract: string; +}; + +export class RelayerClient { + netId: number; + config: Config; + Aggregator: Aggregator; + selectedRelayer?: Relayer; + fetchDataOptions?: fetchDataOptions; + + constructor({ netId, config, Aggregator, fetchDataOptions }: RelayerClientConstructor) { + this.netId = Number(netId); + this.config = config; + this.Aggregator = Aggregator; + this.fetchDataOptions = fetchDataOptions; + } + + async askRelayerStatus({ + hostname, + relayerAddress, + }: { + hostname: string; + relayerAddress?: string; + }): Promise { + const url = `https://${!hostname.endsWith('/') ? hostname + '/' : hostname}`; + + const rawStatus = (await fetchData(`${url}status`, { + ...this.fetchDataOptions, + headers: { + 'Content-Type': 'application/json, application/x-www-form-urlencoded', + }, + timeout: this.fetchDataOptions?.torPort ? 10000 : 3000, + maxRetry: this.fetchDataOptions?.torPort ? 2 : 0, + })) as object; + + const statusValidator = ajv.compile(getStatusSchema(this.netId, this.config)); + + if (!statusValidator(rawStatus)) { + throw new Error('Invalid status schema'); + } + + const status = { + ...rawStatus, + url, + } as RelayerStatus; + + if (status.currentQueue > 5) { + throw new Error('Withdrawal queue is overloaded'); + } + + if (status.netId !== this.netId) { + throw new Error('This relayer serves a different network'); + } + + if (relayerAddress && this.netId === 1 && status.rewardAccount !== relayerAddress) { + throw new Error('The Relayer reward address must match registered address'); + } + + if (!isRelayerUpdated(status.version, this.netId)) { + throw new Error('Outdated version.'); + } + + return status; + } + + async filterRelayer( + curr: RelayerStructOutput, + relayer: RelayerParams, + subdomains: string[], + debugRelayer: boolean = false, + ): Promise { + const { ensSubdomainKey } = this.config; + const subdomainIndex = subdomains.indexOf(ensSubdomainKey); + const mainnetSubdomain = curr.records[0]; + const hostname = curr.records[subdomainIndex]; + const isHostWithProtocol = hostname.includes('http'); + + const { owner, balance: stakeBalance, isRegistered } = curr; + const { ensName, relayerAddress } = relayer; + + const isOwner = !relayerAddress || relayerAddress === owner; + const hasMinBalance = stakeBalance >= MIN_STAKE_BALANCE; + + const preCondition = + hostname && isOwner && mainnetSubdomain && isRegistered && hasMinBalance && !isHostWithProtocol; + + if (preCondition || debugRelayer) { + try { + const status = await this.askRelayerStatus({ hostname, relayerAddress }); + + return { + netId: status.netId, + url: status.url, + hostname, + ensName, + stakeBalance, + relayerAddress, + rewardAccount: status.rewardAccount, + ethPrices: status.ethPrices, + currentQueue: status.currentQueue, + tornadoServiceFee: status.tornadoServiceFee, + } as RelayerInfo; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (err: any) { + if (debugRelayer) { + throw err; + } + return { + hostname, + relayerAddress, + errorMessage: err.message, + } as RelayerError; + } + } else { + if (debugRelayer) { + const errMsg = `Relayer ${hostname} condition not met`; + throw new Error(errMsg); + } + return { + hostname, + relayerAddress, + errorMessage: `Relayer ${hostname} condition not met`, + }; + } + } + + async getValidRelayers( + // this should be ascending order of events + relayers: RelayerParams[], + subdomains: string[], + debugRelayer: boolean = false, + ): Promise<{ + validRelayers: RelayerInfo[]; + invalidRelayers: RelayerError[]; + }> { + const relayersSet = new Set(); + + const uniqueRelayers = relayers.reverse().filter(({ ensName }) => { + if (!relayersSet.has(ensName)) { + relayersSet.add(ensName); + return true; + } + return false; + }); + + const relayerNameHashes = uniqueRelayers.map((r) => namehash(r.ensName)); + + const relayersData = await this.Aggregator.relayersData.staticCall(relayerNameHashes, subdomains); + + const invalidRelayers: RelayerError[] = []; + + const validRelayers = ( + await Promise.all( + relayersData.map((curr, index) => this.filterRelayer(curr, uniqueRelayers[index], subdomains, debugRelayer)), + ) + ).filter((r) => { + if ((r as RelayerError).errorMessage) { + invalidRelayers.push(r); + return false; + } + return true; + }) as RelayerInfo[]; + + return { + validRelayers, + invalidRelayers, + }; + } + + pickWeightedRandomRelayer(relayers: RelayerInfo[]) { + return pickWeightedRandomRelayer(relayers, this.netId); + } + + async tornadoWithdraw({ contract, proof, args }: RelayerClientWithdraw) { + const { url } = this.selectedRelayer as Relayer; + + const withdrawResponse = (await fetchData(`${url}v1/tornadoWithdraw`, { + ...this.fetchDataOptions, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + contract, + proof, + args, + }), + })) as RelayerTornadoWithdraw; + + const { id, error } = withdrawResponse; + + if (error) { + throw new Error(error); + } + + let relayerStatus: string | undefined; + + const jobUrl = `${url}v1/jobs/${id}`; + + console.log(`Job submitted: ${jobUrl}\n`); + + while (!relayerStatus || !['FAILED', 'CONFIRMED'].includes(relayerStatus)) { + const jobResponse = await fetchData(jobUrl, { + ...this.fetchDataOptions, + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }); + + if (jobResponse.error) { + throw new Error(error); + } + + const jobValidator = ajv.compile(jobsSchema); + + if (!jobValidator(jobResponse)) { + const errMsg = `${jobUrl} has an invalid job response`; + throw new Error(errMsg); + } + + const { status, txHash, confirmations, failedReason } = jobResponse as unknown as RelayerTornadoJobs; + + if (relayerStatus !== status) { + if (status === 'FAILED') { + const errMsg = `Job ${status}: ${jobUrl} failed reason: ${failedReason}`; + throw new Error(errMsg); + } else if (status === 'SENT') { + console.log(`Job ${status}: ${jobUrl}, txhash: ${txHash}\n`); + } else if (status === 'MINED') { + console.log(`Job ${status}: ${jobUrl}, txhash: ${txHash}, confirmations: ${confirmations}\n`); + } else if (status === 'CONFIRMED') { + console.log(`Job ${status}: ${jobUrl}, txhash: ${txHash}, confirmations: ${confirmations}\n`); + } else { + console.log(`Job ${status}: ${jobUrl}\n`); + } + + relayerStatus = status; + } + + await sleep(3000); + } + } +} diff --git a/src/services/schemas/index.ts b/src/services/schemas/index.ts new file mode 100644 index 0000000..b7059a2 --- /dev/null +++ b/src/services/schemas/index.ts @@ -0,0 +1,21 @@ +import Ajv from 'ajv'; +import type { BigNumberish } from 'ethers'; + +export const ajv = new Ajv({ allErrors: true }); + +ajv.addKeyword({ + keyword: 'BN', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + validate: (schema: any, data: BigNumberish) => { + try { + BigInt(data); + return true; + } catch (e) { + return false; + } + }, + errors: true, +}); + +export * from './status'; +export * from './jobs'; diff --git a/src/services/schemas/jobs.ts b/src/services/schemas/jobs.ts new file mode 100644 index 0000000..30624a1 --- /dev/null +++ b/src/services/schemas/jobs.ts @@ -0,0 +1,59 @@ +export type jobsSchema = { + type: string; + properties: { + error: { + type: string; + }; + id: { + type: string; + }; + type: { + type: string; + }; + status: { + type: string; + }; + contract: { + type: string; + }; + proof: { + type: string; + }; + args: { + type: string; + items: { + type: string; + }; + }; + txHash: { + type: string; + }; + confirmations: { + type: string; + }; + failedReason: { + type: string; + }; + }; + required: string[]; +}; + +export const jobsSchema: jobsSchema = { + type: 'object', + properties: { + error: { type: 'string' }, + id: { type: 'string' }, + type: { type: 'string' }, + status: { type: 'string' }, + contract: { type: 'string' }, + proof: { type: 'string' }, + args: { + type: 'array', + items: { type: 'string' }, + }, + txHash: { type: 'string' }, + confirmations: { type: 'number' }, + failedReason: { type: 'string' }, + }, + required: ['id', 'status'], +}; diff --git a/src/services/schemas/status.ts b/src/services/schemas/status.ts new file mode 100644 index 0000000..ea0ef49 --- /dev/null +++ b/src/services/schemas/status.ts @@ -0,0 +1,181 @@ +import type { Config } from '../networkConfig'; + +export type statusInstanceType = { + type: string; + properties: { + instanceAddress: { + type: string; + properties: { + [key in string]: typeof addressType; + }; + required: string[]; + }; + tokenAddress?: typeof addressType; + symbol?: { enum: string[] }; + decimals: { enum: number[] }; + }; + required: string[]; +}; + +export type statusInstancesType = { + type: string; + properties: { + [key in string]: statusInstanceType; + }; + required: string[]; +}; + +export type statusEthPricesType = { + type: string; + properties: { + [key in string]: typeof bnType; + }; + required?: string[]; +}; + +export type statusSchema = { + type: string; + properties: { + rewardAccount: typeof addressType; + instances?: statusInstancesType; + gasPrices: { + type: string; + properties: { + [key in string]: { + type: string; + }; + }; + required: string[]; + }; + netId: { + type: string; + }; + ethPrices?: statusEthPricesType; + tornadoServiceFee?: { + type: string; + maximum: number; + minimum: number; + }; + latestBlock?: { + type: string; + }; + version: { + type: string; + }; + health: { + type: string; + properties: { + status: { const: string }; + error: { type: string }; + }; + required: string[]; + }; + currentQueue: { + type: string; + }; + }; + required: string[]; +}; + +const addressType = { type: 'string', pattern: '^0x[a-fA-F0-9]{40}$' }; + +const bnType = { type: 'string', BN: true }; + +const statusSchema: statusSchema = { + type: 'object', + properties: { + rewardAccount: addressType, + gasPrices: { + type: 'object', + properties: { + fast: { type: 'number' }, + additionalProperties: { type: 'number' }, + }, + required: ['fast'], + }, + netId: { type: 'integer' }, + tornadoServiceFee: { type: 'number', maximum: 20, minimum: 0 }, + latestBlock: { type: 'number' }, + version: { type: 'string' }, + health: { + type: 'object', + properties: { + status: { const: 'true' }, + error: { type: 'string' }, + }, + required: ['status'], + }, + currentQueue: { type: 'number' }, + }, + required: ['rewardAccount', 'instances', 'netId', 'tornadoServiceFee', 'version', 'health'], +}; + +export function getStatusSchema(netId: number | string, config: Config) { + const { tokens, optionalTokens = [], nativeCurrency } = config; + + // deep copy schema + const schema = JSON.parse(JSON.stringify(statusSchema)) as statusSchema; + + const instances = Object.keys(tokens).reduce( + (acc: statusInstancesType, token) => { + const { instanceAddress, tokenAddress, symbol, decimals, optionalInstances = [] } = tokens[token]; + const amounts = Object.keys(instanceAddress); + + const instanceProperties: statusInstanceType = { + type: 'object', + properties: { + instanceAddress: { + type: 'object', + properties: amounts.reduce((acc: { [key in string]: typeof addressType }, cur) => { + acc[cur] = addressType; + return acc; + }, {}), + required: amounts.filter((amount) => !optionalInstances.includes(amount)), + }, + decimals: { enum: [decimals] }, + }, + required: ['instanceAddress', 'decimals'].concat( + tokenAddress ? ['tokenAddress'] : [], + symbol ? ['symbol'] : [], + ), + }; + + if (tokenAddress) { + instanceProperties.properties.tokenAddress = addressType; + } + if (symbol) { + instanceProperties.properties.symbol = { enum: [symbol] }; + } + + acc.properties[token] = instanceProperties; + if (!optionalTokens.includes(token)) { + acc.required.push(token); + } + return acc; + }, + { + type: 'object', + properties: {}, + required: [], + }, + ); + + schema.properties.instances = instances; + + if (Number(netId) === 1) { + const _tokens = Object.keys(tokens).filter((t) => t !== nativeCurrency); + + const ethPrices: statusEthPricesType = { + type: 'object', + properties: _tokens.reduce((acc: { [key in string]: typeof bnType }, token: string) => { + acc[token] = bnType; + return acc; + }, {}), + // required: _tokens + }; + schema.properties.ethPrices = ethPrices; + // schema.required.push('ethPrices') + } + + return schema; +} diff --git a/src/services/tokens.ts b/src/services/tokens.ts new file mode 100644 index 0000000..590bc06 --- /dev/null +++ b/src/services/tokens.ts @@ -0,0 +1,90 @@ +import { Provider, ZeroAddress } from 'ethers'; +import { ERC20__factory, Multicall } from '../typechain'; +import { chunk } from './utils'; +import { Call3, multicall } from './multicall'; + +export interface tokenBalances { + address: string; + name: string; + symbol: string; + decimals: number; + balance: bigint; +} + +export async function getTokenBalances({ + provider, + Multicall, + currencyName, + userAddress, + tokenAddresses = [], +}: { + provider: Provider; + Multicall: Multicall; + currencyName: string; + userAddress: string; + tokenAddresses: string[]; +}): Promise { + const tokenCalls = tokenAddresses + .map((tokenAddress) => { + const Token = ERC20__factory.connect(tokenAddress, provider); + + return [ + { + contract: Token, + name: 'balanceOf', + params: [userAddress], + }, + { + contract: Token, + name: 'name', + }, + { + contract: Token, + name: 'symbol', + }, + { + contract: Token, + name: 'decimals', + }, + ]; + }) + .flat() as Call3[]; + + const multicallResults = await multicall(Multicall, [ + { + contract: Multicall, + name: 'getEthBalance', + params: [userAddress], + }, + ...(tokenCalls.length ? tokenCalls : []), + ]); + + const ethResults = multicallResults[0]; + const tokenResults = multicallResults.slice(1).length + ? chunk(multicallResults.slice(1), tokenCalls.length / tokenAddresses.length) + : []; + + const tokenBalances = tokenResults.map((tokenResult, index) => { + const [tokenBalance, tokenName, tokenSymbol, tokenDecimals] = tokenResult; + const tokenAddress = tokenAddresses[index]; + + return { + address: tokenAddress, + name: tokenName, + symbol: tokenSymbol, + decimals: Number(tokenDecimals), + balance: tokenBalance, + }; + }); + + return [ + { + address: ZeroAddress, + name: currencyName, + symbol: currencyName, + decimals: 18, + balance: ethResults, + }, + ...tokenBalances, + ]; +} diff --git a/src/services/utils.ts b/src/services/utils.ts new file mode 100644 index 0000000..a1b8411 --- /dev/null +++ b/src/services/utils.ts @@ -0,0 +1,131 @@ +import { URL } from 'url'; +import BN from 'bn.js'; +import type { BigNumberish } from 'ethers'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(BigInt.prototype as any).toJSON = function () { + return this.toString(); +}; + +type bnInput = number | string | number[] | Uint8Array | Buffer | BN; + +export const isNode = + !( + process as typeof process & { + browser?: boolean; + } + ).browser && typeof globalThis.window === 'undefined'; + +export const chunk = (arr: T[], size: number): T[][] => + [...Array(Math.ceil(arr.length / size))].map((_, i) => arr.slice(size * i, size + size * i)); + +export function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export function validateUrl(url: string, protocols?: string[]) { + try { + const parsedUrl = new URL(url); + if (protocols && protocols.length) { + return protocols.map((p) => p.toLowerCase()).includes(parsedUrl.protocol); + } + return true; + } catch { + return false; + } +} + +export function bufferToBytes(b: Buffer) { + return new Uint8Array(b.buffer); +} + +export function bytesToBase64(bytes: Uint8Array) { + let binary = ''; + const len = bytes.byteLength; + for (let i = 0; i < len; ++i) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary); +} + +export function base64ToBytes(base64: string) { + const binaryString = atob(base64); + const bytes = new Uint8Array(binaryString.length); + for (let i = 0; i < binaryString.length; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + return bytes; +} + +export function bytesToHex(bytes: Uint8Array) { + return ( + '0x' + + Array.from(bytes) + .map((b) => b.toString(16).padStart(2, '0')) + .join('') + ); +} + +// Convert BE encoded bytes (Buffer | Uint8Array) array to BigInt +export function bytesToBN(bytes: Uint8Array) { + return BigInt(bytesToHex(bytes)); +} + +// Convert BigInt to BE encoded Uint8Array type +export function bnToBytes(bigint: bigint | string) { + // Parse bigint to hex string + let hexString: string = typeof bigint === 'bigint' ? bigint.toString(16) : bigint; + // Remove hex string prefix if exists + if (hexString.startsWith('0x')) { + hexString = hexString.replace('0x', ''); + } + // Hex string length should be a multiplier of two (To make correct bytes) + if (hexString.length % 2 !== 0) { + hexString = '0' + hexString; + } + return Uint8Array.from((hexString.match(/.{1,2}/g) as string[]).map((byte) => parseInt(byte, 16))); +} + +// Convert LE encoded bytes (Buffer | Uint8Array) array to BigInt +export function leBuff2Int(bytes: Uint8Array) { + return new BN(bytes, 16, 'le'); +} + +// Convert BigInt to LE encoded Uint8Array type +export function leInt2Buff(bigint: bnInput | bigint) { + return Uint8Array.from(new BN(bigint as bnInput).toArray('le', 31)); +} + +// Inherited from tornado-core and tornado-cli +export function toFixedHex(numberish: BigNumberish, length = 32) { + return ( + '0x' + + BigInt(numberish) + .toString(16) + .padStart(length * 2, '0') + ); +} + +export function toFixedLength(string: string, length: number = 32) { + string = string.replace('0x', ''); + return '0x' + string.padStart(length * 2, '0'); +} + +// Random BigInt in a range of bytes +export function rBigInt(nbytes: number = 31) { + return bytesToBN(crypto.getRandomValues(new Uint8Array(nbytes))); +} + +// Used for JSON.stringify(value, bigIntReplacer, space) +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function bigIntReplacer(key: any, value: any) { + return typeof value === 'bigint' ? value.toString() : value; +} + +export function substring(str: string, length: number = 10) { + if (str.length < length * 2) { + return str; + } + + return `${str.substring(0, length)}...${str.substring(str.length - length)}`; +} diff --git a/src/services/websnark.ts b/src/services/websnark.ts new file mode 100644 index 0000000..99cb49a --- /dev/null +++ b/src/services/websnark.ts @@ -0,0 +1,85 @@ +// @ts-expect-error no-websnark-types +import * as websnarkUtils from '@tornado/websnark/src/utils'; +// @ts-expect-error no-websnark-types +import websnarkGroth from '@tornado/websnark/src/groth16'; +import type { Element } from '@tornado/fixed-merkle-tree'; +import type { AddressLike, BytesLike, BigNumberish } from 'ethers'; +import { toFixedHex } from './utils'; + +export type snarkInputs = { + // Public snark inputs + root: Element; + nullifierHex: string; + recipient: AddressLike; + relayer: AddressLike; + fee: bigint; + refund: bigint; + + // Private snark inputs + nullifier: bigint; + secret: bigint; + pathElements: Element[]; + pathIndices: Element[]; +}; + +export type snarkArgs = [ + _root: BytesLike, + _nullifierHash: BytesLike, + _recipient: AddressLike, + _relayer: AddressLike, + _fee: BigNumberish, + _refund: BigNumberish, +]; + +export type snarkProofs = { + proof: BytesLike; + args: snarkArgs; +}; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +let groth16: any; + +const groth16Promise = (async () => { + if (!groth16) { + groth16 = await websnarkGroth({ wasmInitialMemory: 2000 }); + } +})(); + +export async function calculateSnarkProof( + input: snarkInputs, + circuit: object, + provingKey: ArrayBuffer, +): Promise { + await groth16Promise; + + const snarkInput = { + root: input.root, + nullifierHash: BigInt(input.nullifierHex).toString(), + recipient: BigInt(input.recipient as string), + relayer: BigInt(input.relayer as string), + fee: input.fee, + refund: input.refund, + + nullifier: input.nullifier, + secret: input.secret, + pathElements: input.pathElements, + pathIndices: input.pathIndices, + }; + + console.log('Start generating SNARK proof', snarkInput); + console.time('SNARK proof time'); + const proofData = await websnarkUtils.genWitnessAndProve(groth16, snarkInput, circuit, provingKey); + const proof = websnarkUtils.toSolidityInput(proofData).proof as BytesLike; + console.timeEnd('SNARK proof time'); + + const args = [ + toFixedHex(input.root, 32) as BytesLike, + toFixedHex(input.nullifierHex, 32) as BytesLike, + input.recipient, + input.relayer, + toFixedHex(input.fee, 32) as BigNumberish, + toFixedHex(input.refund, 32) as BigNumberish, + ] as snarkArgs; + + return { proof, args }; +} diff --git a/tsconfig.json b/tsconfig.json index 18b7c6d..171aecd 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -42,7 +42,7 @@ // "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */ // "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */ // "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */ - // "resolveJsonModule": true, /* Enable importing .json files. */ + "resolveJsonModule": true, /* Enable importing .json files. */ // "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */ // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */ @@ -108,5 +108,6 @@ /* Completeness */ // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ "skipLibCheck": true /* Skip type checking all .d.ts files. */ - } + }, + "include": ["./src/**/*"] }