From c293202ba3f4bca40ec29d68a2d9a8c03c460dd1 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Sun, 19 Apr 2026 13:02:13 -0400 Subject: [PATCH 1/5] feat(evm-wallet): use sheaves for capability routing in away-coordinator --- .../src/lib/delegation-twin.ts | 22 +- .../src/vats/away-coordinator.ts | 202 +++++++++--------- 2 files changed, 112 insertions(+), 112 deletions(-) diff --git a/packages/evm-wallet-experiment/src/lib/delegation-twin.ts b/packages/evm-wallet-experiment/src/lib/delegation-twin.ts index 251d347e0..0fa6c95d8 100644 --- a/packages/evm-wallet-experiment/src/lib/delegation-twin.ts +++ b/packages/evm-wallet-experiment/src/lib/delegation-twin.ts @@ -1,13 +1,13 @@ import { M } from '@endo/patterns'; +import { constant } from '@metamask/kernel-utils'; +import type { PresheafSection } from '@metamask/kernel-utils'; import { makeDiscoverableExo } from '@metamask/kernel-utils/discoverable'; import { encodeTransfer } from './erc20.ts'; import { METHOD_CATALOG } from './method-catalog.ts'; import type { Address, DelegationGrant, Execution, Hex } from '../types.ts'; -export type DelegationSection = - | { exo: object; method: 'transferNative' } - | { exo: object; method: 'transferFungible'; token: Address }; +type AwayMetadata = { mode: string; delegationId?: string }; type DelegationTwinOptions = { grant: DelegationGrant; @@ -15,18 +15,18 @@ type DelegationTwinOptions = { }; /** - * Build a DelegationSection for a delegation grant. + * Build a PresheafSection for a delegation grant. * The resulting exo exposes the method covered by the grant, enforcing * local guards and (for transferFungible) a local budget tracker. * * @param options - Twin construction options. * @param options.grant - The semantic delegation grant to wrap. * @param options.redeemFn - Submits an Execution to the delegation framework. - * @returns A DelegationSection wrapping the delegation exo. + * @returns A PresheafSection with constant delegation metadata. */ export function makeDelegationTwin( options: DelegationTwinOptions, -): DelegationSection { +): PresheafSection { const { grant, redeemFn } = options; const { delegation } = grant; const idPrefix = delegation.id.slice(0, 12); @@ -90,7 +90,10 @@ export function makeDelegationTwin( interfaceGuard, ); - return { exo, method: 'transferNative' }; + return { + exo, + metadata: constant({ mode: 'delegation', delegationId: delegation.id }), + }; } // transferFungible — normalize token address to lowercase for consistent matching. @@ -152,5 +155,8 @@ export function makeDelegationTwin( interfaceGuard, ); - return { exo, method: 'transferFungible', token }; + return { + exo, + metadata: constant({ mode: 'delegation', delegationId: delegation.id }), + }; } diff --git a/packages/evm-wallet-experiment/src/vats/away-coordinator.ts b/packages/evm-wallet-experiment/src/vats/away-coordinator.ts index c2301ec32..9b76cb08a 100644 --- a/packages/evm-wallet-experiment/src/vats/away-coordinator.ts +++ b/packages/evm-wallet-experiment/src/vats/away-coordinator.ts @@ -1,9 +1,10 @@ import { E } from '@endo/eventual-send'; +import { sheafify, constant, makeRemoteSection } from '@metamask/kernel-utils'; +import type { Lift, PresheafSection, Sheaf } from '@metamask/kernel-utils'; import { makeDefaultExo } from '@metamask/kernel-utils/exo'; import { Logger } from '@metamask/logger'; import type { Baggage } from '@metamask/ocap-kernel'; -import type { DelegationSection } from '../lib/delegation-twin.ts'; import { makeDelegationTwin } from '../lib/delegation-twin.ts'; import { decodeBalanceOfResult, @@ -15,6 +16,7 @@ import { encodeName, encodeSymbol, } from '../lib/erc20.ts'; +import { METHOD_CATALOG } from '../lib/method-catalog.ts'; import { registerEnvironment, resolveEnvironment, @@ -46,6 +48,13 @@ import type { const harden = globalThis.harden ?? ((value: T): T => value); +// --------------------------------------------------------------------------- +// Local metadata type +// --------------------------------------------------------------------------- + +type AwayMetadata = { mode: string; delegationId?: string }; + + /** * Convert a wei amount in hex to a human-readable ETH string. * @@ -191,6 +200,22 @@ type OcapURLRedemptionFacet = { redeem: (url: string) => Promise; }; +// --------------------------------------------------------------------------- +// Module-level awayLift (preference: delegation > call-home) +// --------------------------------------------------------------------------- + +/** + * Lift coroutine for the away sheaf. + * Tries all matching delegation twins before falling back to phoning home. + * + * @param germs - Evaluated sections with partial metadata, one per matching presheaf section. + * @yields The next candidate section to attempt dispatch on. + */ +const awayLift: Lift = async function* (germs) { + yield* germs.filter((germ) => germ.metadata?.mode === 'delegation'); + yield* germs.filter((germ) => germ.metadata?.mode === 'call-home'); +}; + // --------------------------------------------------------------------------- // buildRootObject // --------------------------------------------------------------------------- @@ -198,11 +223,11 @@ type OcapURLRedemptionFacet = { /** * Build the root object for the away coordinator vat. * - * The away coordinator manages routing for the semantic wallet API on the away - * (agent) side. It keeps execution infrastructure (provider, bundler, smart - * account, tx submission, ERC-20 queries) and routes semantic calls - * (`transferNative`, `transferFungible`) through delegation twins first, then - * falls back to calling home. + * The away coordinator handles sheaf-based capability routing for the semantic + * wallet API on the away (agent) side. It keeps execution infrastructure + * (provider, bundler, smart account, tx submission, ERC-20 queries) and routes + * semantic calls (`transferNative`, `transferFungible`) via the away sheaf to + * either a delegation twin (autonomous) or the call-home section (ask home). * * @param vatPowers - Special powers granted to this vat. * @param _parameters - Initialization parameters (role: 'away'). @@ -266,12 +291,12 @@ export function buildRootObject( // OCAP URL redemption service (wired from services in bootstrap) let redemptionService: OcapURLRedemptionFacet | undefined; - // Routing state + // Sheaf state + let redeemerVatRef: RedeemerFacet | undefined; // alias kept for clarity in sheaf rebuild let homeSection: object | undefined; // remote ref to home's homeSection exo let homeCoordRef: object | undefined; // remote ref to home coordinator (for delegate registration) - let delegationSections: DelegationSection[] = []; - // Keyed by delegation.id so rebuildRouting preserves in-memory spend counters. - const delegationTwinMap = new Map(); + let awaySheaf: Sheaf | undefined; + let currentSection: object | undefined; // ------------------------------------------------------------------------- // Baggage helpers @@ -308,6 +333,7 @@ export function buildRootObject( keyringVat = restoreFromBaggage('keyringVat'); providerVat = restoreFromBaggage('providerVat'); redeemerVat = restoreFromBaggage('redeemerVat'); + redeemerVatRef = redeemerVat; externalSigner = restoreFromBaggage('externalSigner'); bundlerConfig = restoreFromBaggage('bundlerConfig'); if (bundlerConfig?.environment) { @@ -1022,7 +1048,7 @@ export function buildRootObject( } // ------------------------------------------------------------------------- - // Routing helpers + // Sheaf helpers // ------------------------------------------------------------------------- /** @@ -1049,32 +1075,44 @@ export function buildRootObject( } /** - * Rebuild the delegation sections from current redeemer grants. + * Rebuild the away sheaf from current redeemer grants and homeSection state. * Called after `receiveDelegation` or `connectToPeer`. */ - async function rebuildRouting(): Promise { - const grants = redeemerVat ? await E(redeemerVat).listGrants() : []; - const currentIds = new Set(grants.map((grant) => grant.delegation.id)); - for (const id of delegationTwinMap.keys()) { - if (!currentIds.has(id)) { - delegationTwinMap.delete(id); - } - } - for (const grant of grants) { - if (!delegationTwinMap.has(grant.delegation.id)) { - delegationTwinMap.set( - grant.delegation.id, - makeDelegationTwin({ - grant, - redeemFn: makeRedeemFn(grant.delegation), - }), - ); - } - } - delegationSections = grants.map( + async function rebuildAwaySheaf(): Promise { + const grants = redeemerVatRef ? await E(redeemerVatRef).listGrants() : []; + + const delegationSections: PresheafSection[] = grants.map( (grant) => - delegationTwinMap.get(grant.delegation.id) as DelegationSection, + makeDelegationTwin({ + grant, + redeemFn: makeRedeemFn(grant.delegation), + }), ); + + const sections: PresheafSection[] = [...delegationSections]; + + if (homeSection) { + sections.push( + await makeRemoteSection( + 'CallHome', + homeSection, + constant({ mode: 'call-home' }), + ), + ); + } + + if (sections.length === 0) { + // No sections yet — currentSection remains undefined + awaySheaf = undefined; + currentSection = undefined; + return; + } + + awaySheaf = sheafify({ name: 'AwayWallet', sections }); + currentSection = awaySheaf.getDiscoverableGlobalSection({ + lift: awayLift, + schema: METHOD_CATALOG, + }); } // ------------------------------------------------------------------------- @@ -1097,6 +1135,7 @@ export function buildRootObject( keyringVat = vats.keyring as KeyringFacet | undefined; providerVat = vats.provider as ProviderFacet | undefined; redeemerVat = vats.redeemer as RedeemerFacet | undefined; + redeemerVatRef = redeemerVat; redemptionService = services.ocapURLRedemptionService as | OcapURLRedemptionFacet | undefined; @@ -1117,10 +1156,10 @@ export function buildRootObject( hasRedeemer: Boolean(redeemerVat), }); - // Rebuild routing from persisted state (e.g. after kernel restart). + // Rebuild sheaf from persisted state (e.g. after kernel restart). // homeSection is already restored from baggage; grants come from redeemerVat. if (redeemerVat || homeSection) { - await rebuildRouting(); + await rebuildAwaySheaf(); } }, @@ -1484,7 +1523,7 @@ export function buildRootObject( /** * Send a transaction using the direct path only (no delegation matching). - * Away's delegation routing goes through transferNative/transferFungible, not sendTransaction. + * Away's delegation routing goes through the sheaf, not sendTransaction. * * @param tx - The transaction request. * @returns The transaction hash. @@ -1784,55 +1823,30 @@ export function buildRootObject( // ------------------------------------------------------------------ /** - * Transfer native ETH. - * Tries each matching delegation twin in order; the first success is - * returned. If all matched twins fail, throws with all collected errors - * as the cause array; does not fall through to the home section. + * Transfer native ETH via the away sheaf. + * Routes to the best matching delegation twin (autonomous) or the + * call-home section (ask home) based on the awayLift preference order. * * @param to - Recipient address. * @param amount - Amount in wei. * @returns The transaction hash. */ - async transferNative( - to: Address, - amount: string | number | bigint, - ): Promise { - // Coerce at the JSON boundary — CLI callers pass numeric strings. - const amt = BigInt(amount); - const matching = delegationSections.filter( - (sec) => sec.method === 'transferNative', - ); - if (matching.length > 0) { - const errors: unknown[] = []; - for (const section of matching) { - try { - return await E(section.exo).transferNative(to, amt); - } catch (error) { - errors.push(error); - } - } + async transferNative(to: Address, amount: bigint): Promise { + if (!currentSection) { throw new Error( - `All delegation twins failed: ${errors - .map((cause) => - cause instanceof Error ? cause.message : String(cause), - ) - .join('; ')}`, - { cause: errors }, + 'Away sheaf not ready — call connectToPeer first or receive a delegation', ); } - if (homeSection) { - return E(homeSection).transferNative(to, amt); - } - throw new Error( - 'No routing available — call connectToPeer first or receive a delegation', + return E(currentSection).transferNative( + to, + BigInt(amount as unknown as string | number | bigint), ); }, /** - * Transfer ERC-20 tokens. - * Tries each matching delegation twin in order; the first success is - * returned. If all matched twins fail, throws with all collected errors - * as the cause array; does not fall through to the home section. + * Transfer ERC-20 tokens via the away sheaf. + * Routes to the best matching delegation twin (autonomous) or the + * call-home section (ask home) based on the awayLift preference order. * * @param token - ERC-20 token contract address. * @param to - Recipient address. @@ -1844,41 +1858,21 @@ export function buildRootObject( to: Address, amount: string | number | bigint, ): Promise { - // Coerce at the JSON boundary — CLI callers pass numeric strings. - const amt = BigInt(amount); - const tokenLower = token.toLowerCase() as Address; - const matching = delegationSections.filter( - (sec) => sec.method === 'transferFungible' && sec.token === tokenLower, - ); - if (matching.length > 0) { - const errors: unknown[] = []; - for (const section of matching) { - try { - return await E(section.exo).transferFungible(tokenLower, to, amt); - } catch (error) { - errors.push(error); - } - } + if (!currentSection) { throw new Error( - `All delegation twins failed: ${errors - .map((cause) => - cause instanceof Error ? cause.message : String(cause), - ) - .join('; ')}`, - { cause: errors }, + 'Away sheaf not ready — call connectToPeer first or receive a delegation', ); } - if (homeSection) { - return E(homeSection).transferFungible(token, to, amt); - } - throw new Error( - 'No routing available — call connectToPeer first or receive a delegation', + return E(currentSection).transferFungible( + token, + to, + BigInt(amount as unknown as string | number | bigint), ); }, /** * Receive a delegation grant from home and persist it to the redeemer vat. - * Rebuilds the delegation sections to incorporate the new grant. + * Rebuilds the away sheaf to incorporate the new grant. * * @param grant - The semantic delegation grant to store. */ @@ -1887,7 +1881,7 @@ export function buildRootObject( throw new Error('Redeemer vat not available'); } await E(redeemerVat).receiveGrant(grant); - await rebuildRouting(); + await rebuildAwaySheaf(); }, /** @@ -1905,8 +1899,8 @@ export function buildRootObject( /** * Connect to the home coordinator via an OCAP URL. * Redeems the URL to obtain a remote reference to the home coordinator, - * then fetches the home section exo for the call-home fallback path. - * Persists homeCoordRef and homeSection and rebuilds routing. + * then fetches the home section exo for the call-home sheaf path. + * Persists the homeSection reference and rebuilds the away sheaf. * * @param ocapUrl - The OCAP URL issued by the home coordinator. */ @@ -1918,7 +1912,7 @@ export function buildRootObject( homeSection = await E(homeCoordRef).getHomeSection(); persistBaggage('homeCoordRef', homeCoordRef); persistBaggage('homeSection', homeSection); - await rebuildRouting(); + await rebuildAwaySheaf(); }, /** From 9a16612bceac7d9e5940537d876ca52c2f8d2542 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Fri, 24 Apr 2026 09:24:33 -0400 Subject: [PATCH 2/5] fix(evm-wallet): lowercase token address in transferFungible dispatch The delegation twin's interface guard uses M.eq(lowercaseToken) to match the token address, so dispatch through the sheaf must pass the token in canonical lowercase form. Callers that supply a mixed-case address were being silently filtered out of the twin stalk and falling through to the home section. Co-Authored-By: Claude Opus 4.7 --- packages/evm-wallet-experiment/src/vats/away-coordinator.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/evm-wallet-experiment/src/vats/away-coordinator.ts b/packages/evm-wallet-experiment/src/vats/away-coordinator.ts index 9b76cb08a..2c6cfb675 100644 --- a/packages/evm-wallet-experiment/src/vats/away-coordinator.ts +++ b/packages/evm-wallet-experiment/src/vats/away-coordinator.ts @@ -54,7 +54,6 @@ const harden = globalThis.harden ?? ((value: T): T => value); type AwayMetadata = { mode: string; delegationId?: string }; - /** * Convert a wei amount in hex to a human-readable ETH string. * @@ -1864,7 +1863,7 @@ export function buildRootObject( ); } return E(currentSection).transferFungible( - token, + token.toLowerCase() as Address, to, BigInt(amount as unknown as string | number | bigint), ); From 883ba7725d8b9cbafcce373c76b48b881b728239 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Fri, 24 Apr 2026 09:24:45 -0400 Subject: [PATCH 3/5] test(evm-wallet): update delegation twin e2e for sheaf fall-through semantics Once a matched stalk section throws, the sheaf continues dispatch through the remaining sections. The e2e scenario now asserts that an over-budget spend falls through from the delegation twin to the call-home section and that transfer still succeeds via the home coordinator (using a direct EOA tx, so no UserOp polling). Also extend dockerExec with an optional timeoutMs so the two-spend scenario fits within a longer window on bundler-hybrid. Co-Authored-By: Claude Opus 4.7 --- .../test/e2e/docker/docker-e2e.test.ts | 3 +- .../test/e2e/docker/helpers/docker-exec.ts | 10 ++++-- .../e2e/docker/run-delegation-twin-e2e.mjs | 32 ++++++++++++------- 3 files changed, 30 insertions(+), 15 deletions(-) diff --git a/packages/evm-wallet-experiment/test/e2e/docker/docker-e2e.test.ts b/packages/evm-wallet-experiment/test/e2e/docker/docker-e2e.test.ts index 139a5bd35..1b609fb3b 100644 --- a/packages/evm-wallet-experiment/test/e2e/docker/docker-e2e.test.ts +++ b/packages/evm-wallet-experiment/test/e2e/docker/docker-e2e.test.ts @@ -334,7 +334,7 @@ describe('Docker E2E', () => { // ------------------------------------------------------------------------- describe('delegation twin', () => { - it('enforces cumulativeSpend locally; chain enforces expired timestamp', () => { + it('routes transfers through the delegation twin; falls back to home when twin rejects', () => { const delegate = resolveOnChainDelegateAddress({ delegationMode, home: homeResult, @@ -348,6 +348,7 @@ describe('Docker E2E', () => { output = dockerExec( kernelServices.away, `node --conditions development ${scriptPath} ${delegationMode} ${homeResult.kref} ${awayResult.kref} ${delegate}`, + { timeoutMs: 170_000 }, ); } catch (error) { throw new Error( diff --git a/packages/evm-wallet-experiment/test/e2e/docker/helpers/docker-exec.ts b/packages/evm-wallet-experiment/test/e2e/docker/helpers/docker-exec.ts index e3f1a8723..87641722b 100644 --- a/packages/evm-wallet-experiment/test/e2e/docker/helpers/docker-exec.ts +++ b/packages/evm-wallet-experiment/test/e2e/docker/helpers/docker-exec.ts @@ -133,12 +133,18 @@ function shellSingleQuote(value: string): string { * * @param service - The compose service name. * @param command - The command to run. + * @param options - Options bag. + * @param options.timeoutMs - Timeout in milliseconds (default 60_000). * @returns stdout as a string. */ -export function dockerExec(service: string, command: string): string { +export function dockerExec( + service: string, + command: string, + options: { timeoutMs?: number } = {}, +): string { return execSync(`docker ${composePrefix()} exec -T ${service} ${command}`, { encoding: 'utf-8', - timeout: 60_000, + timeout: options.timeoutMs ?? 60_000, }).trim(); } diff --git a/packages/evm-wallet-experiment/test/e2e/docker/run-delegation-twin-e2e.mjs b/packages/evm-wallet-experiment/test/e2e/docker/run-delegation-twin-e2e.mjs index ff1accda2..86ce92423 100644 --- a/packages/evm-wallet-experiment/test/e2e/docker/run-delegation-twin-e2e.mjs +++ b/packages/evm-wallet-experiment/test/e2e/docker/run-delegation-twin-e2e.mjs @@ -12,8 +12,9 @@ * 1. Home builds a transfer-fungible grant (max spend = 5 units, fake token). * 2. Away receives the grant and rebuilds the delegation routing. * 3. Away calls transferFungible(3) → twin succeeds (3 ≤ 5 remaining). - * 4. Away calls transferFungible(3) again → twin rejects LOCALLY - * ("Insufficient budget") before any network call is made. + * 4. Away calls transferFungible(3) again → twin rejects its budget, but + * the sheaf falls back to the call-home section and the transfer + * still succeeds via the home coordinator. * * ── Usage ───────────────────────────────────────────────────────────────── * @@ -147,19 +148,26 @@ assert( `first spend (3 units) → tx hash: ${String(txHash).slice(0, 20)}...`, ); -// Second spend: 3 + 3 = 6 > 5 → should be rejected LOCALLY by the -// delegation twin without making any network call. -console.log(' Calling transferFungible(3) again — should fail locally...'); -const secondError = await awayClient.callVatExpectError( - awayKref, - 'transferFungible', - [FAKE_TOKEN, BURN_ADDRESS, '3'], +// Second spend: 3 + 3 = 6 > 5 → twin rejects its budget, but the sheaf +// falls back to the call-home section and the transfer still succeeds +// via the home coordinator. +console.log( + ' Calling transferFungible(3) again — twin rejects, sheaf falls back to home...', ); +const fallbackTxHash = await awayClient.callVat(awayKref, 'transferFungible', [ + FAKE_TOKEN, + BURN_ADDRESS, + '3', +]); + +// Fallback goes through the home coordinator, which uses a direct EOA +// transaction (broadcastTransaction) rather than a UserOp — no receipt +// polling needed. assert( - typeof secondError === 'string' && - secondError.includes('Insufficient budget'), - `second spend (3 units) rejected locally: ${String(secondError).slice(0, 80)}`, + typeof fallbackTxHash === 'string' && + /^0x[\da-f]{64}$/iu.test(fallbackTxHash), + `second spend (3 units) fell back to home → tx hash: ${String(fallbackTxHash).slice(0, 20)}...`, ); // ── Results ──────────────────────────────────────────────────────────────── From 45cdd4dd8896db6e78e00ddb1e106dd5948c938d Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Mon, 27 Apr 2026 12:56:35 -0400 Subject: [PATCH 4/5] fix(evm-wallet): update sheaf imports to @metamask/kernel-utils/sheaf subpath The rebase onto grypez/bringing-in-the-sheaves introduced a semantic conflict: d3b397d moved sheaf exports from the main kernel-utils index to the ./sheaf subpath, but the evm-wallet commits still imported from the old path. Co-Authored-By: Claude Sonnet 4.6 --- .../evm-wallet-experiment/src/lib/delegation-twin.ts | 4 ++-- .../src/vats/away-coordinator.ts | 12 ++++++++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/packages/evm-wallet-experiment/src/lib/delegation-twin.ts b/packages/evm-wallet-experiment/src/lib/delegation-twin.ts index 0fa6c95d8..65135d020 100644 --- a/packages/evm-wallet-experiment/src/lib/delegation-twin.ts +++ b/packages/evm-wallet-experiment/src/lib/delegation-twin.ts @@ -1,7 +1,7 @@ import { M } from '@endo/patterns'; -import { constant } from '@metamask/kernel-utils'; -import type { PresheafSection } from '@metamask/kernel-utils'; import { makeDiscoverableExo } from '@metamask/kernel-utils/discoverable'; +import { constant } from '@metamask/kernel-utils/sheaf'; +import type { PresheafSection } from '@metamask/kernel-utils/sheaf'; import { encodeTransfer } from './erc20.ts'; import { METHOD_CATALOG } from './method-catalog.ts'; diff --git a/packages/evm-wallet-experiment/src/vats/away-coordinator.ts b/packages/evm-wallet-experiment/src/vats/away-coordinator.ts index 2c6cfb675..92202b28f 100644 --- a/packages/evm-wallet-experiment/src/vats/away-coordinator.ts +++ b/packages/evm-wallet-experiment/src/vats/away-coordinator.ts @@ -1,7 +1,15 @@ import { E } from '@endo/eventual-send'; -import { sheafify, constant, makeRemoteSection } from '@metamask/kernel-utils'; -import type { Lift, PresheafSection, Sheaf } from '@metamask/kernel-utils'; import { makeDefaultExo } from '@metamask/kernel-utils/exo'; +import { + sheafify, + constant, + makeRemoteSection, +} from '@metamask/kernel-utils/sheaf'; +import type { + Lift, + PresheafSection, + Sheaf, +} from '@metamask/kernel-utils/sheaf'; import { Logger } from '@metamask/logger'; import type { Baggage } from '@metamask/ocap-kernel'; From 637f95e141e9550daa66537dfc4dc688306a919f Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Mon, 27 Apr 2026 13:16:49 -0400 Subject: [PATCH 5/5] fix(evm-wallet): update token normalization test to inspect guard instead of section.token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The old DelegationSection type exposed a .token property that reflected the normalized address. PresheafSection does not — the lowercased token is now embedded in the transferFungible method guard (M.eq(token)) rather than carried on the return value. Use getInterfaceGuardPayload/getMethodGuardPayload from @endo/patterns to extract the first arg guard and verify it accepts the lowercase form but rejects the original checksummed address. Co-Authored-By: Claude Sonnet 4.6 --- .../src/lib/delegation-twin.test.ts | 23 ++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/packages/evm-wallet-experiment/src/lib/delegation-twin.test.ts b/packages/evm-wallet-experiment/src/lib/delegation-twin.test.ts index 771864d61..313e19a20 100644 --- a/packages/evm-wallet-experiment/src/lib/delegation-twin.test.ts +++ b/packages/evm-wallet-experiment/src/lib/delegation-twin.test.ts @@ -1,3 +1,9 @@ +import { + matches, + getInterfaceGuardPayload, + getMethodGuardPayload, +} from '@endo/patterns'; +import type { InterfaceGuard, MethodGuard, Pattern } from '@endo/patterns'; import { describe, expect, it, vi } from 'vitest'; import type { @@ -122,11 +128,11 @@ describe('makeDelegationTwin', () => { }); describe('transferFungible twin', () => { - it('normalizes checksummed token address to lowercase in section.token', () => { + it('normalizes checksummed token address to lowercase in transferFungible guard', () => { const CHECKSUMMED_TOKEN = '0xAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAa' as Address; const redeemFn = vi.fn().mockResolvedValue(TX_HASH); - const section = makeDelegationTwin({ + makeDelegationTwin({ grant: { method: 'transferFungible', token: CHECKSUMMED_TOKEN, @@ -135,7 +141,18 @@ describe('makeDelegationTwin', () => { }, redeemFn, }); - expect(section.token).toBe(CHECKSUMMED_TOKEN.toLowerCase()); + // The guard's first arg for transferFungible is M.eq(token.toLowerCase()). + // If the token is not normalized here, incoming calls with the canonical + // lowercase address would be rejected by the guard at dispatch time. + const ifacePayload = getInterfaceGuardPayload( + lastInterfaceGuard as InterfaceGuard, + ) as unknown as { methodGuards: Record }; + const methodPayload = getMethodGuardPayload( + ifacePayload.methodGuards.transferFungible, + ) as unknown as { argGuards: Pattern[] }; + const tokenGuard = methodPayload.argGuards[0]; + expect(matches(CHECKSUMMED_TOKEN.toLowerCase(), tokenGuard)).toBe(true); + expect(matches(CHECKSUMMED_TOKEN, tokenGuard)).toBe(false); }); it('exposes transferFungible method', () => {