"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.RelayerWorker = exports.DEFAULT_GAS_LIMIT = exports.RelayerStatus = void 0; exports.getFeeParams = getFeeParams; exports.checkWithdrawalFees = checkWithdrawalFees; exports.processWithdrawals = processWithdrawals; const crypto_1 = require("crypto"); const ethers_1 = require("ethers"); const contracts_1 = require("@tornado/contracts"); const core_1 = require("@tornado/core"); const config_1 = require("../config"); const logger_1 = require("./logger"); const error_1 = require("./error"); var RelayerStatus; (function (RelayerStatus) { RelayerStatus["QUEUED"] = "QUEUED"; RelayerStatus["ACCEPTED"] = "ACCEPTED"; RelayerStatus["SENT"] = "SENT"; RelayerStatus["MINED"] = "MINED"; RelayerStatus["RESUBMITTED"] = "RESUBMITTED"; RelayerStatus["CONFIRMED"] = "CONFIRMED"; RelayerStatus["FAILED"] = "FAILED"; })(RelayerStatus || (exports.RelayerStatus = RelayerStatus = {})); exports.DEFAULT_GAS_LIMIT = 600_000; function setupServices(relayerWorker) { const { relayerConfig: { enabledNetworks, txRpcUrls }, } = relayerWorker; for (const netId of enabledNetworks) { const config = (0, core_1.getConfig)(netId); const rpcUrl = txRpcUrls[netId]; const provider = (0, core_1.getProviderWithNetId)(netId, rpcUrl, config); const signer = new core_1.TornadoWallet((0, config_1.getPrivateKey)(), provider); const Router = contracts_1.TornadoRouter__factory.connect(config.routerContract, signer); const tornadoFeeOracle = new core_1.TornadoFeeOracle(provider); relayerWorker.cachedRelayerServices[netId] = { provider, signer, Router, tornadoFeeOracle, }; } } function getFeeParams(config, serviceFee, syncManager, { netId, contract, args }) { const { amount, currency } = (0, core_1.getInstanceByAddress)(config, contract); const { nativeCurrency, tokens: { [currency]: { symbol: currencySymbol, decimals, gasLimit: instanceGasLimit }, }, } = config; const symbol = currencySymbol.toLowerCase(); const { gasPrice, l1Fee } = syncManager.getGasPrice(netId); const gasLimit = BigInt(instanceGasLimit || exports.DEFAULT_GAS_LIMIT); const denomination = (0, ethers_1.parseUnits)(amount, decimals); const ethRefund = BigInt(args[5]); const tokenPriceInWei = syncManager.getPrice(netId, symbol); const isEth = nativeCurrency === currency; return { amount, symbol, gasPrice: BigInt(gasPrice), gasLimit, l1Fee, denomination, ethRefund, tokenPriceInWei, tokenDecimals: decimals, relayerFeePercent: serviceFee, isEth, premiumPercent: 5, }; } async function checkWithdrawalFees(relayerWorker, work) { try { const { id, netId, contract, proof, args } = work; const { relayerConfig: { rewardAccount, serviceFee }, cachedRelayerServices: { [netId]: { tornadoFeeOracle, Router }, }, syncManager, } = relayerWorker; const config = (0, core_1.getConfig)(netId); const feeParams = getFeeParams(config, serviceFee, syncManager, work); const { amount, symbol, tokenDecimals, denomination, ethRefund } = feeParams; let fee = tornadoFeeOracle.calculateRelayerFee(feeParams); const gasLimit = await Router.withdraw.estimateGas(contract, proof, ...args, { from: rewardAccount, value: ethRefund, }); // Recalculate fee based on correct gas limit fee = tornadoFeeOracle.calculateRelayerFee({ ...feeParams, gasLimit, }); if (fee > denomination) { return { gasPrice: feeParams.gasPrice, gasLimit, status: false, error: `Fee above deposit amount, requires ${(0, ethers_1.formatUnits)(fee, tokenDecimals)} ${symbol} while denomination is ${amount} ${symbol}`, }; } if (fee > BigInt(args[4])) { return { gasPrice: feeParams.gasPrice, gasLimit, status: false, error: `Insufficient fee, requires ${(0, ethers_1.formatUnits)(fee, tokenDecimals)} ${symbol} while user only wants to pay ${(0, ethers_1.formatUnits)(BigInt(args[4]), tokenDecimals)} ${symbol}`, }; } relayerWorker.logger.info(`New job: ${id} ${netId} ${amount} ${symbol} (Fee: ${(0, ethers_1.formatUnits)(BigInt(args[4]), tokenDecimals)} ${symbol}, Refund: ${(0, ethers_1.formatUnits)(BigInt(args[5]), tokenDecimals)})`); return { gasPrice: feeParams.gasPrice, gasLimit, status: true, }; } catch { return { gasPrice: BigInt(0), gasLimit: BigInt(0), status: false, error: 'Withdrawal transaction expected to be reverted', }; } } async function processWithdrawals(relayerWorker) { const { logger, cachedRelayerServices, errors } = relayerWorker; for (const work of relayerWorker.queue) { try { if (work.status !== RelayerStatus.ACCEPTED) { continue; } const { id, netId, contract, proof, args } = work; // cancel duplicated jobs const otherWork = relayerWorker.queue.find((q) => q.id !== id && // find if other previous work is already sent (not pending or failed - to allow spending first and failed one) q.status !== RelayerStatus.ACCEPTED && q.status !== RelayerStatus.FAILED && q.contract === contract && q.args[1] === args[1]); if (otherWork) { const errMsg = `Found the same pending job ${otherWork.id}, wait until the previous one completes`; throw new Error(errMsg); } const { gasLimit, gasPrice } = relayerWorker.queueGas.find((w) => w.id === id); const config = (0, core_1.getConfig)(netId); const { amount, currency } = (0, core_1.getInstanceByAddress)(config, contract); const { decimals } = config.tokens[currency]; const { Router, signer } = cachedRelayerServices[netId]; /** * Check fees to ensure that it didn't spike or revert (or has insane gas spendings) */ const txObj = await signer.populateTransaction(await Router.withdraw.populateTransaction(contract, proof, ...args, { value: BigInt(args[5]), })); const txGasPrice = txObj.maxFeePerGas ? txObj.maxFeePerGas + BigInt(txObj.maxPriorityFeePerGas || 0) : txObj.gasPrice; // Prevent tx on gas limit spike if (txObj.gasLimit > (gasLimit * BigInt(15)) / BigInt(10)) { const errMsg = `Job ${id} exceeds pre estimated gas limit, wants ${gasLimit * BigInt(2)} have ${txObj.gasLimit}`; throw new Error(errMsg); } // Prevent tx on gas price spike if (txGasPrice > gasPrice * BigInt(2)) { const errMsg = `Job ${id} exceeds pre estimated gas price, wants ${gasPrice * BigInt(2)} have ${txGasPrice}`; throw new Error(errMsg); } const tx = await signer.sendTransaction(txObj); work.txHash = tx.hash; work.confirmations = 0; work.status = RelayerStatus.SENT; logger.info(`Sent Job ${work.id} ${netId} ${amount} ${currency} tx (Fee: ${(0, ethers_1.formatUnits)(BigInt(args[4]), decimals)} ${currency}, Refund: ${(0, ethers_1.formatUnits)(BigInt(args[5]), decimals)} ${currency} ${tx.hash})`); // Wait for 2 seconds so that the remote node could increment nonces await (0, core_1.sleep)(2000); // Head straight to confirmed status as the remote node oftenly doesn't report receipt correctly work.confirmations = 1; work.status = RelayerStatus.MINED; work.confirmations = 3; work.status = RelayerStatus.CONFIRMED; // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (error) { logger.error(`Failed to send job ${work.id}`); console.log(error); errors.push((0, error_1.newError)('Worker (processWithdrawals)', work.netId, error)); work.status = RelayerStatus.FAILED; if (error.message?.includes('exceeds pre estimated')) { work.failedReason = error.message; } else if (error.message?.includes('Found the same pending job')) { work.failedReason = error.message; } else { work.failedReason = 'Relayer failed to send transaction'; } } } relayerWorker.queue = relayerWorker.queue.filter((w) => w.timestamp + relayerWorker.relayerConfig.clearInterval >= Math.floor(Date.now() / 1000)); relayerWorker.queueGas = relayerWorker.queueGas.filter((w) => w.timestamp + relayerWorker.relayerConfig.clearInterval >= Math.floor(Date.now() / 1000)); } class RelayerWorker { relayerConfig; logger; syncManager; cachedRelayerServices; queue; queueGas; queueTimer; errors; constructor(relayerConfig, syncManager) { this.relayerConfig = relayerConfig; this.syncManager = syncManager; this.logger = (0, logger_1.getLogger)('[RelayerWorker]', relayerConfig.logLevel); this.cachedRelayerServices = {}; this.queue = []; this.queueGas = []; this.queueTimer = null; this.errors = []; setupServices(this); } async doWork() { await processWithdrawals(this); const pendingWorks = this.queue.filter((q) => q.status === RelayerStatus.QUEUED || q.status === RelayerStatus.ACCEPTED).length; if (pendingWorks) { if (pendingWorks < 5) { this.doWork(); return; } else { this.queue.forEach((q) => { q.status = RelayerStatus.FAILED; q.error = 'Relayer has too many jobs, try it again later'; q.failedReason = 'Relayer has too many jobs, try it again later'; }); this.logger.error(`Relayer has cleared the workload ( ${pendingWorks} ) due to overhaul`); } } this.queueTimer = null; } async createWork({ netId, contract, proof, args, }) { const work = { netId, id: crypto_1.webcrypto.randomUUID(), type: 'TORNADO_WITHDRAW', status: RelayerStatus.QUEUED, contract, proof, args, timestamp: Math.floor(Date.now() / 1000), }; if (this.queue.find((q) => q.status !== RelayerStatus.FAILED && q.contract === contract && q.args[1] === args[1])) { work.status = RelayerStatus.FAILED; return { error: 'Found the same pending job, wait until the previous one completes', }; } const { gasPrice, gasLimit, status, error } = await checkWithdrawalFees(this, work); const workGas = { id: work.id, gasPrice, gasLimit, timestamp: work.timestamp, }; if (!status) { work.status = RelayerStatus.FAILED; return { error, }; } work.status = RelayerStatus.ACCEPTED; this.queue.push(work); this.queueGas.push(workGas); if (!this.queueTimer) { this.queueTimer = setTimeout(() => this.doWork(), 500); } return work; } getWork({ id }) { const work = this.queue.find((w) => w.id === id); if (!work) { return { error: `Work ${id} not found`, }; } const copiedWork = JSON.parse(JSON.stringify(work)); delete copiedWork.netId; return copiedWork; } pendingWorks() { return this.queue.filter((q) => q.status === RelayerStatus.QUEUED || q.status === RelayerStatus.ACCEPTED) .length; } } exports.RelayerWorker = RelayerWorker;