91822 w a low editing the recipient during portal transfer preparation sends the ens name to the replaced address
Submitted on Sep 4th 2026 at 14:59:46 UTC by @manvi for Audit Competition | ENS
- Report ID: #91822
- Report Type: Websites & Apps
- Report severity: Low
- Target: https://github.com/immunefi-team/audit-comp-ens/tree/audit-comp-ready/apps/portal
- Impacts:
- Causing the application to display or pre-fill an incorrect address, name, or transaction detail, so that a user sends funds or submits a transaction to an unintended recipient, such as: - resolving or rendering an ENS name to an attacker-controlled address - showing the wrong recipient or amount on a transaction the user then approves
Description
Brief / Intro
I analysed the Portal's ENS V2 transfer flow and found that the recipient shown in the transfer form can diverge from the recipient saved for transaction construction.
The sequence is straightforward:
- The user enters recipient A.
- The user presses Transfer name.
SendNameFormcallsstartTransfer()with A.useTransferNamebegins an asynchronousgetEnsTokenId()read and keeps A in the mutation variables.- Portal changes the button to Preparing…, but leaves the recipient field editable.
- The user notices A is wrong and replaces it with intended recipient B.
- The form and recipient preview now show B.
- The earlier
getEnsTokenId()request completes. useTransferName.onSuccesssaves the original mutation result containing A.- The generated transaction callbacks close over A.
- Portal opens a generic transaction modal that does not disclose the saved recipient.
- The user proceeds.
transferToken()encodessafeTransferFrom(owner, A, tokenId, 1, "0x").- The transaction succeeds and A receives the ENS token.
Portal itself warns:
Transferring ownership of an ENS name is irreversible. Make sure you check the recipient address before proceeding.
The bug defeats that safety check. A user can notice the wrong recipient, correct it before wallet approval, and still transfer the name to the address they replaced.
The final PoC makes the asynchronous interval deterministic by delaying the real getTokenId(uint256) RPC response. The response is not altered or replaced; it is released unchanged after the user-visible recipient has been changed to B.
The run then executes the real Portal transaction flow and checks authoritative ERC-1155 balances on Anvil:
BEFORE:
victim = 1
A = 0
B = 0
AFTER:
victim = 0
A = 1
B = 0
The test exits successfully:
1 passed (26.2s)
Vulnerability Details
1. SendNameForm snapshots the recipient when the user starts the transfer
apps/portal/src/features/transfer/components/SendNameForm.tsx derives the current recipient from the form:
const resolution = useAddressResolution(recipientInput)
const { address: recipient, isResolving } = resolution
const { startTransfer, transactions, isPreparing, prepError } =
useTransferName({ name, registryAddress, owner })
The submit path is:
const runTransfer = () => {
if (!recipient || !canStart) return
startTransfer({ recipient, options: effectiveOptions })
}
If A is visible when the user presses the button, A becomes the recipient passed into the asynchronous transfer-preparation mutation.
2. isPreparing disables the button but not the recipient field
The form prevents a second submit while preparation is running:
const canStart =
hasValidRecipient && !isResolving && !isPreparing && detachTargetsSettled
The submit button is disabled through canStart:
<Button
variant="default"
onClick={runTransfer}
disabled={!canStart}
>
{isPreparing ? 'Preparing…' : 'Transfer name'}
</Button>
However the recipient field remains:
<AddressNameInput
value={recipientInput}
onChange={setRecipientInput}
resolution={resolution}
className="h-9"
/>
No disabled={isPreparing} value is passed.
The reusable AddressNameInput component already supports a disabled prop. The transfer page simply does not use it here.
The browser PoC proves the production behavior directly:
[POC] recipient input remains ENABLED while Preparing = true
3. useTransferName preserves A across the asynchronous token-ID lookup
apps/portal/src/features/transfer/hooks/useTransferName.ts prepares the flow with:
const prepareMutation = useMutation(
resultMutationOptions({
mutationFn: ({ recipient, options }: StartTransferParams) =>
getEnsTokenId({
label: getLabel(name),
registryAddress,
}).map((tokenId) => ({ recipient, tokenId, options })),
onSuccess: (params) => {
startedStepsRef.current = new Set()
setSavedParams(params)
openModal()
},
}),
)
The recipient inside the .map() callback is the mutation variable supplied when startTransfer() was called.
Changing the form from A to B does not mutate that already-started request.
When getEnsTokenId() finishes, the value saved by setSavedParams(params) is still:
recipient = A
There is no comparison against the current recipient in the form before opening the transaction modal.
4. All later transaction callbacks use savedParams
The transaction list is built from the saved snapshot:
if (!savedParams) return []
const { recipient, tokenId, options } = savedParams
const steps = buildTransferPlan(options)
Each runner closes over that saved recipient:
const runners = steps.map((step) => async () => {
const id = `transfer-${name}-${step}`
...
await runStep(step, id, recipient, tokenId)
})
The final transfer step is:
.with('transfer-token', () =>
transferToken({
...common,
registryAddress,
tokenId,
recipient,
id,
}),
)
By this point the live form can show B while the transaction runner is permanently bound to A.
5. transferToken() encodes the saved recipient into safeTransferFrom
apps/portal/src/features/transfer/helpers/transferToken.ts performs the actual ENS V2 ownership transfer:
const data = encodeFunctionData({
abi: erc1155Abi,
functionName: 'safeTransferFrom',
args: [
walletWithAccount.account.address,
recipient,
tokenId,
1n,
'0x',
],
})
The transaction manager then submits that calldata to registryAddress.
Therefore the stale value controls the real ERC-1155 transfer destination.
6. The Portal transaction modal does not reveal which recipient was saved
The transaction descriptor uses:
title: STEP_LABELS[step],
transactionName: `${STEP_LABELS[step]} - ${name}`,
For the final step the user gets generic Portal text such as:
Transfer name
The successful PoC observed:
Transaction overview Transfer name Not Started Est. Cost Not yet Start Close
and asserted:
modal discloses stale A = false
modal discloses intended B = false
So Portal provides no final application-level indication that A is still the prepared destination.
7. The same stale snapshot also includes transfer options
startTransfer() receives both:
{
recipient,
options: effectiveOptions,
}
The same class of stale state can therefore apply to the cleanup switches while preparation is running.
This report does not rely on that secondary effect. The hardened PoC disables the visible detach-resolver option before preparation and demonstrates the Critical impact with only the final transfer-token step.
Why this is not merely a UI/UX issue
The UI mismatch is only the entry point. The final effect is a real ownership transfer.
The browser-to-chain PoC executes the actual Portal transaction and proves:
Portal field immediately before wallet flow:
B
Transaction:
SUCCESS
ERC-1155 post-state:
victim = 0
A = 1
B = 0
That is an application transaction-integrity failure, not presentation-only stale state.
Why this is not intended design
Portal explicitly tells the user to check the recipient because the transfer is irreversible.
An interface intentionally allowing edits during preparation would need one of two behaviors:
edit during preparation
|
+--> cancel / invalidate old preparation
|
+--> rebuild using the new values
Alternatively, the form can be locked once preparation begins.
The current behavior instead allows:
form shows B
prepared transaction uses A
There is no reasonable transfer-flow interpretation under which a corrected recipient should be ignored while remaining visibly accepted by the application.
Why this does not require a trusted or privileged actor
The vulnerability does not require:
- Portal administrator access;
- ENS governance access;
- backend credentials;
- a privileged protocol account;
- victim private-key compromise;
- malicious contract deployment;
- malicious resolver behavior;
- a failed transaction;
- a retry;
- transaction-ID reuse.
The victim is the legitimate owner and uses the normal Portal transfer flow.
The only timing condition is that the recipient is edited while the asynchronous token-ID preparation is still pending.
Why the deterministic RPC delay does not fabricate the bug
The PoC intercepts the real browser request for:
getTokenId(uint256 labelHash)
It does not change:
- the RPC method;
- the contract address;
- the calldata;
- the token ID;
- the RPC response.
The request is paused, the user-visible recipient is changed, and then that exact request is continued unchanged:
[POC] 🚧 intercepted production getTokenId(uint256) RPC
...
[POC] ▶ releasing unchanged getTokenId RPC
The delay only makes the naturally asynchronous interval reproducible on demand.
Any real RPC latency long enough for the recipient to be edited creates the same state relationship:
submitted mutation variables = old recipient
live form state = new recipient
Wallet signing prompt caveat
The browser-to-chain PoC uses the repository's own @ensdomains/headless-web3-provider fixture.
That fixture authorizes the wallet request without rendering a MetaMask, Rainbow, hardware-wallet, or other human-facing decoded signing prompt.
For that reason, this report does not claim that every real wallet will hide recipient A.
A wallet that fully decodes ERC-1155 safeTransferFrom may show A clearly enough for an attentive user to notice the mismatch and reject the transaction.
That is an independent downstream mitigation, not a fix for the Portal behavior demonstrated here.
Portal itself has already done all of the following before control reaches the wallet:
accepted corrected recipient B
displayed corrected recipient B
opened a Portal transaction modal
failed to disclose A
failed to disclose B
submitted a transaction still bound to A
Wallet decoding quality also varies. A wallet may show decoded arguments, generic contract-interaction information, truncated values, or raw calldata.
The narrow claim made here is fully demonstrated regardless of wallet presentation:
Portal accepts and displays B while constructing and successfully submitting the ENS ownership transfer using A.
Why this is not one of the disclosed known issues
R3-07 has a different root cause and consequence
R3-07 concerns a transaction ID reused after a failed attempt.
Its effect is that a later successful retry skips archive/history/telemetry handling and can remain displayed as pending.
This finding occurs before the transaction actor is started.
It does not require:
failed transaction
retry
fixed transaction ID
history
telemetry
archive state
The old recipient is already captured during the initial preparation.
WEB-1257 / QA-01 are separate transfer problems
WEB-1257 concerns registry detachment after a parent transfer.
QA-01 concerns role combinations that lead to errors in later profile / primary-name operations.
Neither issue changes the recipient encoded into the ERC-1155 safeTransferFrom.
A fix for those issues would leave this stale-recipient race intact.
Audited Revision Verification
The frozen competition import verified for this finding is:
cda79acaad59711b943fc68207ebb3f1d0ff8596
That commit records:
fork ensdomains/apps-monorepo at 63772fd872af472ced58b009499355f3430c2a86
The relevant frozen blobs include:
apps/portal/src/features/transfer/components/SendNameForm.tsx
blob SHA: 0d48b575648a728f7f8d4415e4024b7f6c517258
apps/portal/src/features/transfer/hooks/useTransferName.ts
blob SHA: d3f124ff0b64fb1e450b3b46259f7b84880cf5f4
apps/portal/src/features/transfer/helpers/transferToken.ts
blob SHA: c76d53ad49f85b76e7d0cffc9acf5dd68e8c0dec
apps/portal/src/features/transfer/utils/buildTransferPlan.ts
blob SHA: 2874722beb2dece5bbef12cc58c52a2be9617f84
apps/portal/src/features/address/components/AddressNameInput.tsx
blob SHA: f3f45bbb9fb17970a6a101fbaec3671eb9afb1c4
apps/portal/src/features/transaction-manager/components/TransactionsOverviewContent.tsx
blob SHA: 837ef9cd17ec94d3c6ae01d8fefc53b36ae1940e
The PoC exercises the frozen behavior with:
real Portal rendering
real transfer form
real getTokenId(uint256) RPC
real transaction descriptors
real transaction manager
real headless wallet authorization
real ERC-1155 transfer
real Anvil transaction receipt
direct post-transaction ERC-1155 balance reads
The final successful run completed with:
1 passed (26.2s)
The successfully executed PoC file has SHA-256:
a540fd562cb90e48f1a14af7232f92b4d8f42cc44c9a71fb08ce63155295045b
Impact Details
The demonstrated impact is an irreversible ENS V2 ownership transfer to an address that the user has already replaced in the Portal UI.
Concrete demonstration:
Initial Portal recipient:
A = 0x70997970C51812dc3A010C7d01b50e0d17dc79C8
User corrects Portal recipient:
B = 0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC
Portal immediately before wallet flow:
B
Portal transaction modal:
A not disclosed
B not disclosed
Real transaction:
SUCCESS
Authoritative ERC-1155 post-state:
victim = 0
A = 1
B = 0
If A is attacker-controlled, the attacker receives the ENS NFT.
Even when A is merely an unintended third party, the transfer cannot be undone by Portal after it succeeds. Recovery depends on A voluntarily transferring the name back.
I claim only the transfer-recipient mismatch reproduced end-to-end here. I do not claim that every wallet hides A at its signing prompt.
Preconditions
- The user owns a transferable ENS V2
.ethname. - Recipient A is present when the user presses Transfer name.
- The
getEnsTokenId()preparation has not completed yet. - The user changes the recipient from A to B during that pending interval.
- The original preparation completes after the edit.
- The user proceeds with the transaction.
- The downstream wallet does not provide a sufficiently clear warning that causes the user to reject the stale-A transaction.
For deterministic reproduction, the PoC pauses the real getTokenId(uint256) RPC until after B is visible.
No privileged actor, malicious backend, malicious RPC response, transaction retry, or contract modification is required.
Recommended Remediation
The transfer's displayed parameters and prepared parameters must never diverge.
Option 1 — freeze prepared fields while isPreparing
At minimum:
<AddressNameInput
value={recipientInput}
onChange={setRecipientInput}
resolution={resolution}
disabled={isPreparing}
/>
The switches contributing to effectiveOptions should also be disabled for the same period.
Option 2 — invalidate stale preparation results when form values change
If editing during preparation is intended, associate each preparation attempt with a monotonically increasing identifier.
Conceptually:
const runId = ++currentRun.current
const result = await prepare(...)
if (runId !== currentRun.current) return
setSavedParams(result)
Changing the recipient or any transfer option should invalidate the previous run.
An old async response must not be allowed to open the transaction modal.
Option 3 — re-check the recipient before building transactions
Before committing savedParams or before exposing the transaction modal, verify that:
prepared recipient
==
current resolved recipient
If not, discard the prepared result and require a new preparation.
Option 4 — show the actual destination in Portal's transaction modal
Portal should show the exact saved recipient used by the final transaction:
Transfer name
To: 0x...
This should be derived from the same immutable parameters used to generate calldata.
Recommended invariant
The recipient displayed by Portal at approval time must be the same recipient encoded in the ENS ownership-transfer transaction. If any prepared field changes, the old preparation must be blocked, cancelled, or discarded.
References
In-scope Explorer / Portal
apps/portal/src/features/transfer/components/SendNameForm.tsx- live
recipientInput startTransfer({ recipient, options })isPreparing- recipient input remains enabled
- generic irreversible-transfer warning
- live
apps/portal/src/features/transfer/hooks/useTransferName.ts- asynchronous
getEnsTokenId() - original mutation variables preserved
setSavedParams(params)- transaction callbacks close over saved recipient
transferToken(... recipient ...)
- asynchronous
apps/portal/src/features/transfer/helpers/transferToken.ts- ERC-1155
safeTransferFrom - saved recipient used as second argument
- real transaction-manager submission
- ERC-1155
apps/portal/src/features/transfer/utils/buildTransferPlan.ts- transfer step is always appended
- cleanup steps execute before transfer
apps/portal/src/features/address/components/AddressNameInput.tsx- supports
disabled - transfer form does not set it during preparation
- supports
apps/portal/src/features/transaction-manager/components/TransactionsOverviewContent.tsx- generic transaction overview
- no recipient disclosure
E2E infrastructure used by the PoC
e2e/fixtures/playwright.portal.fixture.ts- Portal browser fixture
- headless wallet
- default Anvil accounts
- real local Sepolia fork
e2e/fixtures/makeName.ts- real V2
.ethregistration on Anvil
- real V2
e2e/helpers/portal-auth.ts- transaction authorization helper
Proof of Concept
PoC Classification
This is a repo-native browser-to-chain E2E PoC against the frozen competition code.
It uses the real:
- Portal transfer page;
SendNameForm;useTransferName;buildTransferPlan;transferToken;getTokenId(uint256)RPC request;- transaction modal;
- transaction manager;
- headless wallet authorization path;
- V2 ETH Registry;
- ERC-1155 ownership transfer;
- Anvil Sepolia fork;
- post-transaction
balanceOfreads.
The only deterministic intervention is that the exact real getTokenId(uint256) browser RPC is paused and later continued unchanged.
No fake token ID, fake RPC response, mocked transfer helper, or mocked ownership result is used for the demonstrated impact.
The PoC deliberately disables the visible detach-resolver option before preparation so that the demonstrated transaction plan contains only the final irreversible transfer-token step.
PoC File
e2e/projects/portal/tests/stale-recipient-transfer.poc.spec.ts
SHA-256 of the successfully executed file:
a540fd562cb90e48f1a14af7232f92b4d8f42cc44c9a71fb08ce63155295045b
Complete Hardened E2E PoC
The complete successfully executed PoC source is included inline below.
import { ensL1Contracts, supportedL1Chains } from '@ensdomains/ensjs/chain'
import type { Web3ProviderBackend } from '@ensdomains/headless-web3-provider'
import type { Page } from '@playwright/test'
import {
erc1155Abi,
labelhash,
parseAbi,
toFunctionSelector,
type Address,
} from 'viem'
import {
connectWithHeadlessWallet,
expect,
test,
} from '../../../fixtures/playwright.portal.fixture.js'
import {
publicClient,
} from '../../../helpers/anvil-client.js'
import {
authorizeTransaction,
} from '../../../helpers/portal-auth.js'
const PORTAL_APP_URL =
process.env.PORTAL_APP_URL ??
'http://127.0.0.1:3001'
const ensjsSepolia =
ensL1Contracts[
supportedL1Chains.sepolia
]
const ETH_REGISTRY =
ensjsSepolia.ensRegistry.address
/*
* ENSJS getTokenId() calls:
*
* registry.getTokenId(labelhash(label))
*
* Detect exactly this read at the browser RPC boundary.
*/
const GET_TOKEN_ID_SELECTOR =
toFunctionSelector(
'getTokenId(uint256)',
).toLowerCase()
const GET_TOKEN_ID_ABI =
parseAbi([
'function getTokenId(uint256 labelHash) view returns (uint256)',
])
type RpcCall = {
method?: string
params?: unknown[]
}
function containsTargetGetTokenIdCall(
payload: unknown,
): boolean {
const calls =
Array.isArray(payload)
? payload
: [payload]
return calls.some(
(entry) => {
const call =
entry as RpcCall
if (
call.method !==
'eth_call'
) {
return false
}
const tx =
(
call.params?.[0] ??
{}
) as {
to?: string
data?: string
}
if (
typeof tx.to !==
'string' ||
typeof tx.data !==
'string'
) {
return false
}
return (
tx.to.toLowerCase() ===
ETH_REGISTRY.toLowerCase() &&
tx.data
.toLowerCase()
.startsWith(
GET_TOKEN_ID_SELECTOR,
)
)
},
)
}
async function readTokenBalance(
account: Address,
tokenId: bigint,
): Promise<bigint> {
return publicClient.readContract({
address:
ETH_REGISTRY,
abi:
erc1155Abi,
functionName:
'balanceOf',
args: [
account,
tokenId,
],
})
}
async function driveTransactionsToSuccess(
page: Page,
wallet: Web3ProviderBackend,
successTxIds: string[],
timeoutMs = 180_000,
): Promise<void> {
const succeeded =
new Set<string>()
const onConsole =
(msg: {
text(): string
}) => {
const text =
msg.text()
if (
text.includes(
'[TRANSACTION MANAGER]',
)
) {
console.log(
'[BROWSER]',
text,
)
}
for (
const id of
successTxIds
) {
if (
text.includes(
`Transaction ${id} state: success`,
)
) {
succeeded.add(
id,
)
}
}
}
page.on(
'console',
onConsole,
)
try {
const deadline =
Date.now() +
timeoutMs
while (
Date.now() <
deadline
) {
const dialog =
page.locator(
'[data-slot="dialog-content"][data-state="open"]',
)
const retry =
dialog.getByRole(
'button',
{
name:
/^(Retry|Try again)$/i,
},
)
if (
await retry
.isVisible()
.catch(
() => false,
)
) {
const text =
(
await dialog
.innerText()
.catch(
() => '',
)
)
.replace(
/\s+/g,
' ',
)
.trim()
throw new Error(
`Portal transaction entered error state: ${text}`,
)
}
const openWallet =
dialog.getByRole(
'button',
{
name:
/open wallet/i,
},
)
if (
await openWallet
.isVisible()
.catch(
() => false,
)
) {
console.log(
'[POC] clicking Open wallet',
)
await openWallet.click()
await authorizeTransaction(
wallet,
60_000,
)
await page.waitForTimeout(
500,
)
continue
}
const waitingButton =
dialog.getByRole(
'button',
{
name:
/^Waiting\.\.\.$/i,
},
)
if (
await waitingButton
.isVisible()
.catch(
() => false,
)
) {
const iconWalletButton =
waitingButton.locator(
'xpath=preceding-sibling::button[1]',
)
if (
await iconWalletButton
.isVisible()
.catch(
() => false,
)
) {
await iconWalletButton.click()
await authorizeTransaction(
wallet,
60_000,
)
await page.waitForTimeout(
500,
)
continue
}
}
const primary =
dialog.getByRole(
'button',
{
name:
/^(Start|Next|Done)$/i,
},
)
if (
await primary
.isVisible()
.catch(
() => false,
) &&
await primary
.isEnabled()
.catch(
() => false,
)
) {
const label =
await primary
.innerText()
console.log(
'[POC] clicking modal primary =',
label,
)
await primary.click()
await page.waitForTimeout(
500,
)
continue
}
if (
succeeded.size ===
successTxIds.length
) {
break
}
await page.waitForTimeout(
500,
)
}
} finally {
page.off(
'console',
onConsole,
)
}
expect(
succeeded.size,
).toBe(
successTxIds.length,
)
}
test.describe(
'CRITICAL stale transfer recipient',
() => {
test(
'Portal displays corrected B but transfers ENS ERC1155 to stale A',
async ({
portalPage: page,
wallet,
accounts,
makeName,
}) => {
test.setTimeout(
240_000,
)
console.log('')
console.log(
'============================================================',
)
console.log(
'CRITICAL STALE RECIPIENT — REAL BROWSER / REAL CHAIN',
)
console.log(
'============================================================',
)
await connectWithHeadlessWallet(
page,
wallet,
)
/*
* user = victim / current ENS owner
* user2 = stale recipient A
* user3 = corrected intended recipient B
*/
const victim =
accounts.getAddress(
'user',
)
const staleRecipient =
accounts.getAddress(
'user2',
)
const intendedRecipient =
accounts.getAddress(
'user3',
)
expect(
staleRecipient
.toLowerCase(),
).not.toBe(
intendedRecipient
.toLowerCase(),
)
const name =
await makeName({
label:
'critical-stale-recipient',
owner:
'user',
})
const label =
name.replace(
/\.eth$/,
'',
)
const tokenId =
await publicClient
.readContract({
address:
ETH_REGISTRY,
abi:
GET_TOKEN_ID_ABI,
functionName:
'getTokenId',
args: [
BigInt(
labelhash(
label,
),
),
],
})
console.log(
'[POC] name =',
name,
)
console.log(
'[POC] victim =',
victim,
)
console.log(
'[POC] stale recipient A =',
staleRecipient,
)
console.log(
'[POC] intended recipient B =',
intendedRecipient,
)
console.log(
'[POC] tokenId =',
tokenId.toString(),
)
const victimBefore =
await readTokenBalance(
victim,
tokenId,
)
const staleBefore =
await readTokenBalance(
staleRecipient,
tokenId,
)
const intendedBefore =
await readTokenBalance(
intendedRecipient,
tokenId,
)
console.log('')
console.log(
'=== ON-CHAIN PRE-STATE ===',
)
console.log(
'[POC] victim balance BEFORE =',
victimBefore.toString(),
)
console.log(
'[POC] A balance BEFORE =',
staleBefore.toString(),
)
console.log(
'[POC] B balance BEFORE =',
intendedBefore.toString(),
)
expect(
victimBefore,
).toBe(
1n,
)
expect(
staleBefore,
).toBe(
0n,
)
expect(
intendedBefore,
).toBe(
0n,
)
/*
* Open the real transfer page and let all normal owner,
* transfer-role and detach-target reads settle BEFORE
* installing the RPC interception.
*/
await page.goto(
`${PORTAL_APP_URL}/${name}/ownership/transfer`,
)
await expect(
page.getByRole(
'heading',
{
name:
'Transfer ownership',
},
),
).toBeVisible({
timeout:
30_000,
})
const recipientInput =
page.getByPlaceholder(
'ENS name or address',
)
await expect(
recipientInput,
).toBeVisible({
timeout:
30_000,
})
/*
* Start with stale A.
*/
await recipientInput.fill(
staleRecipient,
)
await expect(
page
.getByText(
staleRecipient,
{
exact:
true,
},
)
.first(),
).toBeVisible({
timeout:
15_000,
})
const transferButton =
page.getByRole(
'button',
{
name:
'Transfer name',
},
)
await expect(
transferButton,
).toBeEnabled({
timeout:
30_000,
})
/*
* Keep the test to ONE irreversible transfer transaction.
*
* A newly registered name can expose "Detach the resolver"
* because the resolver pointer is populated. The switch defaults
* to true. Disable it before starting preparation so the saved
* plan contains only transfer-token.
*/
const detachResolver =
page.getByRole(
'switch',
{
name:
/Detach the resolver/i,
},
)
if (
await detachResolver
.isVisible()
.catch(
() => false,
)
) {
const checked =
await detachResolver
.isChecked()
console.log(
'[POC] detach-resolver visible = true',
)
console.log(
'[POC] detach-resolver initially checked =',
checked,
)
if (
checked
) {
await detachResolver.click()
}
await expect(
detachResolver,
).not.toBeChecked()
} else {
console.log(
'[POC] detach-resolver visible = false',
)
}
await expect(
transferButton,
).toBeEnabled({
timeout:
15_000,
})
console.log('')
console.log(
'=== PORTAL INITIAL STATE ===',
)
console.log(
'[POC] Portal visible recipient BEFORE click =',
await recipientInput.inputValue(),
)
expect(
await recipientInput
.inputValue(),
).toBe(
staleRecipient,
)
/*
* Deterministically hold only the production getTokenId(uint256)
* RPC used inside useTransferName.prepareMutation.
*/
let tokenCallSeenResolve!:
() => void
const tokenCallSeen =
new Promise<void>(
(resolve) => {
tokenCallSeenResolve =
resolve
},
)
let releaseTokenCall!:
() => void
const tokenCallRelease =
new Promise<void>(
(resolve) => {
releaseTokenCall =
resolve
},
)
let interceptedCalls =
0
await page.route(
'**/rpc',
async (route) => {
let payload:
unknown
try {
payload =
route
.request()
.postDataJSON()
} catch {
await route.continue()
return
}
if (
interceptedCalls ===
0 &&
containsTargetGetTokenIdCall(
payload,
)
) {
interceptedCalls++
console.log(
'[POC] 🚧 intercepted production getTokenId(uint256) RPC',
)
tokenCallSeenResolve()
/*
* Deliberately pause the real response. No response data is
* changed and no fake token ID is supplied.
*/
await tokenCallRelease
console.log(
'[POC] ▶ releasing unchanged getTokenId RPC',
)
}
await route.continue()
},
)
/*
* Captures recipient A in:
*
* startTransfer({
* recipient: A,
* options
* })
*/
await transferButton.click()
await Promise.race([
tokenCallSeen,
page
.waitForTimeout(
20_000,
)
.then(() => {
throw new Error(
'Timed out waiting for real getTokenId RPC interception',
)
}),
])
await expect(
page.getByRole(
'button',
{
name:
'Preparing…',
},
),
).toBeVisible({
timeout:
10_000,
})
/*
* THE UI BUG:
* preparation is locked, but recipient input is not.
*/
await expect(
recipientInput,
).toBeEnabled()
console.log(
'[POC] recipient input remains ENABLED while Preparing = true',
)
/*
* Victim corrects A -> B while the OLD preparation is pending.
*/
await recipientInput.fill(
intendedRecipient,
)
await expect(
recipientInput,
).toHaveValue(
intendedRecipient,
)
await expect(
page
.getByText(
intendedRecipient,
{
exact:
true,
},
)
.first(),
).toBeVisible({
timeout:
15_000,
})
console.log(
'[POC] Portal corrected visible recipient B =',
await recipientInput.inputValue(),
)
expect(
await recipientInput
.inputValue(),
).toBe(
intendedRecipient,
)
/*
* Now complete the original A preparation request with the
* untouched real Anvil response.
*/
releaseTokenCall()
const transactionDialog =
page.locator(
'[data-slot="dialog-content"][data-state="open"]',
)
await expect(
transactionDialog,
).toHaveCount(
1,
{
timeout:
30_000,
},
)
await expect(
transactionDialog,
).toBeVisible({
timeout:
30_000,
})
/*
* The held RPC has completed now; remove the route interception.
*/
await page.unroute(
'**/rpc',
)
const dialogText =
(
await transactionDialog
.innerText()
)
.replace(
/\s+/g,
' ',
)
.trim()
console.log('')
console.log(
'=== TRANSACTION MODAL ===',
)
console.log(
'[POC] modal text =',
dialogText,
)
console.log(
'[POC] modal discloses stale A =',
dialogText
.toLowerCase()
.includes(
staleRecipient
.toLowerCase(),
),
)
console.log(
'[POC] modal discloses intended B =',
dialogText
.toLowerCase()
.includes(
intendedRecipient
.toLowerCase(),
),
)
expect(
dialogText
.toLowerCase(),
).not.toContain(
staleRecipient
.toLowerCase(),
)
expect(
dialogText
.toLowerCase(),
).not.toContain(
intendedRecipient
.toLowerCase(),
)
/*
* Even after the stale preparation completes, the underlying
* Portal form still holds the victim's corrected B.
*/
expect(
await recipientInput
.inputValue(),
).toBe(
intendedRecipient,
)
console.log(
'[POC] Portal field immediately before wallet flow =',
await recipientInput.inputValue(),
)
/*
* Run the REAL Portal transaction flow and authorize the
* headless wallet.
*/
await driveTransactionsToSuccess(
page,
wallet,
[
`transfer-${name}-transfer-token`,
],
)
/*
* Authoritative post-condition: ERC-1155 balances on Anvil.
*/
const victimAfter =
await readTokenBalance(
victim,
tokenId,
)
const staleAfter =
await readTokenBalance(
staleRecipient,
tokenId,
)
const intendedAfter =
await readTokenBalance(
intendedRecipient,
tokenId,
)
console.log('')
console.log(
'=== AUTHORITATIVE ON-CHAIN POST-STATE ===',
)
console.log(
'[POC] victim balance AFTER =',
victimAfter.toString(),
)
console.log(
'[POC] stale A balance AFTER =',
staleAfter.toString(),
)
console.log(
'[POC] intended B balance AFTER =',
intendedAfter.toString(),
)
/*
* This is the Critical impact.
*
* The user-visible corrected recipient was B.
*
* But ownership moved to the old replaced address A.
*/
expect(
victimAfter,
).toBe(
0n,
)
expect(
staleAfter,
).toBe(
1n,
)
expect(
intendedAfter,
).toBe(
0n,
)
console.log('')
console.log(
'============================================================',
)
console.log(
'🚨 CRITICAL BROWSER-TO-CHAIN VULNERABILITY CONFIRMED',
)
console.log(
'============================================================',
)
console.log(
'Victim initially entered recipient A.',
)
console.log(
'Portal began real asynchronous getTokenId preparation for A.',
)
console.log(
'Recipient input remained editable while Portal showed Preparing…',
)
console.log(
'Victim replaced A with intended recipient B.',
)
console.log(
'Portal visibly displayed B before wallet approval.',
)
console.log(
'The transaction modal did not disclose the snapshotted recipient.',
)
console.log(
'The real Portal transaction succeeded on Anvil.',
)
console.log(
'ERC1155 balance proves the ENS name moved to replaced address A.',
)
console.log(
'Corrected intended recipient B received nothing.',
)
console.log(
'============================================================',
)
},
)
},
)
Command
The successful run used the following local services and test command.
Terminal 1 — Anvil
cd /workspaces/audit-comp-ens
nvm use 22
docker compose -f e2e/infra/docker-compose.yml down --remove-orphans
docker compose -f e2e/infra/docker-compose.yml up -d anvil
curl -s -H 'content-type: application/json' --data '{"jsonrpc":"2.0","method":"eth_chainId","params":[],"id":1}' http://127.0.0.1:8545
Expected chain:
0xaa36a7
Terminal 2 — Portal
cd /workspaces/audit-comp-ens
nvm use 22
cp apps/portal/.env.ci apps/portal/.env.local
pnpm --filter portal dev --host 0.0.0.0
Portal readiness check:
curl -I http://127.0.0.1:3001
Observed:
HTTP/1.1 200 OK
Terminal 3 — PoC
cd /workspaces/audit-comp-ens
nvm use 22
export ANVIL_RPC_URL=http://127.0.0.1:8545
export PORTAL_APP_URL=http://127.0.0.1:3001
pnpm --filter @ens-apps/e2e exec playwright test --config=projects/portal/playwright.config.ts stale-recipient-transfer.poc.spec.ts --workers=1 --retries=0 --reporter=line
Observed Output
Running 1 test using 1 worker
[portal-e2e] › projects/portal/tests/stale-recipient-transfer.poc.spec.ts:368:5 › CRITICAL stale transfer recipient › Portal displays corrected B but transfers ENS ERC1155 to stale A
============================================================
CRITICAL STALE RECIPIENT — REAL BROWSER / REAL CHAIN
============================================================
[makeName] registering critical-stale-recipient-1788348652.eth (duration=2419200s, desiredGapPastExpiry=0s)
[makeName] waiting 61s for MIN_COMMITMENT_AGE
[makeName] ✅ registered critical-stale-recipient-1788348652.eth
[time] sync — browser clock set to 2026-09-02T11:32:56.000Z
[makeName] ready: critical-stale-recipient-1788348652.eth
[POC] name = critical-stale-recipient-1788348652.eth
[POC] victim = 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
[POC] stale recipient A = 0x70997970C51812dc3A010C7d01b50e0d17dc79C8
[POC] intended recipient B = 0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC
[POC] tokenId = 26100276157303549828673643272941724725817132847856319908496485817464408506368
=== ON-CHAIN PRE-STATE ===
[POC] victim balance BEFORE = 1
[POC] A balance BEFORE = 0
[POC] B balance BEFORE = 0
[POC] detach-resolver visible = true
[POC] detach-resolver initially checked = true
=== PORTAL INITIAL STATE ===
[POC] Portal visible recipient BEFORE click = 0x70997970C51812dc3A010C7d01b50e0d17dc79C8
[POC] 🚧 intercepted production getTokenId(uint256) RPC
[POC] recipient input remains ENABLED while Preparing = true
[POC] Portal corrected visible recipient B = 0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC
[POC] ▶ releasing unchanged getTokenId RPC
=== TRANSACTION MODAL ===
[POC] modal text = Transaction overview Transfer name Not Started Est. Cost Not yet Start Close
[POC] modal discloses stale A = false
[POC] modal discloses intended B = false
[POC] Portal field immediately before wallet flow = 0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC
[POC] clicking modal primary = Start
[POC] clicking Open wallet
[BROWSER] 📊 [TRANSACTION MANAGER] Transaction transfer-critical-stale-recipient-1788348652.eth-transfer-token state: submitting
[BROWSER] 📊 [TRANSACTION MANAGER] Transaction transfer-critical-stale-recipient-1788348652.eth-transfer-token state: pending
[BROWSER] 📊 [TRANSACTION MANAGER] Transaction transfer-critical-stale-recipient-1788348652.eth-transfer-token state: success
[POC] clicking modal primary = Done
=== AUTHORITATIVE ON-CHAIN POST-STATE ===
[POC] victim balance AFTER = 0
[POC] stale A balance AFTER = 1
[POC] intended B balance AFTER = 0
============================================================
🚨 CRITICAL BROWSER-TO-CHAIN VULNERABILITY CONFIRMED
============================================================
Victim initially entered recipient A.
Portal began real asynchronous getTokenId preparation for A.
Recipient input remained editable while Portal showed Preparing…
Victim replaced A with intended recipient B.
Portal visibly displayed B before wallet approval.
The transaction modal did not disclose the snapshotted recipient.
The real Portal transaction succeeded on Anvil.
ERC1155 balance proves the ENS name moved to replaced address A.
Corrected intended recipient B received nothing.
============================================================
1 passed (26.2s)
=== POC SHA256 ===
a540fd562cb90e48f1a14af7232f92b4d8f42cc44c9a71fb08ce63155295045b e2e/projects/portal/tests/stale-recipient-transfer.poc.spec.ts
============================================================
✅ CRITICAL STALE-RECIPIENT BROWSER-TO-CHAIN POC PASSED
============================================================
What the PoC Proves
- A real V2
.ethname is registered on the repository's local Sepolia/Anvil environment. - The victim is the authoritative ERC-1155 holder before transfer.
- Stale recipient A and intended recipient B both have zero token balance initially.
- Production Portal initially displays A as the transfer recipient.
- The user starts the normal transfer flow.
- The PoC intercepts the real production
getTokenId(uint256)RPC used during transfer preparation. - The RPC response is delayed but not modified.
- Portal enters Preparing….
- The recipient input remains enabled while that preparation is pending.
- The user changes A to B.
- Production Portal visibly displays B.
- The unchanged
getTokenIdrequest is released. - The Portal transaction modal opens.
- Portal's modal discloses neither A nor B.
- The Portal field immediately before the wallet flow still contains B.
- The real Portal transaction flow is started.
- The real transaction manager reaches
submitting. - The transaction reaches
pending. - The transaction reaches
success. - A direct post-transaction ERC-1155 read shows the victim balance is
0. - A direct post-transaction ERC-1155 read shows stale recipient A balance is
1. - A direct post-transaction ERC-1155 read shows intended recipient B balance is
0. - The Playwright test exits successfully with
1 passed (26.2s).
The demonstrated invariant violation is:
Portal-visible transfer recipient
!=
recipient receiving the ENS ERC-1155 token
Portal immediately before wallet flow:
B
Portal transaction modal:
A not disclosed
B not disclosed
Chain after successful transfer:
A owns token
B owns nothing
This finding is therefore a Portal transaction-construction vulnerability where asynchronous preparation preserves an earlier recipient after the application has visibly accepted a corrected destination, causing the real ENS ownership transfer to go to the replaced address.