🌳
pt0/serverF/ethersF/tornadoAI.mts
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'
12import { pathDownJoin } from '../pathF.mts'
21import { setEnvConf, type envConfType } from '../envConfF.mts'
23type EthPoolAmount = keyof typeof ethPools
25interface TornadoDeposit {
26 nullifier: any
27 secret: any
28 preimage: Buffer
29 commitment: any
30 commitmentHex: string
31 nullifierHash: any
32 nullifierHex: string
35interface DepositEvent {
36 blockNumber: number
37 transactionHash: string
38 commitment: string
39 leafIndex: number
40 timestamp: number
43interface PendingNote {
44 id: string
45 pairKey: string
46 status: 'deposited' | 'withdrawing' | 'complete'
47 noteString: string
48 fromAddress: string
49 toAddress: string
50 amtEth: string
51 depositTxHash: string
52 depositTimestamp: number
53 withdrawAfter: 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
85const ethPools = {
86 '0.1': '0x12D66f87A04A9E220743712cE6d9bB1B5616B8Fc', // contract
87 '1': '0x47CE0C6eD5B0Ce3d3A51fdb1C52DC66a7c3c2936', // contract
88 '10': '0x910Cbd523D972eb0a6f4cAe4618aD62622b39DbF', // contract
89 '100': '0xA160cdAB225685dA1d56aa342Ad8841c3b53f291', // contract
92const deployedBlocks = {
93 '0.1': 9116966,
94 '1': 9116966,
95 '10': 9116966,
96 '100': 9116966,
99const relayers = [
100 'https://tornadowithdraw.com',
103const circuitUrls = {
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',
108const tornadoAbi = [
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)',
117const tornadoDataPath = () => pathDownJoin(ptDiskDir, 'tornado')
118const circuitPath = () => pathDownJoin(tornadoDataPath(), 'circuit')
119const eventsPath = () => pathDownJoin(tornadoDataPath(), 'events')
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)
142 ])
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)
147 return deposit
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)
153 assertDefined(match, {noteString: noteString.slice(0, 50)})
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 })
161 return {
162 currency: groups.currency,
163 amount: groups.amount as EthPoolAmount,
164 netId: Number(groups.netId),
165 deposit
166 }
169const ensureTornadoDirs = async () => {
170 await mkDirUnlessExist(tornadoDataPath())
171 await mkDirUnlessExist(circuitPath())
172 await mkDirUnlessExist(eventsPath())
175const downloadAndUnzip = async (url: string, destPath: string, description: string) => {
176 betLog(`Downloading ${description} from ${url}...`)
177 try {
178 const response = await axios.get(url, {
179 responseType: 'arraybuffer',
180 timeout: 300000, // 5 min timeout for large files
181 })
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()
189 assertNonEmpty(entries, {description})
191 const content: Buffer = entries[0].getData()
192 await fs.writeFile(destPath, content)
193 betLog(`Extracted ${description} (${(content.length / 1024 / 1024).toFixed(2)} MB)`)
194 } catch (err) {
195 throwDebugCtx({description, error: (err as Error).message})
196 }
199const ensureCircuitFiles = async () => {
200 await ensureTornadoDirs()
201 if (!await fileExists(withdrawJsonPath())) {
202 await downloadAndUnzip(circuitUrls.withdraw, withdrawJsonPath(), 'withdraw.json')
203 }
204 if (!await fileExists(provingKeyPath())) {
205 await downloadAndUnzip(circuitUrls.provingKey, provingKeyPath(), 'proving_key.bin')
206 }
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()
218 if (!await fileExists(pendingNotesPath())) {
219 return []
220 }
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
239 try {
240 betLog(`Downloading bootstrap events for ${amtEth} ETH pool...`)
241 const response = await axios.get(url, {
242 responseType: 'arraybuffer',
243 timeout: 120000,
244 headers: { 'Accept-Encoding': 'gzip' },
245 })
247 let data
248 try {
249 data = gunzipSync(Buffer.from(response.data)).toString('utf8')
250 } catch {
251 data = Buffer.from(response.data).toString('utf8')
252 }
254 const events = JSON.parse(data)
255 betLog(`Downloaded ${events.length} bootstrap events`)
256 return events
257 } catch (err) {
258 betLog(`Bootstrap download failed: ${(err as Error).message}`)
259 return null
260 }
263const loadCachedEvents = async (amtEth: EthPoolAmount) => {
264 const filePath = eventsFilePath(amtEth)
265 if (await fileExists(filePath)) {
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 }
269 }
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 }
275 }
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})`)
293 return cachedEvents
294 }
296 const totalBlocks = currentBlock - lastBlock
297 betLog(`Fetching events for ${amtEth} ETH pool from block ${lastBlock + 1} to ${currentBlock} (${totalBlocks.toLocaleString()} blocks)...`)
299 const newEvents = []
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()
309 try {
310 const logs = await contract.queryFilter(filter, fromBlock, toBlock)
311 consecutiveErrors = 0
313 for (const log of logs) {
314 const eventLog = log as EventLog
315 newEvents.push({
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),
321 })
322 }
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)
330 }
332 // Log every 1%
333 if (progressPct !== lastLoggedProgress) {
334 lastLoggedProgress = progressPct
335 betLog(`${progressPct}% - block ${toBlock.toLocaleString()}, ${(cachedEvents.length + newEvents.length).toLocaleString()} events`)
336 }
337 } catch (err) {
338 consecutiveErrors++
339 const errMsg = (err as Error).message?.slice(0, 80) || String(err)
340 betLog(`Error (attempt ${consecutiveErrors}): ${errMsg}`)
342 // Save progress
343 if (newEvents.length > 0) {
344 const allEventsSoFar = [...cachedEvents, ...newEvents]
345 await saveCachedEvents(amtEth, allEventsSoFar)
346 }
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
353 continue
354 }
356 if (consecutiveErrors >= 3) {
357 rotateRpc()
358 logsProvider = getLogsCapableProvider()
359 contract = new Contract(poolAddress, tornadoAbi, logsProvider)
360 consecutiveErrors = 0
361 }
363 await sleep(Math.min(consecutiveErrors * 1000, 5000))
364 fromBlock -= chunkSize // Retry this chunk
365 continue
366 }
367 }
369 const allEvents = [...cachedEvents, ...newEvents]
370 await saveCachedEvents(amtEth, allEvents)
372 betLog(`Cached ${allEvents.length} total events for ${amtEth} ETH pool`)
373 return allEvents
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))
381 let leafIndex = -1
382 for (let i = 0; i < events.length; i++) {
383 if (BigInt(events[i].commitment) === BigInt(deposit.commitmentHex)) {
384 leafIndex = events[i].leafIndex
385 break
386 }
387 }
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))
395 assertDefined(isKnownRoot)
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,
408}) => {
409 const { circuit, provingKey } = await loadCircuitFiles()
411 const input = {
412 root: root,
413 nullifierHash: deposit.nullifierHash,
414 recipient: bigInt(recipient),
415 relayer: bigInt(relayerAddress),
416 fee: bigInt(fee),
417 refund: bigInt(refund),
418 nullifier: deposit.nullifier,
419 secret: deposit.secret,
420 pathElements: pathElements,
421 pathIndices: pathIndices,
422 }
424 betLog('Generating zkSNARK proof...')
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`)
433 const args = [
434 toHex(input.root),
435 toHex(input.nullifierHash),
436 toHex(input.recipient, 20),
437 toHex(input.relayer, 20),
438 toHex(input.fee),
439 toHex(input.refund),
440 ]
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]
448 assertDefined(poolAddress, {amtEth, validAmounts: Object.keys(ethPools)})
450 const deposit = createDeposit({
451 nullifier: rbigint(31),
452 secret: rbigint(31),
453 })
455 const note = toHex(deposit.preimage, 62)
456 const noteString = `tornado-eth-${amtKey}-1-${note}`
458 betLog(`Depositing ${amtEth} ETH to Tornado Cash...`)
459 betLog(`Pool address: ${poolAddress}`)
461 const contract = new Contract(poolAddress, tornadoAbi, wallet)
462 const tx = await contract.deposit(deposit.commitmentHex, {
463 value: parseEther(amtKey),
464 })
466 betLog(`Deposit tx submitted: ${tx.hash}`)
467 const receipt = await tx.wait()
468 betLog(`Deposit confirmed in block ${receipt.blockNumber}`)
470 return {
471 noteString,
472 commitment: deposit.commitmentHex,
473 txHash: tx.hash,
474 blockNumber: receipt.blockNumber,
475 }
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)
484 assertDefined(mainnet, {relayerUrl})
485 return mainnet
486 }
487 return data
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,
494 proof,
495 args,
496 }, { timeout: 60000 })
497 return response.data
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++) {
504 await sleep(5000)
506 try {
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 }
514 }
515 if (status === 'FAILED') {
516 return { success: false, error: failedReason }
517 }
518 } catch (err) {
519 betLog(`Poll attempt ${attempt + 1} failed: ${(err as Error).message}`)
520 }
521 }
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) {
536 try {
537 relayerStatus = await getRelayerStatus(url)
538 relayerUrl = url
539 betLog(`Using relayer: ${url}`)
540 betLog(`Relayer address: ${relayerStatus.rewardAccount}`)
541 betLog(`Relayer fee: ${relayerStatus.tornadoServiceFee}%`)
542 break
543 } catch (err) {
544 betLog(`Relayer ${url} unavailable: ${(err as Error).message}`)
545 }
546 }
548 assertDefined(relayerUrl)
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`)
568 const refund = 0n
570 const contract = new Contract(poolAddress, tornadoAbi, provider)
571 const { root, pathElements, pathIndices } = await generateMerkleProof(deposit, events, contract)
573 const { proof, args } = await generateZkProof({
574 deposit,
575 root,
576 pathElements,
577 pathIndices,
578 recipient: recipientAddress,
579 relayerAddress: rewardAccount,
580 fee,
581 refund,
582 })
584 const netId = 1
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
591 betLog(`Relayer job ID: ${jobId}`)
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 }
598 }
599 throwDebugCtx({relayerUrl, jobId, error: result.error})
602export const tornadoAmt = async ({
603 fromWallet,
604 toWallet,
605 amtEthF = 0.1,
606 delayMs = 24 * 60 * 60 * 1000,
607 provider,
608}: {
609 fromWallet: any, toWallet: any, amtEthF?: number, delayMs?: number, provider?: JsonRpcProvider,
610}) => {
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`)
623 betLog(`From: ${fromAddress}`)
624 betLog(`To: ${toAddress}`)
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') {
632 betLog('Creating new deposit...')
634 const result = await tornadoDeposit({ wallet: fromWallet, amtEth: amtEthF })
636 const newNote: PendingNote = {
637 id: crypto.randomUUID(),
638 pairKey,
639 status: 'deposited',
640 noteString: result.noteString,
641 fromAddress,
642 toAddress,
643 amtEth: amtKey,
644 depositTxHash: result.txHash,
645 depositTimestamp: now,
646 withdrawAfter: now + delayMs,
647 withdrawTxHash: null,
648 }
650 if (note) {
651 const idx = notes.findIndex(n => n.pairKey === pairKey)
652 notes[idx] = newNote
653 } else {
654 notes.push(newNote)
655 }
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 }
663 }
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 }
671 }
673 betLog('Delay period elapsed. Proceeding with withdrawal...')
675 note.status = 'withdrawing'
676 await savePendingNotes(notes)
678 return executeWithdrawal(note, {prov, toAddress, notes})
679 }
681 if (note.status === 'withdrawing') {
682 betLog('Found note in withdrawing state. Retrying withdrawal...')
683 return executeWithdrawal(note, {prov, toAddress, notes})
684 }
686 throwDebugCtx({noteStatus: note.status})
689const executeWithdrawal = async (note: PendingNote, {prov, toAddress, notes}: {prov: JsonRpcProvider, toAddress: string, notes: PendingNote[]}) => {
690 try {
691 const result = await tornadoWithdraw({
692 provider: prov,
693 noteString: note.noteString,
694 recipientAddress: toAddress,
695 })
697 note.status = 'complete'
698 note.withdrawTxHash = result!.txHash
699 await savePendingNotes(notes)
701 betLog('Tornado transfer complete!')
702 return { status: 'complete', note }
703 } catch (err) {
704 note.status = 'deposited'
705 await savePendingNotes(notes)
706 throw err
707 }