1import crypto from 'crypto' 2import fs from 'fs/promises' 3import { createRequire } from 'module' 4import { gunzipSync } from 'zlib' 5// @ts-ignore no type declarations 6import AdmZip from 'adm-zip' 7import { Contract, parseEther, formatEther, JsonRpcProvider, EventLog } from 'ethers' 8import type { Wallet, JsonRpcSigner } from 'ethers' 9import axios from 'axios' 23type EthPoolAmount = keyof typeof ethPools 25interface TornadoDeposit { 35interface DepositEvent { 37 transactionHash: string 43interface PendingNote { 46 status: 'deposited' | 'withdrawing' | 'complete' 52 depositTimestamp: number 54 withdrawTxHash: string | null 57// Ordered by reliability/speed - drpc and publicnode work best 58const publicRpcUrls = [ 59 'https://eth.drpc.org', 60 'https://ethereum.publicnode.com', 61 'https://1rpc.io/eth', 64let currentRpcIndex = 0 66const getLogsCapableProvider = () => { 67 return new JsonRpcProvider(publicRpcUrls[currentRpcIndex]) 70const rotateRpc = () => { 71 currentRpcIndex = (currentRpcIndex + 1) % publicRpcUrls.length 72 betLog(`Switching to RPC: ${publicRpcUrls[currentRpcIndex]}`) 75// CommonJS imports via createRequire (tornado's forked packages are CJS) 76const require = createRequire(import.meta.url) 77const circomlib = require('circomlib') 78const snarkjs = require('snarkjs') 79const MerkleTree = require('fixed-merkle-tree') 80const buildGroth16 = require('websnark/src/groth16.js') 81const websnarkUtils = require('websnark/src/utils.js') 83const merkleTreeHeight = 20 86 '0.1': '0x12D66f87A04A9E220743712cE6d9bB1B5616B8Fc', // contract 87 '1': '0x47CE0C6eD5B0Ce3d3A51fdb1C52DC66a7c3c2936', // contract 88 '10': '0x910Cbd523D972eb0a6f4cAe4618aD62622b39DbF', // contract 89 '100': '0xA160cdAB225685dA1d56aa342Ad8841c3b53f291', // contract 92const deployedBlocks = { 100 'https://tornadowithdraw.com', 104 withdraw: 'https://cdn.jsdelivr.net/npm/tornado-cdn@2.0.4/tornado.json.zip', 105 provingKey: 'https://cdn.jsdelivr.net/npm/tornado-cdn@2.0.4/tornadoProvingKey.bin.zip', 109 'function deposit(bytes32 _commitment) payable', 110 'function withdraw(bytes calldata _proof, bytes32 _root, bytes32 _nullifierHash, address payable _recipient, address payable _relayer, uint256 _fee, uint256 _refund) external payable', 111 'function isKnownRoot(bytes32 _root) view returns (bool)', 112 'function isSpent(bytes32 _nullifierHash) view returns (bool)', 113 'event Deposit(bytes32 indexed commitment, uint32 leafIndex, uint256 timestamp)', 114 'event Withdrawal(address to, bytes32 nullifierHash, address indexed relayer, uint256 fee)', 120const pendingNotesPath = () => pathDownJoin(tornadoDataPath(), 'pending_notes.json') 122const withdrawJsonPath = () => pathDownJoin(circuitPath(), 'withdraw.json') 123const provingKeyPath = () => pathDownJoin(circuitPath(), 'proving_key.bin') 124const eventsFilePath = (amtEth: EthPoolAmount) => pathDownJoin(eventsPath(), `eth_${amtEth}.json`) 126const bigInt = snarkjs.bigInt 128const rbigint = (nbytes: number) => bigInt.leBuff2int(crypto.randomBytes(nbytes)) 130const pedersenHash = (data: Buffer) => circomlib.babyJub.unpackPoint(circomlib.pedersenHash.hash(data))[0] 132const toHex = (number: any, length = 32): string => { 133 const str = number instanceof Buffer ? number.toString('hex') : bigInt(number).toString(16) 134 return '0x' + str.padStart(length * 2, '0') 137const createDeposit = ({ nullifier, secret }: {nullifier: any, secret: any}): TornadoDeposit => { 138 const deposit = { nullifier, secret } as TornadoDeposit 139 deposit.preimage = Buffer.concat([ 140 deposit.nullifier.leInt2Buff(31), 141 deposit.secret.leInt2Buff(31) 143 deposit.commitment = pedersenHash(deposit.preimage) 144 deposit.commitmentHex = toHex(deposit.commitment) 145 deposit.nullifierHash = pedersenHash(deposit.nullifier.leInt2Buff(31)) 146 deposit.nullifierHex = toHex(deposit.nullifierHash) 150const parseNote = (noteString: string) => { 151 const noteRegex = /tornado-(?<currency>\w+)-(?<amount>[\d.]+)-(?<netId>\d+)-0x(?<note>[0-9a-fA-F]{124})/g 152 const match = noteRegex.exec(noteString) 154 const groups = match.groups! 156 const buf = Buffer.from(groups.note, 'hex') 157 const nullifier = bigInt.leBuff2int(buf.slice(0, 31)) 158 const secret = bigInt.leBuff2int(buf.slice(31, 62)) 159 const deposit = createDeposit({ nullifier, secret }) 162 currency: groups.currency, 163 amount: groups.amount as EthPoolAmount, 164 netId: Number(groups.netId), 169const ensureTornadoDirs = async () => { 175const downloadAndUnzip = async (url: string, destPath: string, description: string) => { 176 betLog(`Downloading ${description} from ${url}...`) 178 const response = await axios.get(url, { 179 responseType: 'arraybuffer', 180 timeout: 300000, // 5 min timeout for large files 183 const zipBuffer = Buffer.from(response.data) 184 betLog(`Downloaded ${(zipBuffer.length / 1024 / 1024).toFixed(2)} MB, extracting...`) 186 // Extract first file from zip 187 const zip = new AdmZip(zipBuffer) 188 const entries: any[] = zip.getEntries() 191 const content: Buffer = entries[0].getData() 192 await fs.writeFile(destPath, content) 193 betLog(`Extracted ${description} (${(content.length / 1024 / 1024).toFixed(2)} MB)`) 199const ensureCircuitFiles = async () => { 200 await ensureTornadoDirs() 202 await downloadAndUnzip(circuitUrls.withdraw, withdrawJsonPath(), 'withdraw.json') 205 await downloadAndUnzip(circuitUrls.provingKey, provingKeyPath(), 'proving_key.bin') 209const loadCircuitFiles = async () => { 210 await ensureCircuitFiles() 211 const circuit = JSON.parse(await fs.readFile(withdrawJsonPath(), 'utf8')) 212 const provingKey = (await fs.readFile(provingKeyPath())).buffer 213 return { circuit, provingKey } 216const loadPendingNotes = async () => { 217 await ensureTornadoDirs() 221 return JSON.parse(await fs.readFile(pendingNotesPath(), 'utf8')) 224const savePendingNotes = async (notes: PendingNote[]) => { 225 await fs.writeFile(pendingNotesPath(), JSON.stringify(notes, null, 2)) 228const eventBootstrapUrls = { 229 '0.1': 'https://tornadocash.eth.limo/events/mainnet/deposits_eth_0.1.json.gz', 230 '1': 'https://tornadocash.eth.limo/events/mainnet/deposits_eth_1.json.gz', 231 '10': 'https://tornadocash.eth.limo/events/mainnet/deposits_eth_10.json.gz', 232 '100': 'https://tornadocash.eth.limo/events/mainnet/deposits_eth_100.json.gz', 235const tryDownloadBootstrapEvents = async (amtEth: EthPoolAmount) => { 236 const url = eventBootstrapUrls[amtEth as keyof typeof eventBootstrapUrls] 237 if (!url) return null 240 betLog(`Downloading bootstrap events for ${amtEth} ETH pool...`) 241 const response = await axios.get(url, { 242 responseType: 'arraybuffer', 244 headers: { 'Accept-Encoding': 'gzip' }, 249 data = gunzipSync(Buffer.from(response.data)).toString('utf8') 251 data = Buffer.from(response.data).toString('utf8') 254 const events = JSON.parse(data) 255 betLog(`Downloaded ${events.length} bootstrap events`) 258 betLog(`Bootstrap download failed: ${(err as Error).message}`) 263const loadCachedEvents = async (amtEth: EthPoolAmount) => { 264 const filePath = eventsFilePath(amtEth) 266 const events = JSON.parse(await fs.readFile(filePath, 'utf8')) 267 const lastBlock = events.length > 0 ? events[events.length - 1].blockNumber : deployedBlocks[amtEth] 268 return { events, lastBlock } 270 const bootstrapEvents = await tryDownloadBootstrapEvents(amtEth) 271 if (bootstrapEvents && bootstrapEvents.length > 0) { 272 await saveCachedEvents(amtEth, bootstrapEvents) 273 const lastBlock = bootstrapEvents[bootstrapEvents.length - 1].blockNumber 274 return { events: bootstrapEvents, lastBlock } 276 return { events: [], lastBlock: deployedBlocks[amtEth] } 279const saveCachedEvents = async (amtEth: EthPoolAmount, events: DepositEvent[]) => { 280 await ensureTornadoDirs() 281 await fs.writeFile(eventsFilePath(amtEth), JSON.stringify(events, null, 2)) 284const fetchAndCacheEvents = async (provider: JsonRpcProvider, amtEth: EthPoolAmount) => { 285 const poolAddress = ethPools[amtEth] 286 const { events: cachedEvents, lastBlock } = await loadCachedEvents(amtEth) 287 let logsProvider = getLogsCapableProvider() 288 let contract = new Contract(poolAddress, tornadoAbi, logsProvider) 289 const currentBlock = await logsProvider.getBlockNumber() 291 if (lastBlock >= currentBlock) { 292 betLog(`Event cache up to date for ${amtEth} ETH pool (block ${currentBlock})`) 296 const totalBlocks = currentBlock - lastBlock 297 betLog(`Fetching events for ${amtEth} ETH pool from block ${lastBlock + 1} to ${currentBlock} (${totalBlocks.toLocaleString()} blocks)...`) 300 // Most public RPCs support 10k+ block ranges 301 let chunkSize = 10000 302 let consecutiveErrors = 0 303 let lastLoggedProgress = -1 305 for (let fromBlock = lastBlock + 1; fromBlock <= currentBlock; fromBlock += chunkSize) { 306 const toBlock = Math.min(fromBlock + chunkSize - 1, currentBlock) 308 const filter = contract.filters.Deposit() 310 const logs = await contract.queryFilter(filter, fromBlock, toBlock) 311 consecutiveErrors = 0 313 for (const log of logs) { 314 const eventLog = log as EventLog 316 blockNumber: eventLog.blockNumber, 317 transactionHash: eventLog.transactionHash, 318 commitment: eventLog.args.commitment, 319 leafIndex: Number(eventLog.args.leafIndex), 320 timestamp: Number(eventLog.args.timestamp), 324 const progressPct = Math.floor((fromBlock - lastBlock) / totalBlocks * 100) 326 // Save progress every 5% to avoid losing work if interrupted 327 if (newEvents.length > 0 && (progressPct % 5 === 0 && progressPct !== lastLoggedProgress || toBlock >= currentBlock)) { 328 const allEventsSoFar = [...cachedEvents, ...newEvents] 329 await saveCachedEvents(amtEth, allEventsSoFar) 333 if (progressPct !== lastLoggedProgress) { 334 lastLoggedProgress = progressPct 335 betLog(`${progressPct}% - block ${toBlock.toLocaleString()}, ${(cachedEvents.length + newEvents.length).toLocaleString()} events`) 339 const errMsg = (err as Error).message?.slice(0, 80) || String(err) 340 betLog(`Error (attempt ${consecutiveErrors}): ${errMsg}`) 343 if (newEvents.length > 0) { 344 const allEventsSoFar = [...cachedEvents, ...newEvents] 345 await saveCachedEvents(amtEth, allEventsSoFar) 348 // If chunk too large, reduce 349 if ((err as Error).message?.includes('block range') || (err as Error).message?.includes('query returned more')) { 350 chunkSize = Math.max(1000, Math.floor(chunkSize / 2)) 351 betLog(`Reducing chunk size to ${chunkSize} blocks`) 352 fromBlock -= chunkSize 356 if (consecutiveErrors >= 3) { 358 logsProvider = getLogsCapableProvider() 359 contract = new Contract(poolAddress, tornadoAbi, logsProvider) 360 consecutiveErrors = 0 363 await sleep(Math.min(consecutiveErrors * 1000, 5000)) 364 fromBlock -= chunkSize // Retry this chunk 369 const allEvents = [...cachedEvents, ...newEvents] 370 await saveCachedEvents(amtEth, allEvents) 372 betLog(`Cached ${allEvents.length} total events for ${amtEth} ETH pool`) 376const generateMerkleProof = async (deposit: TornadoDeposit, events: DepositEvent[], contract: Contract) => { 377 const leaves = events 378 .sort((a: DepositEvent, b: DepositEvent) => a.leafIndex - b.leafIndex) 379 .map((e: DepositEvent) => BigInt(e.commitment).toString(10)) 382 for (let i = 0; i < events.length; i++) { 383 if (BigInt(events[i].commitment) === BigInt(deposit.commitmentHex)) { 384 leafIndex = events[i].leafIndex 389 if (leafIndex < 0) throw new Error('Deposit not found in events - has it been confirmed?') 391 const tree = new MerkleTree(merkleTreeHeight, leaves) 392 const root = tree.root() 394 const isKnownRoot = await contract.isKnownRoot(toHex(root)) 397 const isSpent = await contract.isSpent(deposit.nullifierHex) 398 throwIf(() => isSpent, {nullifierHex: deposit.nullifierHex}) 400 const { pathElements, pathIndices } = tree.path(leafIndex) 402 return { root, pathElements, pathIndices } 405const generateZkProof = async ({ deposit, root, pathElements, pathIndices, recipient, relayerAddress, fee, refund }: { 406 deposit: TornadoDeposit, root: any, pathElements: any[], pathIndices: number[], 407 recipient: string, relayerAddress: string, fee: bigint, refund: bigint, 409 const { circuit, provingKey } = await loadCircuitFiles() 413 nullifierHash: deposit.nullifierHash, 414 recipient: bigInt(recipient), 415 relayer: bigInt(relayerAddress), 417 refund: bigInt(refund), 418 nullifier: deposit.nullifier, 419 secret: deposit.secret, 420 pathElements: pathElements, 421 pathIndices: pathIndices, 425 const startTime = Date.now() 427 const groth16 = await buildGroth16() 428 const proofData = await websnarkUtils.genWitnessAndProve(groth16, input, circuit, provingKey) 429 const { proof } = websnarkUtils.toSolidityInput(proofData) 431 betLog(`Proof generated in ${((Date.now() - startTime) / 1000).toFixed(1)}s`) 435 toHex(input.nullifierHash), 436 toHex(input.recipient, 20), 437 toHex(input.relayer, 20), 442 return { proof, args } 445const tornadoDeposit = async ({ wallet, amtEth }: {wallet: Wallet | JsonRpcSigner, amtEth: number | string}) => { 446 const amtKey = String(amtEth) as EthPoolAmount 447 const poolAddress = ethPools[amtKey] 450 const deposit = createDeposit({ 451 nullifier: rbigint(31), 455 const note = toHex(deposit.preimage, 62) 456 const noteString = `tornado-eth-${amtKey}-1-${note}` 458 betLog(`Depositing ${amtEth} ETH to Tornado Cash...`) 461 const contract = new Contract(poolAddress, tornadoAbi, wallet) 462 const tx = await contract.deposit(deposit.commitmentHex, { 463 value: parseEther(amtKey), 466 betLog(`Deposit tx submitted: ${tx.hash}`) 467 const receipt = await tx.wait() 468 betLog(`Deposit confirmed in block ${receipt.blockNumber}`) 472 commitment: deposit.commitmentHex, 474 blockNumber: receipt.blockNumber, 478const getRelayerStatus = async (relayerUrl: string) => { 479 const url = relayerUrl.replace(/\/$/, '') + '/status' 480 const response = await axios.get(url, { timeout: 10000 }) 481 const data = response.data 482 if (Array.isArray(data)) { 483 const mainnet = data.find((d: any) => d.netId === 1) 490const submitToRelayer = async (relayerUrl: string, poolAddress: string, proof: string, args: string[], netId = 1) => { 491 const url = relayerUrl.replace(/\/$/, '') + `/${netId}/v1/tornadoWithdraw` 492 const response = await axios.post(url, { 493 contract: poolAddress, 496 }, { timeout: 60000 }) 500const pollRelayerJob = async (relayerUrl: string, jobId: string, netId = 1, maxAttempts = 60) => { 501 const url = relayerUrl.replace(/\/$/, '') + `/${netId}/v1/jobs/` + jobId 503 for (let attempt = 0; attempt < maxAttempts; attempt++) { 507 const response = await axios.get(url, { timeout: 10000 }) 508 const { status, txHash, failedReason, confirmations } = response.data 510 betLog(`Relayer job status: ${status} (confirmations: ${confirmations || 0})`) 512 if (status === 'CONFIRMED') { 513 return { success: true, txHash } 515 if (status === 'FAILED') { 516 return { success: false, error: failedReason } 519 betLog(`Poll attempt ${attempt + 1} failed: ${(err as Error).message}`) 523 return { success: false, error: 'Timeout waiting for relayer confirmation' } 526const tornadoWithdraw = async ({ provider, noteString, recipientAddress }: {provider: JsonRpcProvider, noteString: string, recipientAddress: string}) => { 527 const { amount, deposit } = parseNote(noteString) 528 const poolAddress = ethPools[amount] 530 betLog(`Withdrawing ${amount} ETH to ${recipientAddress}...`) 532 const events = await fetchAndCacheEvents(provider, amount) 534 let relayerUrl, relayerStatus 535 for (const url of relayers) { 537 relayerStatus = await getRelayerStatus(url) 540 betLog(`Relayer address: ${relayerStatus.rewardAccount}`) 541 betLog(`Relayer fee: ${relayerStatus.tornadoServiceFee}%`) 544 betLog(`Relayer ${url} unavailable: ${(err as Error).message}`) 550 const { rewardAccount, tornadoServiceFee, gasPrices } = relayerStatus 551 const amountWei = parseEther(amount) 553 const gasLimit = 600000n 554 const gasPrice = BigInt(gasPrices.fast) 555 const gasCost = gasLimit * gasPrice 557 const serviceFeePercent = BigInt(Math.floor(tornadoServiceFee * 100)) 558 const serviceFee = (amountWei * serviceFeePercent) / 10000n 560 const premiumPercent = 5n 561 const premium = (gasCost * premiumPercent) / 100n 563 const baseFee = gasCost + serviceFee + premium 564 const fee = baseFee + (baseFee * 10n / 100n) 566 betLog(`Fee breakdown: gas=${formatEther(gasCost)} + service=${formatEther(serviceFee)} + premium=${formatEther(premium)} + buffer = ${formatEther(fee)} ETH`) 570 const contract = new Contract(poolAddress, tornadoAbi, provider) 571 const { root, pathElements, pathIndices } = await generateMerkleProof(deposit, events, contract) 573 const { proof, args } = await generateZkProof({ 578 recipient: recipientAddress, 579 relayerAddress: rewardAccount, 585 betLog('Submitting withdrawal to relayer...') 586 const submitResult = await submitToRelayer(relayerUrl, poolAddress, proof, args, netId) 588 throwIf(() => submitResult.error, {relayerUrl, error: submitResult.error}) 590 const jobId = submitResult.id 593 const result = await pollRelayerJob(relayerUrl, jobId, netId) 595 if (result.success) { 596 betLog(`Withdrawal confirmed! TX: ${result.txHash}`) 597 return { success: true, txHash: result.txHash } 602export const tornadoAmt = async ({ 606 delayMs = 24 * 60 * 60 * 1000, 609 fromWallet: any, toWallet: any, amtEthF?: number, delayMs?: number, provider?: JsonRpcProvider, 611 setEnvConf({ secretsMapping: defaultSecMappings } as unknown as envConfType) 612 const amtKey = String(amtEthF) as EthPoolAmount 613 assertDefined(ethPools[amtKey], {amtEthF, validAmounts: Object.keys(ethPools)}) 615 const prov: JsonRpcProvider = provider || fromWallet.provider || getAlchemyProv('homestead') 616 fromWallet = fromWallet.provider ? fromWallet : fromWallet.connect(prov) 618 const fromAddress = fromWallet.address 619 const toAddress = toWallet.address 620 const pairKey = `${fromAddress}-${toAddress}-${amtKey}` 622 betLog(`Tornado transfer: ${amtKey} ETH`) 626 const notes: PendingNote[] = await loadPendingNotes() 627 let note = notes.find((n: PendingNote) => n.pairKey === pairKey) 629 const now = Date.now() 631 if (!note || note.status === 'complete') { 634 const result = await tornadoDeposit({ wallet: fromWallet, amtEth: amtEthF }) 636 const newNote: PendingNote = { 637 id: crypto.randomUUID(), 640 noteString: result.noteString, 644 depositTxHash: result.txHash, 645 depositTimestamp: now, 646 withdrawAfter: now + delayMs, 647 withdrawTxHash: null, 651 const idx = notes.findIndex(n => n.pairKey === pairKey) 656 await savePendingNotes(notes) 658 const waitHours = (delayMs / 1000 / 60 / 60).toFixed(1) 659 betLog(`Deposit complete. Withdrawal available in ${waitHours} hours.`) 660 betLog(`Note saved to ${pendingNotesPath()}. Run again later to withdraw.`) 662 return { status: 'deposited', note: newNote } 665 if (note.status === 'deposited') { 666 if (now < note.withdrawAfter) { 667 const remainingMs = note.withdrawAfter - now 668 const remainingHours = (remainingMs / 1000 / 60 / 60).toFixed(1) 669 betLog(`Deposit found. Withdrawal available in ${remainingHours} hours.`) 670 return { status: 'waiting', note, remainingMs } 673 betLog('Delay period elapsed. Proceeding with withdrawal...') 675 note.status = 'withdrawing' 676 await savePendingNotes(notes) 678 return executeWithdrawal(note, {prov, toAddress, notes}) 681 if (note.status === 'withdrawing') { 682 betLog('Found note in withdrawing state. Retrying withdrawal...') 683 return executeWithdrawal(note, {prov, toAddress, notes}) 689const executeWithdrawal = async (note: PendingNote, {prov, toAddress, notes}: {prov: JsonRpcProvider, toAddress: string, notes: PendingNote[]}) => { 691 const result = await tornadoWithdraw({ 693 noteString: note.noteString, 694 recipientAddress: toAddress, 697 note.status = 'complete' 698 note.withdrawTxHash = result!.txHash 699 await savePendingNotes(notes) 702 return { status: 'complete', note } 704 note.status = 'deposited' 705 await savePendingNotes(notes)