This commit is contained in:
Alexey 2020-08-04 10:39:56 +03:00
parent cb6cd89665
commit 850cfb3f7e
11 changed files with 155 additions and 117 deletions

@ -21,23 +21,12 @@
"SwitchCase": 1 "SwitchCase": 1
} }
], ],
"linebreak-style": [ "linebreak-style": ["error", "unix"],
"error", "quotes": ["error", "single"],
"unix" "semi": ["error", "never"],
], "object-curly-spacing": ["error", "always"],
"quotes": [
"error",
"single"
],
"semi": [
"error",
"never"
],
"object-curly-spacing": [
"error",
"always"
],
"require-await": "error", "require-await": "error",
"comma-dangle": ["error", "never"],
"space-before-function-paren": [ "space-before-function-paren": [
"error", "error",
{ {

7
.prettierrc Normal file

@ -0,0 +1,7 @@
{
"semi": false,
"arrowParens": "always",
"singleQuote": true,
"printWidth": 110,
"trailingComma": "none"
}

@ -1,18 +1,21 @@
# Relayer for Tornado Cash [![Build Status](https://travis-ci.org/tornadocash/relayer.svg?branch=master)](https://travis-ci.org/tornadocash/relayer) [![Docker Cloud Build Status](https://img.shields.io/docker/cloud/build/tornadocash/relayer.svg)](https://hub.docker.com/r/tornadocash/relayer/builds) # Relayer for Tornado Cash [![Build Status](https://travis-ci.org/tornadocash/relayer.svg?branch=master)](https://travis-ci.org/tornadocash/relayer) [![Docker Cloud Build Status](https://img.shields.io/docker/cloud/build/tornadocash/relayer.svg)](https://hub.docker.com/r/tornadocash/relayer/builds)
## Run locally ## Run locally
1. `npm i` 1. `npm i`
2. `cp .env.example .env` 2. `cp .env.example .env`
3. Modify `.env` as needed 3. Modify `.env` as needed
4. `npm run start` 4. `npm run start`
5. Go to `http://127.0.0.1:8000` 5. Go to `http://127.0.0.1:8000`
6. In order to execute withdraw request, you can run following command 6. In order to execute withdraw request, you can run following command
```bash ```bash
curl -X POST -H 'content-type:application/json' --data '<input data>' http://127.0.0.1:8000/relay curl -X POST -H 'content-type:application/json' --data '<input data>' http://127.0.0.1:8000/relay
``` ```
Relayer should return a transaction hash. Relayer should return a transaction hash.
*Note.* If you want to change contracts' addresses go to [config.js](./config.js) file. _Note._ If you want to change contracts' addresses go to [config.js](./config.js) file.
## Deploy with docker-compose ## Deploy with docker-compose
@ -20,11 +23,11 @@ docker-compose.yml contains a stack that will automatically provision SSL certif
1. Download docker-compose.yml 1. Download docker-compose.yml
2. Change environment variables for `kovan` containers as appropriate 2. Change environment variables for `kovan` containers as appropriate
* add `PRIVATE_KEY` for your relayer address (without 0x prefix) - add `PRIVATE_KEY` for your relayer address (without 0x prefix)
* set `VIRTUAL_HOST` and `LETSENCRYPT_HOST` to your domain and add DNS record pointing to your relayer ip address - set `VIRTUAL_HOST` and `LETSENCRYPT_HOST` to your domain and add DNS record pointing to your relayer ip address
* customize `RELAYER_FEE` - customize `RELAYER_FEE`
* update `RPC_URL` if needed - update `RPC_URL` if needed
* update `REDIS_URL` if needed - update `REDIS_URL` if needed
3. Run `docker-compose up -d` 3. Run `docker-compose up -d`
## Run as a Docker container ## Run as a Docker container
@ -37,22 +40,22 @@ In that case you will need to add https termination yourself because browsers wi
tornado.cash UI from submitting your request over http connection tornado.cash UI from submitting your request over http connection
## Input data example ## Input data example
```json ```json
{ {
"proof": "0x0f8cb4c2ca9cbb23a5f21475773e19e39d3470436d7296f25c8730d19d88fcef2986ec694ad094f4c5fff79a4e5043bd553df20b23108bc023ec3670718143c20cc49c6d9798e1ae831fd32a878b96ff8897728f9b7963f0d5a4b5574426ac6203b2456d360b8e825d8f5731970bf1fc1b95b9713e3b24203667ecdd5939c2e40dec48f9e51d9cc8dc2f7f3916f0e9e31519c7df2bea8c51a195eb0f57beea4924cb846deaa78cdcbe361a6c310638af6f6157317bc27d74746bfaa2e1f8d2e9088fd10fa62100740874cdffdd6feb15c95c5a303f6bc226d5e51619c5b825471a17ddfeb05b250c0802261f7d05cf29a39a72c13e200e5bc721b0e4c50d55e6", "proof": "0x0f8cb4c2ca9cbb23a5f21475773e19e39d3470436d7296f25c8730d19d88fcef2986ec694ad094f4c5fff79a4e5043bd553df20b23108bc023ec3670718143c20cc49c6d9798e1ae831fd32a878b96ff8897728f9b7963f0d5a4b5574426ac6203b2456d360b8e825d8f5731970bf1fc1b95b9713e3b24203667ecdd5939c2e40dec48f9e51d9cc8dc2f7f3916f0e9e31519c7df2bea8c51a195eb0f57beea4924cb846deaa78cdcbe361a6c310638af6f6157317bc27d74746bfaa2e1f8d2e9088fd10fa62100740874cdffdd6feb15c95c5a303f6bc226d5e51619c5b825471a17ddfeb05b250c0802261f7d05cf29a39a72c13e200e5bc721b0e4c50d55e6",
"args": [ "args": [
"0x1579d41e5290ab5bcec9a7df16705e49b5c0b869095299196c19c5e14462c9e3", "0x1579d41e5290ab5bcec9a7df16705e49b5c0b869095299196c19c5e14462c9e3",
"0x0cf7f49c5b35c48b9e1d43713e0b46a75977e3d10521e9ac1e4c3cd5e3da1c5d", "0x0cf7f49c5b35c48b9e1d43713e0b46a75977e3d10521e9ac1e4c3cd5e3da1c5d",
"0x03ebd0748aa4d1457cf479cce56309641e0a98f5", "0x03ebd0748aa4d1457cf479cce56309641e0a98f5",
"0xbd4369dc854c5d5b79fe25492e3a3cfcb5d02da5", "0xbd4369dc854c5d5b79fe25492e3a3cfcb5d02da5",
"0x000000000000000000000000000000000000000000000000058d15e176280000", "0x000000000000000000000000000000000000000000000000058d15e176280000",
"0x0000000000000000000000000000000000000000000000000000000000000000" "0x0000000000000000000000000000000000000000000000000000000000000000"
], ],
"contract": "0xA27E34Ad97F171846bAf21399c370c9CE6129e0D" "contract": "0xA27E34Ad97F171846bAf21399c370c9CE6129e0D"
} }
``` ```
Disclaimer: Disclaimer:
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

@ -30,9 +30,7 @@ class Fetcher {
} }
async fetchPrices() { async fetchPrices() {
try { try {
let prices = await this.oracle.methods let prices = await this.oracle.methods.getPricesInETH(this.tokenAddresses, this.oneUintAmount).call()
.getPricesInETH(this.tokenAddresses, this.oneUintAmount)
.call()
this.ethPrices = prices.reduce((acc, price, i) => { this.ethPrices = prices.reduce((acc, price, i) => {
acc[this.currencyLookup[this.tokenAddresses[i]]] = price acc[this.currencyLookup[this.tokenAddresses[i]]] = price
return acc return acc

@ -34,16 +34,17 @@ app.use(function (req, res, next) {
app.get('/', function (req, res) { app.get('/', function (req, res) {
// just for testing purposes // just for testing purposes
res.send('This is <a href=https://tornado.cash>tornado.cash</a> Relayer service. Check the <a href=/status>/status</a> for settings') res.send(
'This is <a href=https://tornado.cash>tornado.cash</a> Relayer service. Check the <a href=/status>/status</a> for settings'
)
}) })
app.get('/status', async function (req, res) { app.get('/status', async function (req, res) {
let nonce = await redisClient.get('nonce') let nonce = await redisClient.get('nonce')
let latestBlock = null let latestBlock = null
try { try {
latestBlock = await web3.eth.getBlockNumber() latestBlock = await web3.eth.getBlockNumber()
} catch(e) { } catch (e) {
console.error('Problem with RPC', e) console.error('Problem with RPC', e)
} }
const { ethPrices } = fetcher const { ethPrices } = fetcher
@ -74,7 +75,12 @@ console.log(`mixers: ${JSON.stringify(mixers)}`)
console.log(`netId: ${netId}`) console.log(`netId: ${netId}`)
console.log(`ethPrices: ${JSON.stringify(fetcher.ethPrices)}`) console.log(`ethPrices: ${JSON.stringify(fetcher.ethPrices)}`)
const { GAS_PRICE_BUMP_PERCENTAGE, ALLOWABLE_PENDING_TX_TIMEOUT, NONCE_WATCHER_INTERVAL, MAX_GAS_PRICE } = process.env const {
GAS_PRICE_BUMP_PERCENTAGE,
ALLOWABLE_PENDING_TX_TIMEOUT,
NONCE_WATCHER_INTERVAL,
MAX_GAS_PRICE
} = process.env
if (!NONCE_WATCHER_INTERVAL) { if (!NONCE_WATCHER_INTERVAL) {
console.log(`NONCE_WATCHER_INTERVAL is not set. Using default value ${watherInterval / 1000} sec`) console.log(`NONCE_WATCHER_INTERVAL is not set. Using default value ${watherInterval / 1000} sec`)
} }

@ -1,9 +1,7 @@
const Queue = require('bull') const Queue = require('bull')
const { numberToHex, toWei, toHex, toBN, toChecksumAddress } = require('web3-utils') const { numberToHex, toWei, toHex, toBN, toChecksumAddress } = require('web3-utils')
const mixerABI = require('../abis/mixerABI.json') const mixerABI = require('../abis/mixerABI.json')
const { const { isValidProof, isValidArgs, isKnownContract, isEnoughFee } = require('./utils')
isValidProof, isValidArgs, isKnownContract, isEnoughFee
} = require('./utils')
const config = require('../config') const config = require('../config')
const { redisClient, redisOpts } = require('./redis') const { redisClient, redisOpts } = require('./redis')
@ -28,14 +26,15 @@ async function relayController(req, resp) {
return resp.status(400).json({ error: 'Proof format is invalid' }) return resp.status(400).json({ error: 'Proof format is invalid' })
} }
({ valid, reason } = isValidArgs(args)) // eslint-disable-next-line no-extra-semi
;({ valid, reason } = isValidArgs(args))
if (!valid) { if (!valid) {
console.log('Args are invalid:', reason) console.log('Args are invalid:', reason)
return resp.status(400).json({ error: 'Withdraw arguments are invalid' }) return resp.status(400).json({ error: 'Withdraw arguments are invalid' })
} }
let currency, amount let currency, amount
({ valid, currency, amount } = isKnownContract(contract)) ;({ valid, currency, amount } = isKnownContract(contract))
if (!valid) { if (!valid) {
console.log('Contract does not exist:', contract) console.log('Contract does not exist:', contract)
return resp.status(400).json({ error: 'This relayer does not support the token' }) return resp.status(400).json({ error: 'This relayer does not support the token' })
@ -59,9 +58,20 @@ async function relayController(req, resp) {
return resp.status(400).json({ error: 'Relayer address is invalid' }) return resp.status(400).json({ error: 'Relayer address is invalid' })
} }
requestJob = await withdrawQueue.add({ requestJob = await withdrawQueue.add(
contract, nullifierHash, root, proof, args, currency, amount, fee: fee.toString(), refund: refund.toString() {
}, { removeOnComplete: true }) contract,
nullifierHash,
root,
proof,
args,
currency,
amount,
fee: fee.toString(),
refund: refund.toString()
},
{ removeOnComplete: true }
)
reponseCbs[requestJob.id] = resp reponseCbs[requestJob.id] = resp
} }
@ -102,7 +112,15 @@ withdrawQueue.process(async function (job, done) {
gas += 50000 gas += 50000
const ethPrices = fetcher.ethPrices const ethPrices = fetcher.ethPrices
const { isEnough, reason } = isEnoughFee({ gas, gasPrices, currency, amount, refund: toBN(refund), ethPrices, fee: toBN(fee) }) const { isEnough, reason } = isEnoughFee({
gas,
gasPrices,
currency,
amount,
refund: toBN(refund),
ethPrices,
fee: toBN(fee)
})
if (!isEnough) { if (!isEnough) {
console.log(`Wrong fee: ${reason}`) console.log(`Wrong fee: ${reason}`)
done(null, { done(null, {

@ -38,38 +38,43 @@ class Sender {
let signedTx = await this.web3.eth.accounts.signTransaction(tx, config.privateKey) let signedTx = await this.web3.eth.accounts.signTransaction(tx, config.privateKey)
let result = this.web3.eth.sendSignedTransaction(signedTx.rawTransaction) let result = this.web3.eth.sendSignedTransaction(signedTx.rawTransaction)
result.once('transactionHash', (txHash) => { result
console.log(`A new successfully sent tx ${txHash}`) .once('transactionHash', (txHash) => {
if (done) { console.log(`A new successfully sent tx ${txHash}`)
done(null, { if (done) {
status: 200, done(null, {
msg: { txHash } status: 200,
}) msg: { txHash }
} })
}).on('error', async (e) => {
console.log(`Error for tx with nonce ${tx.nonce}\n${e.message}`)
if (e.message === 'Returned error: Transaction gas price supplied is too low. There is another transaction with same nonce in the queue. Try increasing the gas price or incrementing the nonce.'
|| e.message === 'Returned error: Transaction nonce is too low. Try incrementing the nonce.'
|| e.message === 'Returned error: nonce too low'
|| e.message === 'Returned error: replacement transaction underpriced') {
console.log('nonce too low, retrying')
if (retryAttempt <= 10) {
retryAttempt++
const newNonce = tx.nonce + 1
tx.nonce = newNonce
await redisClient.set('nonce', newNonce)
await redisClient.set('tx:' + newNonce, JSON.stringify(tx))
this.sendTx(tx, done, retryAttempt)
return
} }
} })
if (done) { .on('error', async (e) => {
done(null, { console.log(`Error for tx with nonce ${tx.nonce}\n${e.message}`)
status: 400, if (
msg: { error: 'Internal Relayer Error. Please use a different relayer service' } e.message ===
}) 'Returned error: Transaction gas price supplied is too low. There is another transaction with same nonce in the queue. Try increasing the gas price or incrementing the nonce.' ||
} e.message === 'Returned error: Transaction nonce is too low. Try incrementing the nonce.' ||
}) e.message === 'Returned error: nonce too low' ||
e.message === 'Returned error: replacement transaction underpriced'
) {
console.log('nonce too low, retrying')
if (retryAttempt <= 10) {
retryAttempt++
const newNonce = tx.nonce + 1
tx.nonce = newNonce
await redisClient.set('nonce', newNonce)
await redisClient.set('tx:' + newNonce, JSON.stringify(tx))
this.sendTx(tx, done, retryAttempt)
return
}
}
if (done) {
done(null, {
status: 400,
msg: { error: 'Internal Relayer Error. Please use a different relayer service' }
})
}
})
} }
} }

@ -8,7 +8,7 @@ function setup() {
web3.eth.accounts.wallet.add('0x' + privateKey) web3.eth.accounts.wallet.add('0x' + privateKey)
web3.eth.defaultAccount = account.address web3.eth.defaultAccount = account.address
return web3 return web3
} catch(e) { } catch (e) {
console.error('web3 failed') console.error('web3 failed')
} }
} }

@ -4,7 +4,7 @@ const { netId, mixers, relayerServiceFee } = require('../config')
function isValidProof(proof) { function isValidProof(proof) {
// validator expects `websnarkUtils.toSolidityInput(proof)` output // validator expects `websnarkUtils.toSolidityInput(proof)` output
if (!(proof)) { if (!proof) {
return { valid: false, reason: 'The proof is empty.' } return { valid: false, reason: 'The proof is empty.' }
} }
@ -16,8 +16,7 @@ function isValidProof(proof) {
} }
function isValidArgs(args) { function isValidArgs(args) {
if (!args) {
if (!(args)) {
return { valid: false, reason: 'Args are empty' } return { valid: false, reason: 'Args are empty' }
} }
@ -25,18 +24,20 @@ function isValidArgs(args) {
return { valid: false, reason: 'Length of args is lower than 6' } return { valid: false, reason: 'Length of args is lower than 6' }
} }
for(let signal of args) { for (let signal of args) {
if (!isHexStrict(signal)) { if (!isHexStrict(signal)) {
return { valid: false, reason: `Corrupted signal ${signal}` } return { valid: false, reason: `Corrupted signal ${signal}` }
} }
} }
if (args[0].length !== 66 || if (
args[1].length !== 66 || args[0].length !== 66 ||
args[2].length !== 42 || args[1].length !== 66 ||
args[3].length !== 42 || args[2].length !== 42 ||
args[4].length !== 66 || args[3].length !== 42 ||
args[5].length !== 66) { args[4].length !== 66 ||
args[5].length !== 66
) {
return { valid: false, reason: 'The length one of the signals is incorrect' } return { valid: false, reason: 'The length one of the signals is incorrect' }
} }
@ -56,7 +57,7 @@ function isKnownContract(contract) {
} }
function sleep(ms) { function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms)) return new Promise((resolve) => setTimeout(resolve, ms))
} }
function fromDecimals(value, decimals) { function fromDecimals(value, decimals) {
@ -77,9 +78,7 @@ function fromDecimals(value, decimals) {
// Split it into a whole and fractional part // Split it into a whole and fractional part
const comps = ether.split('.') const comps = ether.split('.')
if (comps.length > 2) { if (comps.length > 2) {
throw new Error( throw new Error('[ethjs-unit] while converting number ' + value + ' to wei, too many decimal points')
'[ethjs-unit] while converting number ' + value + ' to wei, too many decimal points'
)
} }
let whole = comps[0] let whole = comps[0]
@ -92,9 +91,7 @@ function fromDecimals(value, decimals) {
fraction = '0' fraction = '0'
} }
if (fraction.length > baseLength) { if (fraction.length > baseLength) {
throw new Error( throw new Error('[ethjs-unit] while converting number ' + value + ' to wei, too many decimal places')
'[ethjs-unit] while converting number ' + value + ' to wei, too many decimal places'
)
} }
while (fraction.length < baseLength) { while (fraction.length < baseLength) {
@ -114,9 +111,15 @@ function fromDecimals(value, decimals) {
function isEnoughFee({ gas, gasPrices, currency, amount, refund, ethPrices, fee }) { function isEnoughFee({ gas, gasPrices, currency, amount, refund, ethPrices, fee }) {
const { decimals } = mixers[`netId${netId}`][currency] const { decimals } = mixers[`netId${netId}`][currency]
const decimalsPoint = Math.floor(relayerServiceFee) === relayerServiceFee ? 0 : relayerServiceFee.toString().split('.')[1].length const decimalsPoint =
Math.floor(relayerServiceFee) === relayerServiceFee
? 0
: relayerServiceFee.toString().split('.')[1].length
const roundDecimal = 10 ** decimalsPoint const roundDecimal = 10 ** decimalsPoint
const feePercent = toBN(fromDecimals(amount, decimals)).mul(toBN(relayerServiceFee * roundDecimal)).div(toBN(roundDecimal * 100)) const feePercent = toBN(fromDecimals(amount, decimals))
.mul(toBN(relayerServiceFee * roundDecimal))
.div(toBN(roundDecimal * 100))
const expense = toBN(toWei(gasPrices.fast.toString(), 'gwei')).mul(toBN(gas)) const expense = toBN(toWei(gasPrices.fast.toString(), 'gwei')).mul(toBN(gas))
let desiredFee let desiredFee
switch (currency) { switch (currency) {
@ -125,15 +128,20 @@ function isEnoughFee({ gas, gasPrices, currency, amount, refund, ethPrices, fee
break break
} }
default: { default: {
desiredFee = desiredFee = expense
expense.add(refund) .add(refund)
.mul(toBN(10 ** decimals)) .mul(toBN(10 ** decimals))
.div(toBN(ethPrices[currency])) .div(toBN(ethPrices[currency]))
desiredFee = desiredFee.add(feePercent) desiredFee = desiredFee.add(feePercent)
break break
} }
} }
console.log('sent fee, desired fee, feePercent', fee.toString(), desiredFee.toString(), feePercent.toString()) console.log(
'sent fee, desired fee, feePercent',
fee.toString(),
desiredFee.toString(),
feePercent.toString()
)
if (fee.lt(desiredFee)) { if (fee.lt(desiredFee)) {
return { isEnough: false, reason: 'Not enough fee' } return { isEnough: false, reason: 'Not enough fee' }
} }
@ -148,11 +156,7 @@ function getArgsForOracle() {
Object.entries(tokens).map(([currency, data]) => { Object.entries(tokens).map(([currency, data]) => {
if (currency !== 'eth') { if (currency !== 'eth') {
tokenAddresses.push(data.tokenAddress) tokenAddresses.push(data.tokenAddress)
oneUintAmount.push( oneUintAmount.push(toBN('10').pow(toBN(data.decimals.toString())).toString())
toBN('10')
.pow(toBN(data.decimals.toString()))
.toString()
)
currencyLookup[data.tokenAddress] = currency currencyLookup[data.tokenAddress] = currency
} }
}) })
@ -163,4 +167,12 @@ function getMixers() {
return mixers[`netId${netId}`] return mixers[`netId${netId}`]
} }
module.exports = { isValidProof, isValidArgs, sleep, isKnownContract, isEnoughFee, getMixers, getArgsForOracle } module.exports = {
isValidProof,
isValidArgs,
sleep,
isKnownContract,
isEnoughFee,
getMixers,
getArgsForOracle
}