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', () => { diff --git a/packages/evm-wallet-experiment/src/lib/delegation-twin.ts b/packages/evm-wallet-experiment/src/lib/delegation-twin.ts index 251d347e0..65135d020 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 { 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'; 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..92202b28f 100644 --- a/packages/evm-wallet-experiment/src/vats/away-coordinator.ts +++ b/packages/evm-wallet-experiment/src/vats/away-coordinator.ts @@ -1,9 +1,18 @@ import { E } from '@endo/eventual-send'; 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'; -import type { DelegationSection } from '../lib/delegation-twin.ts'; import { makeDelegationTwin } from '../lib/delegation-twin.ts'; import { decodeBalanceOfResult, @@ -15,6 +24,7 @@ import { encodeName, encodeSymbol, } from '../lib/erc20.ts'; +import { METHOD_CATALOG } from '../lib/method-catalog.ts'; import { registerEnvironment, resolveEnvironment, @@ -46,6 +56,12 @@ 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 +207,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 +230,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 +298,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 +340,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 +1055,7 @@ export function buildRootObject( } // ------------------------------------------------------------------------- - // Routing helpers + // Sheaf helpers // ------------------------------------------------------------------------- /** @@ -1049,32 +1082,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 +1142,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 +1163,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 +1530,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 +1830,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 +1865,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.toLowerCase() as Address, + 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 +1888,7 @@ export function buildRootObject( throw new Error('Redeemer vat not available'); } await E(redeemerVat).receiveGrant(grant); - await rebuildRouting(); + await rebuildAwaySheaf(); }, /** @@ -1905,8 +1906,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 +1919,7 @@ export function buildRootObject( homeSection = await E(homeCoordRef).getHomeSection(); persistBaggage('homeCoordRef', homeCoordRef); persistBaggage('homeSection', homeSection); - await rebuildRouting(); + await rebuildAwaySheaf(); }, /** 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 ────────────────────────────────────────────────────────────────