From a00ef7965d7471e8d51660c0e9517a0f19d24555 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Wed, 15 Apr 2026 19:51:21 -0400 Subject: [PATCH 01/23] refactor(evm-wallet): replace DelegationGrant with discriminated union and slim method-catalog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove CaveatSpec, CaveatSpecStruct, BigIntStruct, and the old DelegationGrant type (which carried raw caveat bytes on the away side). Replace with TransferNativeGrant | TransferFungibleGrant — a discriminated union where each variant carries pre-decoded semantic fields (to, maxAmount, token) alongside the signed Delegation. This means the away side never needs to decode caveat bytes; the home encodes once when building the grant. Slim method-catalog to only the two methods this refactor introduces: transferNative and transferFungible. Co-Authored-By: Claude Sonnet 4.6 --- .../src/lib/method-catalog.test.ts | 117 +++++------------- .../src/lib/method-catalog.ts | 100 +++------------ packages/evm-wallet-experiment/src/types.ts | 57 ++++----- 3 files changed, 69 insertions(+), 205 deletions(-) diff --git a/packages/evm-wallet-experiment/src/lib/method-catalog.test.ts b/packages/evm-wallet-experiment/src/lib/method-catalog.test.ts index 5ba75e8049..5eef560ef9 100644 --- a/packages/evm-wallet-experiment/src/lib/method-catalog.test.ts +++ b/packages/evm-wallet-experiment/src/lib/method-catalog.test.ts @@ -1,102 +1,43 @@ import { describe, expect, it } from 'vitest'; -import type { Address, Hex } from '../types.ts'; -import { - decodeTransferCalldata, - encodeApprove, - ERC20_APPROVE_SELECTOR, - ERC20_TRANSFER_SELECTOR, -} from './erc20.ts'; -import { METHOD_CATALOG, GET_BALANCE_SCHEMA } from './method-catalog.ts'; - -const TOKEN = '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as Address; -const BOB = '0x2222222222222222222222222222222222222222' as Address; +import { METHOD_CATALOG } from './method-catalog.ts'; describe('method-catalog', () => { - it('has entries for transfer, approve, and call', () => { - expect(METHOD_CATALOG).toHaveProperty('transfer'); - expect(METHOD_CATALOG).toHaveProperty('approve'); - expect(METHOD_CATALOG).toHaveProperty('call'); - }); - - describe('transfer', () => { - it('has the correct selector', () => { - expect(METHOD_CATALOG.transfer.selector).toBe(ERC20_TRANSFER_SELECTOR); - }); - - it('builds correct ERC-20 transfer execution', () => { - const execution = METHOD_CATALOG.transfer.buildExecution(TOKEN, [ - BOB, - 5000n, - ]); - expect(execution.target).toBe(TOKEN); - expect(execution.value).toBe('0x0'); - const decoded = decodeTransferCalldata(execution.callData); - expect(decoded.to.toLowerCase()).toBe(BOB.toLowerCase()); - expect(decoded.amount).toBe(5000n); - }); - - it('has a valid MethodSchema', () => { - expect(METHOD_CATALOG.transfer.schema.description).toBeDefined(); - expect(METHOD_CATALOG.transfer.schema.args).toHaveProperty('to'); - expect(METHOD_CATALOG.transfer.schema.args).toHaveProperty('amount'); - expect(METHOD_CATALOG.transfer.schema.returns).toBeDefined(); - }); + it('has entries for transferNative and transferFungible', () => { + expect(METHOD_CATALOG).toHaveProperty('transferNative'); + expect(METHOD_CATALOG).toHaveProperty('transferFungible'); }); - describe('approve', () => { - it('has the correct selector', () => { - expect(METHOD_CATALOG.approve.selector).toBe(ERC20_APPROVE_SELECTOR); - }); - - it('builds correct ERC-20 approve execution', () => { - const execution = METHOD_CATALOG.approve.buildExecution(TOKEN, [ - BOB, - 1000n, - ]); - expect(execution.target).toBe(TOKEN); - expect(execution.value).toBe('0x0'); - expect(execution.callData).toBe(encodeApprove(BOB, 1000n)); - }); - + describe('transferNative', () => { it('has a valid MethodSchema', () => { - expect(METHOD_CATALOG.approve.schema.description).toBeDefined(); - expect(METHOD_CATALOG.approve.schema.args).toHaveProperty('spender'); - expect(METHOD_CATALOG.approve.schema.args).toHaveProperty('amount'); + expect(METHOD_CATALOG.transferNative).toStrictEqual({ + description: 'Transfer native ETH to a recipient.', + args: { + to: { type: 'string', description: 'Recipient address.' }, + amount: { + type: 'string', + description: 'Amount in wei (bigint as string).', + }, + }, + returns: { type: 'string', description: 'Transaction hash.' }, + }); }); }); - describe('call', () => { - it('has no selector', () => { - expect(METHOD_CATALOG.call.selector).toBeUndefined(); - }); - - it('passes through raw args', () => { - const target = '0x3333333333333333333333333333333333333333' as Address; - const callData = '0xdeadbeef' as Hex; - const execution = METHOD_CATALOG.call.buildExecution(TOKEN, [ - target, - 100n, - callData, - ]); - expect(execution.target).toBe(target); - expect(execution.value).toBe('0x64'); - expect(execution.callData).toBe(callData); - }); - + describe('transferFungible', () => { it('has a valid MethodSchema', () => { - expect(METHOD_CATALOG.call.schema.description).toBeDefined(); - expect(METHOD_CATALOG.call.schema.args).toHaveProperty('target'); - expect(METHOD_CATALOG.call.schema.args).toHaveProperty('value'); - expect(METHOD_CATALOG.call.schema.args).toHaveProperty('data'); - }); - }); - - describe('GET_BALANCE_SCHEMA', () => { - it('describes a read-only method', () => { - expect(GET_BALANCE_SCHEMA.description).toBeDefined(); - expect(GET_BALANCE_SCHEMA.args).toStrictEqual({}); - expect(GET_BALANCE_SCHEMA.returns).toBeDefined(); + expect(METHOD_CATALOG.transferFungible).toStrictEqual({ + description: 'Transfer ERC-20 tokens to a recipient.', + args: { + token: { type: 'string', description: 'Token contract address.' }, + to: { type: 'string', description: 'Recipient address.' }, + amount: { + type: 'string', + description: 'Amount in token units (bigint as string).', + }, + }, + returns: { type: 'string', description: 'Transaction hash.' }, + }); }); }); }); diff --git a/packages/evm-wallet-experiment/src/lib/method-catalog.ts b/packages/evm-wallet-experiment/src/lib/method-catalog.ts index 1c930be818..dcb525edfe 100644 --- a/packages/evm-wallet-experiment/src/lib/method-catalog.ts +++ b/packages/evm-wallet-experiment/src/lib/method-catalog.ts @@ -1,91 +1,29 @@ -import type { MethodSchema } from '@metamask/kernel-utils'; - -import type { Address, Execution, Hex } from '../types.ts'; -import { - encodeApprove, - makeErc20TransferExecution, - ERC20_TRANSFER_SELECTOR, - ERC20_APPROVE_SELECTOR, -} from './erc20.ts'; - const harden = globalThis.harden ?? ((value: T): T => value); -type CatalogEntry = { - selector: Hex | undefined; - buildExecution: (token: Address, args: unknown[]) => Execution; - schema: MethodSchema; -}; - -export type CatalogMethodName = 'transfer' | 'approve' | 'call'; - -export const METHOD_CATALOG: Record = harden({ - transfer: { - selector: ERC20_TRANSFER_SELECTOR, - buildExecution: (token: Address, args: unknown[]): Execution => { - const [to, amount] = args as [Address, bigint]; - return makeErc20TransferExecution({ token, to, amount }); - }, - schema: { - description: 'Transfer ERC-20 tokens to a recipient.', - args: { - to: { type: 'string', description: 'Recipient address.' }, - amount: { - type: 'string', - description: 'Token amount to transfer (bigint as string).', - }, +export const METHOD_CATALOG = harden({ + transferNative: { + description: 'Transfer native ETH to a recipient.', + args: { + to: { type: 'string', description: 'Recipient address.' }, + amount: { + type: 'string', + description: 'Amount in wei (bigint as string).', }, - returns: { type: 'string', description: 'Transaction hash.' }, }, + returns: { type: 'string', description: 'Transaction hash.' }, }, - approve: { - selector: ERC20_APPROVE_SELECTOR, - buildExecution: (token: Address, args: unknown[]): Execution => { - const [spender, amount] = args as [Address, bigint]; - return harden({ - target: token, - value: '0x0' as Hex, - callData: encodeApprove(spender, amount), - }); - }, - schema: { - description: 'Approve a spender for ERC-20 tokens.', - args: { - spender: { type: 'string', description: 'Spender address.' }, - amount: { - type: 'string', - description: 'Allowance amount (bigint as string).', - }, + transferFungible: { + description: 'Transfer ERC-20 tokens to a recipient.', + args: { + token: { type: 'string', description: 'Token contract address.' }, + to: { type: 'string', description: 'Recipient address.' }, + amount: { + type: 'string', + description: 'Amount in token units (bigint as string).', }, - returns: { type: 'string', description: 'Transaction hash.' }, - }, - }, - call: { - selector: undefined, - buildExecution: (_token: Address, args: unknown[]): Execution => { - const [target, value, callData] = args as [Address, bigint, Hex]; - return harden({ - target, - value: `0x${value.toString(16)}`, - callData, - }); - }, - schema: { - description: 'Execute a raw call via the delegation.', - args: { - target: { type: 'string', description: 'Target contract address.' }, - value: { - type: 'string', - description: 'ETH value in wei (bigint as string).', - }, - data: { type: 'string', description: 'Calldata hex string.' }, - }, - returns: { type: 'string', description: 'Transaction hash.' }, }, + returns: { type: 'string', description: 'Transaction hash.' }, }, }); -export const GET_BALANCE_SCHEMA: MethodSchema = harden({ - description: 'Get the ERC-20 token balance for this delegation.', - args: {}, - returns: { type: 'string', description: 'Token balance (bigint as string).' }, -}); +export type CatalogMethodName = keyof typeof METHOD_CATALOG; diff --git a/packages/evm-wallet-experiment/src/types.ts b/packages/evm-wallet-experiment/src/types.ts index 68ce3f4d80..5c26f42b4f 100644 --- a/packages/evm-wallet-experiment/src/types.ts +++ b/packages/evm-wallet-experiment/src/types.ts @@ -324,46 +324,31 @@ export type DelegationMatchResult = { }; // --------------------------------------------------------------------------- -// Delegation grant (twin construction input) +// Semantic delegation grants (twin construction input — post-decoded) // --------------------------------------------------------------------------- -const BigIntStruct = define( - 'BigInt', - (value) => typeof value === 'bigint', -); - -export const CaveatSpecStruct = union([ - object({ - type: literal('cumulativeSpend'), - token: AddressStruct, - max: BigIntStruct, - }), - object({ - type: literal('blockWindow'), - after: BigIntStruct, - before: BigIntStruct, - }), - object({ - type: literal('allowedCalldata'), - dataStart: number(), - value: HexStruct, - }), - object({ - type: literal('valueLte'), - max: BigIntStruct, - }), -]); - -export type CaveatSpec = Infer; +export type TransferNativeGrant = { + method: 'transferNative'; + /** Restricted recipient; enforced by AllowedTargetsEnforcer on-chain. */ + to?: Address; + /** Per-call ETH value limit; from ValueLteEnforcer. */ + maxAmount?: bigint; + delegation: Delegation; +}; -export const DelegationGrantStruct = object({ - delegation: DelegationStruct, - methodName: string(), - caveatSpecs: array(CaveatSpecStruct), - token: optional(AddressStruct), -}); +export type TransferFungibleGrant = { + method: 'transferFungible'; + /** ERC-20 token contract; from AllowedTargetsEnforcer. Always present. */ + token: Address; + /** Restricted recipient; from AllowedCalldataEnforcer. */ + to?: Address; + /** Cumulative transfer cap; from ERC20TransferAmountEnforcer. */ + maxAmount?: bigint; + delegation: Delegation; +}; -export type DelegationGrant = Infer; +/** Discriminated union of all supported semantic delegation grant types. */ +export type DelegationGrant = TransferNativeGrant | TransferFungibleGrant; // --------------------------------------------------------------------------- // Swap types (MetaSwap API) From 1981c867be0e4109ab04d588abd591b43d2d9474 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Wed, 15 Apr 2026 19:51:54 -0400 Subject: [PATCH 02/23] feat(evm-wallet): add delegator-vat for home-side grant building Vat that builds and stores delegation grants on the home side. Exposes: - buildTransferNativeGrant: valueLte + allowedTargets caveats (conditional) - buildTransferFungibleGrant: allowedTargets(token) + allowedMethods(ERC20 transfer selector) always; erc20TransferAmount + allowedCalldata(to) conditionally - storeGrant / removeGrant / listGrants: persisted in baggage Grants are returned unsigned (status: 'unsigned'); signing is done by the home coordinator before storeGrant is called. Co-Authored-By: Claude Sonnet 4.6 --- .../src/vats/delegator-vat.test.ts | 386 ++++++++++++++++++ .../src/vats/delegator-vat.ts | 192 +++++++++ 2 files changed, 578 insertions(+) create mode 100644 packages/evm-wallet-experiment/src/vats/delegator-vat.test.ts create mode 100644 packages/evm-wallet-experiment/src/vats/delegator-vat.ts diff --git a/packages/evm-wallet-experiment/src/vats/delegator-vat.test.ts b/packages/evm-wallet-experiment/src/vats/delegator-vat.test.ts new file mode 100644 index 0000000000..21df1404f7 --- /dev/null +++ b/packages/evm-wallet-experiment/src/vats/delegator-vat.test.ts @@ -0,0 +1,386 @@ +import type { Baggage } from '@metamask/ocap-kernel'; +import { describe, expect, it, vi } from 'vitest'; + +import type { + Address, + DelegationGrant, + TransferNativeGrant, + TransferFungibleGrant, +} from '../types.ts'; +import { buildRootObject } from './delegator-vat.ts'; +import { makeMockBaggage } from '../../test/helpers.ts'; + +vi.mock('@metamask/kernel-utils/exo', () => ({ + makeDefaultExo: (_name: string, methods: Record) => methods, +})); + +type DelegatorVat = { + buildTransferNativeGrant(opts: { + delegator: Address; + delegate: Address; + to?: Address; + maxAmount?: bigint; + chainId: number; + }): Promise; + buildTransferFungibleGrant(opts: { + delegator: Address; + delegate: Address; + token: Address; + to?: Address; + maxAmount?: bigint; + chainId: number; + }): Promise; + storeGrant(grant: DelegationGrant): Promise; + removeGrant(id: string): Promise; + listGrants(): Promise; +}; + +const DELEGATOR = '0x1111111111111111111111111111111111111111' as Address; +const DELEGATE = '0x2222222222222222222222222222222222222222' as Address; +const TOKEN = '0x3333333333333333333333333333333333333333' as Address; +const RECIPIENT = '0x4444444444444444444444444444444444444444' as Address; +const CHAIN_ID = 1; + +function makeRoot() { + const baggage = makeMockBaggage(); + const root = buildRootObject( + undefined, + undefined, + baggage as unknown as Baggage, + ) as unknown as DelegatorVat; + return { root, baggage }; +} + +describe('delegator-vat', () => { + describe('buildTransferNativeGrant', () => { + it('returns a grant with method transferNative', async () => { + const { root } = makeRoot(); + const grant: TransferNativeGrant = await root.buildTransferNativeGrant({ + delegator: DELEGATOR, + delegate: DELEGATE, + chainId: CHAIN_ID, + }); + expect(grant.method).toBe('transferNative'); + }); + + it('sets delegator and delegate on the delegation', async () => { + const { root } = makeRoot(); + const grant: TransferNativeGrant = await root.buildTransferNativeGrant({ + delegator: DELEGATOR, + delegate: DELEGATE, + chainId: CHAIN_ID, + }); + expect(grant.delegation.delegator).toBe(DELEGATOR); + expect(grant.delegation.delegate).toBe(DELEGATE); + expect(grant.delegation.chainId).toBe(CHAIN_ID); + }); + + it('does not include to when not provided', async () => { + const { root } = makeRoot(); + const grant: TransferNativeGrant = await root.buildTransferNativeGrant({ + delegator: DELEGATOR, + delegate: DELEGATE, + chainId: CHAIN_ID, + }); + expect(grant.to).toBeUndefined(); + }); + + it('includes to when provided', async () => { + const { root } = makeRoot(); + const grant: TransferNativeGrant = await root.buildTransferNativeGrant({ + delegator: DELEGATOR, + delegate: DELEGATE, + to: RECIPIENT, + chainId: CHAIN_ID, + }); + expect(grant.to).toBe(RECIPIENT); + }); + + it('does not include maxAmount when not provided', async () => { + const { root } = makeRoot(); + const grant: TransferNativeGrant = await root.buildTransferNativeGrant({ + delegator: DELEGATOR, + delegate: DELEGATE, + chainId: CHAIN_ID, + }); + expect(grant.maxAmount).toBeUndefined(); + }); + + it('includes maxAmount when provided', async () => { + const { root } = makeRoot(); + const grant: TransferNativeGrant = await root.buildTransferNativeGrant({ + delegator: DELEGATOR, + delegate: DELEGATE, + maxAmount: 500n, + chainId: CHAIN_ID, + }); + expect(grant.maxAmount).toBe(500n); + }); + + it('has no caveats when no to or maxAmount', async () => { + const { root } = makeRoot(); + const grant: TransferNativeGrant = await root.buildTransferNativeGrant({ + delegator: DELEGATOR, + delegate: DELEGATE, + chainId: CHAIN_ID, + }); + expect(grant.delegation.caveats).toHaveLength(0); + }); + + it('has allowedTargets caveat when to is provided', async () => { + const { root } = makeRoot(); + const grant: TransferNativeGrant = await root.buildTransferNativeGrant({ + delegator: DELEGATOR, + delegate: DELEGATE, + to: RECIPIENT, + chainId: CHAIN_ID, + }); + const types = grant.delegation.caveats.map((caveat) => caveat.type); + expect(types).toContain('allowedTargets'); + expect(types).not.toContain('valueLte'); + }); + + it('has valueLte caveat when maxAmount is provided', async () => { + const { root } = makeRoot(); + const grant: TransferNativeGrant = await root.buildTransferNativeGrant({ + delegator: DELEGATOR, + delegate: DELEGATE, + maxAmount: 1000n, + chainId: CHAIN_ID, + }); + const types = grant.delegation.caveats.map((caveat) => caveat.type); + expect(types).toContain('valueLte'); + expect(types).not.toContain('allowedTargets'); + }); + + it('has both allowedTargets and valueLte caveats when to and maxAmount are provided', async () => { + const { root } = makeRoot(); + const grant: TransferNativeGrant = await root.buildTransferNativeGrant({ + delegator: DELEGATOR, + delegate: DELEGATE, + to: RECIPIENT, + maxAmount: 1000n, + chainId: CHAIN_ID, + }); + const types = grant.delegation.caveats.map((caveat) => caveat.type); + expect(types).toContain('allowedTargets'); + expect(types).toContain('valueLte'); + }); + }); + + describe('buildTransferFungibleGrant', () => { + it('returns a grant with method transferFungible', async () => { + const { root } = makeRoot(); + const grant: TransferFungibleGrant = + await root.buildTransferFungibleGrant({ + delegator: DELEGATOR, + delegate: DELEGATE, + token: TOKEN, + chainId: CHAIN_ID, + }); + expect(grant.method).toBe('transferFungible'); + }); + + it('sets token, delegator, delegate, and chainId', async () => { + const { root } = makeRoot(); + const grant: TransferFungibleGrant = + await root.buildTransferFungibleGrant({ + delegator: DELEGATOR, + delegate: DELEGATE, + token: TOKEN, + chainId: CHAIN_ID, + }); + expect(grant.token).toBe(TOKEN); + expect(grant.delegation.delegator).toBe(DELEGATOR); + expect(grant.delegation.delegate).toBe(DELEGATE); + expect(grant.delegation.chainId).toBe(CHAIN_ID); + }); + + it('always has allowedTargets and allowedMethods caveats', async () => { + const { root } = makeRoot(); + const grant: TransferFungibleGrant = + await root.buildTransferFungibleGrant({ + delegator: DELEGATOR, + delegate: DELEGATE, + token: TOKEN, + chainId: CHAIN_ID, + }); + const types = grant.delegation.caveats.map((caveat) => caveat.type); + expect(types).toContain('allowedTargets'); + expect(types).toContain('allowedMethods'); + }); + + it('does not include erc20TransferAmount caveat when maxAmount is not provided', async () => { + const { root } = makeRoot(); + const grant: TransferFungibleGrant = + await root.buildTransferFungibleGrant({ + delegator: DELEGATOR, + delegate: DELEGATE, + token: TOKEN, + chainId: CHAIN_ID, + }); + const types = grant.delegation.caveats.map((caveat) => caveat.type); + expect(types).not.toContain('erc20TransferAmount'); + }); + + it('includes erc20TransferAmount caveat when maxAmount is provided', async () => { + const { root } = makeRoot(); + const grant: TransferFungibleGrant = + await root.buildTransferFungibleGrant({ + delegator: DELEGATOR, + delegate: DELEGATE, + token: TOKEN, + maxAmount: 5000n, + chainId: CHAIN_ID, + }); + const types = grant.delegation.caveats.map((caveat) => caveat.type); + expect(types).toContain('erc20TransferAmount'); + expect(grant.maxAmount).toBe(5000n); + }); + + it('does not include allowedCalldata caveat when to is not provided', async () => { + const { root } = makeRoot(); + const grant: TransferFungibleGrant = + await root.buildTransferFungibleGrant({ + delegator: DELEGATOR, + delegate: DELEGATE, + token: TOKEN, + chainId: CHAIN_ID, + }); + const types = grant.delegation.caveats.map((caveat) => caveat.type); + expect(types).not.toContain('allowedCalldata'); + }); + + it('includes allowedCalldata caveat when to is provided', async () => { + const { root } = makeRoot(); + const grant: TransferFungibleGrant = + await root.buildTransferFungibleGrant({ + delegator: DELEGATOR, + delegate: DELEGATE, + token: TOKEN, + to: RECIPIENT, + chainId: CHAIN_ID, + }); + const types = grant.delegation.caveats.map((caveat) => caveat.type); + expect(types).toContain('allowedCalldata'); + expect(grant.to).toBe(RECIPIENT); + }); + + it('includes all caveats when to and maxAmount are provided', async () => { + const { root } = makeRoot(); + const grant: TransferFungibleGrant = + await root.buildTransferFungibleGrant({ + delegator: DELEGATOR, + delegate: DELEGATE, + token: TOKEN, + to: RECIPIENT, + maxAmount: 5000n, + chainId: CHAIN_ID, + }); + const types = grant.delegation.caveats.map((caveat) => caveat.type); + expect(types).toContain('allowedTargets'); + expect(types).toContain('allowedMethods'); + expect(types).toContain('erc20TransferAmount'); + expect(types).toContain('allowedCalldata'); + }); + }); + + describe('storeGrant and listGrants', () => { + it('returns empty array when no grants stored', async () => { + const { root } = makeRoot(); + const grants = await root.listGrants(); + expect(grants).toStrictEqual([]); + }); + + it('stored grant appears in listGrants', async () => { + const { root } = makeRoot(); + const grant: TransferNativeGrant = await root.buildTransferNativeGrant({ + delegator: DELEGATOR, + delegate: DELEGATE, + chainId: CHAIN_ID, + }); + await root.storeGrant(grant); + const grants = await root.listGrants(); + expect(grants).toHaveLength(1); + expect(grants[0]).toStrictEqual(grant); + }); + + it('multiple stored grants all appear in listGrants', async () => { + const { root } = makeRoot(); + const grant1: TransferNativeGrant = await root.buildTransferNativeGrant({ + delegator: DELEGATOR, + delegate: DELEGATE, + chainId: CHAIN_ID, + }); + const grant2: TransferFungibleGrant = + await root.buildTransferFungibleGrant({ + delegator: DELEGATOR, + delegate: DELEGATE, + token: TOKEN, + chainId: CHAIN_ID, + }); + await root.storeGrant(grant1); + await root.storeGrant(grant2); + const grants = await root.listGrants(); + expect(grants).toHaveLength(2); + }); + }); + + describe('removeGrant', () => { + it('removed grant no longer appears in listGrants', async () => { + const { root } = makeRoot(); + const grant: TransferNativeGrant = await root.buildTransferNativeGrant({ + delegator: DELEGATOR, + delegate: DELEGATE, + chainId: CHAIN_ID, + }); + await root.storeGrant(grant); + await root.removeGrant(grant.delegation.id); + const grants = await root.listGrants(); + expect(grants).toStrictEqual([]); + }); + + it('removing one grant leaves others intact', async () => { + const { root } = makeRoot(); + const grant1: TransferNativeGrant = await root.buildTransferNativeGrant({ + delegator: DELEGATOR, + delegate: DELEGATE, + chainId: CHAIN_ID, + }); + const grant2: TransferFungibleGrant = + await root.buildTransferFungibleGrant({ + delegator: DELEGATOR, + delegate: DELEGATE, + token: TOKEN, + chainId: CHAIN_ID, + }); + await root.storeGrant(grant1); + await root.storeGrant(grant2); + await root.removeGrant(grant1.delegation.id); + const grants = await root.listGrants(); + expect(grants).toHaveLength(1); + expect(grants[0]).toStrictEqual(grant2); + }); + }); + + describe('baggage persistence', () => { + it('restores grants after second buildRootObject call with same baggage', async () => { + const { root, baggage } = makeRoot(); + const grant: TransferNativeGrant = await root.buildTransferNativeGrant({ + delegator: DELEGATOR, + delegate: DELEGATE, + chainId: CHAIN_ID, + }); + await root.storeGrant(grant); + + const restoredRoot = buildRootObject( + undefined, + undefined, + baggage as unknown as Baggage, + ) as unknown as DelegatorVat; + const grants = await restoredRoot.listGrants(); + expect(grants).toHaveLength(1); + expect(grants[0]).toStrictEqual(grant); + }); + }); +}); diff --git a/packages/evm-wallet-experiment/src/vats/delegator-vat.ts b/packages/evm-wallet-experiment/src/vats/delegator-vat.ts new file mode 100644 index 0000000000..0e35f6b0b4 --- /dev/null +++ b/packages/evm-wallet-experiment/src/vats/delegator-vat.ts @@ -0,0 +1,192 @@ +import { makeDefaultExo } from '@metamask/kernel-utils/exo'; +import type { Baggage } from '@metamask/ocap-kernel'; + +import { + makeCaveat, + encodeValueLte, + encodeAllowedTargets, + encodeAllowedMethods, + encodeErc20TransferAmount, + encodeAllowedCalldata, +} from '../lib/caveats.ts'; +import { makeDelegation, makeSaltGenerator } from '../lib/delegation.ts'; +import { ERC20_TRANSFER_SELECTOR, FIRST_ARG_OFFSET } from '../lib/erc20.ts'; +import type { + Address, + DelegationGrant, + Hex, + TransferFungibleGrant, + TransferNativeGrant, +} from '../types.ts'; + +const harden = globalThis.harden ?? ((value: T): T => value); + +/** + * ABI-encode an Ethereum address as a 32-byte padded hex value. + * + * @param address - The Ethereum address to encode. + * @returns A 0x-prefixed 64-character hex string. + */ +function abiEncodeAddress(address: Address): Hex { + return `0x${address.slice(2).toLowerCase().padStart(64, '0')}`; +} + +/** + * Build the root object for the delegator vat. + * + * @param _vatPowers - Special powers granted to this vat (unused). + * @param _parameters - Initialization parameters (unused). + * @param baggage - Root of vat's persistent state. + * @returns The root object for the delegator vat. + */ +export function buildRootObject( + _vatPowers: unknown, + _parameters: unknown, + baggage: Baggage, +): object { + const grants: Map = baggage.has('grants') + ? new Map( + Object.entries( + baggage.get('grants') as Record, + ), + ) + : new Map(); + + const saltGenerator = makeSaltGenerator(); + + /** + * Persist grants map to baggage (handles both init and update). + */ + function persistGrants(): void { + const serialized = harden(Object.fromEntries(grants)); + if (baggage.has('grants')) { + baggage.set('grants', serialized); + } else { + baggage.init('grants', serialized); + } + } + + return makeDefaultExo('walletDelegator', { + async buildTransferNativeGrant(options: { + delegator: Address; + delegate: Address; + to?: Address; + maxAmount?: bigint; + chainId: number; + }): Promise { + const { delegator, delegate, to, maxAmount, chainId } = options; + const caveats = []; + + if (to !== undefined) { + caveats.push( + makeCaveat({ + type: 'allowedTargets', + terms: encodeAllowedTargets([to]), + chainId, + }), + ); + } + + if (maxAmount !== undefined) { + caveats.push( + makeCaveat({ + type: 'valueLte', + terms: encodeValueLte(maxAmount), + chainId, + }), + ); + } + + const delegation = makeDelegation({ + delegator, + delegate, + caveats, + chainId, + saltGenerator, + }); + + return harden({ + method: 'transferNative', + ...(to !== undefined && { to }), + ...(maxAmount !== undefined && { maxAmount }), + delegation, + }); + }, + + async buildTransferFungibleGrant(options: { + delegator: Address; + delegate: Address; + token: Address; + to?: Address; + maxAmount?: bigint; + chainId: number; + }): Promise { + const { delegator, delegate, token, to, maxAmount, chainId } = options; + const caveats = [ + makeCaveat({ + type: 'allowedTargets', + terms: encodeAllowedTargets([token]), + chainId, + }), + makeCaveat({ + type: 'allowedMethods', + terms: encodeAllowedMethods([ERC20_TRANSFER_SELECTOR]), + chainId, + }), + ]; + + if (maxAmount !== undefined) { + caveats.push( + makeCaveat({ + type: 'erc20TransferAmount', + terms: encodeErc20TransferAmount({ token, amount: maxAmount }), + chainId, + }), + ); + } + + if (to !== undefined) { + caveats.push( + makeCaveat({ + type: 'allowedCalldata', + terms: encodeAllowedCalldata({ + dataStart: FIRST_ARG_OFFSET, + value: abiEncodeAddress(to), + }), + chainId, + }), + ); + } + + const delegation = makeDelegation({ + delegator, + delegate, + caveats, + chainId, + saltGenerator, + }); + + return harden({ + method: 'transferFungible', + token, + ...(to !== undefined && { to }), + ...(maxAmount !== undefined && { maxAmount }), + delegation, + }); + }, + + async storeGrant(grant: DelegationGrant): Promise { + grants.set(grant.delegation.id, grant); + persistGrants(); + }, + + async removeGrant(id: string): Promise { + grants.delete(id); + persistGrants(); + }, + + async listGrants(): Promise { + return harden([...grants.values()]); + }, + }); +} From 7d9d32b18017ad380eb982a290f2e72141ede8eb Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Wed, 15 Apr 2026 19:52:03 -0400 Subject: [PATCH 03/23] feat(evm-wallet): add redeemer-vat for away-side grant storage Simple away-side store for DelegationGrant values received from the home coordinator. Grants are keyed by delegation.id and persisted in baggage. Exposes receiveGrant / removeGrant / listGrants; used by away-coordinator when rebuilding the away sheaf. Co-Authored-By: Claude Sonnet 4.6 --- .../src/vats/redeemer-vat.test.ts | 153 ++++++++++++++++++ .../src/vats/redeemer-vat.ts | 55 +++++++ 2 files changed, 208 insertions(+) create mode 100644 packages/evm-wallet-experiment/src/vats/redeemer-vat.test.ts create mode 100644 packages/evm-wallet-experiment/src/vats/redeemer-vat.ts diff --git a/packages/evm-wallet-experiment/src/vats/redeemer-vat.test.ts b/packages/evm-wallet-experiment/src/vats/redeemer-vat.test.ts new file mode 100644 index 0000000000..b82ebd91cc --- /dev/null +++ b/packages/evm-wallet-experiment/src/vats/redeemer-vat.test.ts @@ -0,0 +1,153 @@ +import type { Baggage } from '@metamask/ocap-kernel'; +import { describe, expect, it, vi } from 'vitest'; + +import type { + TransferNativeGrant, + TransferFungibleGrant, + DelegationGrant, + Address, + Hex, +} from '../types.ts'; +import { buildRootObject } from './redeemer-vat.ts'; +import { makeMockBaggage } from '../../test/helpers.ts'; + +vi.mock('@metamask/kernel-utils/exo', () => ({ + makeDefaultExo: (_name: string, methods: Record) => methods, +})); + +type RedeemerVat = { + receiveGrant(grant: DelegationGrant): Promise; + removeGrant(id: string): Promise; + listGrants(): Promise; +}; + +const NATIVE_GRANT: TransferNativeGrant = { + method: 'transferNative', + to: '0x2222222222222222222222222222222222222222' as Address, + maxAmount: 1000n, + delegation: { + id: '0xaaaa', + delegator: '0x1111111111111111111111111111111111111111' as Address, + delegate: '0x2222222222222222222222222222222222222222' as Address, + authority: + '0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff' as Hex, + caveats: [], + salt: '0x01' as Hex, + chainId: 1, + status: 'pending' as const, + }, +}; + +const FUNGIBLE_GRANT: TransferFungibleGrant = { + method: 'transferFungible', + token: '0x3333333333333333333333333333333333333333' as Address, + to: '0x4444444444444444444444444444444444444444' as Address, + maxAmount: 5000n, + delegation: { + id: '0xbbbb', + delegator: '0x1111111111111111111111111111111111111111' as Address, + delegate: '0x2222222222222222222222222222222222222222' as Address, + authority: + '0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff' as Hex, + caveats: [], + salt: '0x02' as Hex, + chainId: 1, + status: 'pending' as const, + }, +}; + +function makeRoot() { + const baggage = makeMockBaggage(); + const root = buildRootObject( + undefined, + undefined, + baggage as unknown as Baggage, + ) as unknown as RedeemerVat; + return { root, baggage }; +} + +describe('redeemer-vat', () => { + describe('listGrants', () => { + it('returns empty array when no grants received', async () => { + const { root } = makeRoot(); + const grants = await root.listGrants(); + expect(grants).toStrictEqual([]); + }); + }); + + describe('receiveGrant and listGrants', () => { + it('stored grant appears in listGrants', async () => { + const { root } = makeRoot(); + await root.receiveGrant(NATIVE_GRANT); + const grants = await root.listGrants(); + expect(grants).toHaveLength(1); + expect(grants[0]).toStrictEqual(NATIVE_GRANT); + }); + + it('multiple grants all appear in listGrants', async () => { + const { root } = makeRoot(); + await root.receiveGrant(NATIVE_GRANT); + await root.receiveGrant(FUNGIBLE_GRANT); + const grants = await root.listGrants(); + expect(grants).toHaveLength(2); + }); + + it('receiving the same grant id twice does not duplicate', async () => { + const { root } = makeRoot(); + await root.receiveGrant(NATIVE_GRANT); + await root.receiveGrant(NATIVE_GRANT); + const grants = await root.listGrants(); + expect(grants).toHaveLength(1); + }); + }); + + describe('removeGrant', () => { + it('removed grant no longer appears in listGrants', async () => { + const { root } = makeRoot(); + await root.receiveGrant(NATIVE_GRANT); + await root.removeGrant(NATIVE_GRANT.delegation.id); + const grants = await root.listGrants(); + expect(grants).toStrictEqual([]); + }); + + it('removing one grant leaves others intact', async () => { + const { root } = makeRoot(); + await root.receiveGrant(NATIVE_GRANT); + await root.receiveGrant(FUNGIBLE_GRANT); + await root.removeGrant(NATIVE_GRANT.delegation.id); + const grants = await root.listGrants(); + expect(grants).toHaveLength(1); + expect(grants[0]).toStrictEqual(FUNGIBLE_GRANT); + }); + }); + + describe('baggage persistence', () => { + it('restores grants after second buildRootObject call with same baggage', async () => { + const { root, baggage } = makeRoot(); + await root.receiveGrant(NATIVE_GRANT); + + const restoredRoot = buildRootObject( + undefined, + undefined, + baggage as unknown as Baggage, + ) as unknown as RedeemerVat; + const grants = await restoredRoot.listGrants(); + expect(grants).toHaveLength(1); + expect(grants[0]).toStrictEqual(NATIVE_GRANT); + }); + + it('restores multiple grants correctly', async () => { + const { root, baggage } = makeRoot(); + await root.receiveGrant(NATIVE_GRANT); + await root.receiveGrant(FUNGIBLE_GRANT); + + const restoredRoot = buildRootObject( + undefined, + undefined, + baggage as unknown as Baggage, + ) as unknown as RedeemerVat; + const grants = await restoredRoot.listGrants(); + expect(grants).toHaveLength(2); + }); + }); +}); diff --git a/packages/evm-wallet-experiment/src/vats/redeemer-vat.ts b/packages/evm-wallet-experiment/src/vats/redeemer-vat.ts new file mode 100644 index 0000000000..5075e2b6d7 --- /dev/null +++ b/packages/evm-wallet-experiment/src/vats/redeemer-vat.ts @@ -0,0 +1,55 @@ +import { makeDefaultExo } from '@metamask/kernel-utils/exo'; +import type { Baggage } from '@metamask/ocap-kernel'; + +import type { DelegationGrant } from '../types.ts'; + +const harden = globalThis.harden ?? ((value: T): T => value); + +/** + * Build the root object for the redeemer vat. + * + * @param _vatPowers - Special powers granted to this vat (unused). + * @param _parameters - Initialization parameters (unused). + * @param baggage - Root of vat's persistent state. + * @returns The root object for the redeemer vat. + */ +export function buildRootObject( + _vatPowers: unknown, + _parameters: unknown, + baggage: Baggage, +): object { + // Restore from baggage on resuscitation + const grants: Map = baggage.has('grants') + ? new Map( + Object.entries( + baggage.get('grants') as Record, + ), + ) + : new Map(); + + /** + * Persist grants map to baggage (handles both init and update). + */ + function persistGrants(): void { + const serialized = harden(Object.fromEntries(grants)); + if (baggage.has('grants')) { + baggage.set('grants', serialized); + } else { + baggage.init('grants', serialized); + } + } + + return makeDefaultExo('walletRedeemer', { + async receiveGrant(grant: DelegationGrant): Promise { + grants.set(grant.delegation.id, grant); + persistGrants(); + }, + async removeGrant(id: string): Promise { + grants.delete(id); + persistGrants(); + }, + async listGrants(): Promise { + return harden([...grants.values()]); + }, + }); +} From 16dc00393a8b65e8b65b752c80106b1f063bc979 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Wed, 15 Apr 2026 19:52:16 -0400 Subject: [PATCH 04/23] feat(evm-wallet): add home-coordinator as home-side split of coordinator-vat Home coordinator keeps all shared infrastructure (signing, tx submission, ERC-20, swap, smart account, OcapURL) and adds delegation management via the new delegator-vat plus a homeSection exo. New additions: - buildTransferNativeGrant / buildTransferFungibleGrant: sign + store via delegator-vat - signDelegationInGrant: resolves DelegationManager from chain contracts, signs via keyring or external signer, finalizes delegation - listGrants / revokeGrant: list and on-chain disable grants - getHomeSection(): returns the pre-built homeSection exo - homeSection: local exo with transferNative/transferFungible that submit direct transactions; throws after 2 uses per method (demo limit) Removes: all away-side peer protocol (connectToPeer, registerAwayWallet, handleSigningRequest, handleRedemptionRequest) and the old delegation routing in sendTransaction. Co-Authored-By: Claude Sonnet 4.6 --- .../src/vats/delegator-vat.ts | 37 + .../src/vats/home-coordinator.ts | 2418 +++++++++++++++++ 2 files changed, 2455 insertions(+) create mode 100644 packages/evm-wallet-experiment/src/vats/home-coordinator.ts diff --git a/packages/evm-wallet-experiment/src/vats/delegator-vat.ts b/packages/evm-wallet-experiment/src/vats/delegator-vat.ts index 0e35f6b0b4..dde30ac9f0 100644 --- a/packages/evm-wallet-experiment/src/vats/delegator-vat.ts +++ b/packages/evm-wallet-experiment/src/vats/delegator-vat.ts @@ -1,6 +1,12 @@ import { makeDefaultExo } from '@metamask/kernel-utils/exo'; import type { Baggage } from '@metamask/ocap-kernel'; +import { + ENFORCER_CONTRACT_KEY_MAP, + PLACEHOLDER_CONTRACTS, + registerChainContracts, +} from '../constants.ts'; +import type { ChainContracts } from '../constants.ts'; import { makeCaveat, encodeValueLte, @@ -175,6 +181,37 @@ export function buildRootObject( }); }, + /** + * Register contract addresses for a chain so caveat builders can look up + * enforcer addresses. Called by the home coordinator after configureBundler + * and on resuscitation so this vat's module-level Map stays in sync. + * + * @param chainId - The chain ID to register. + * @param environment - The deployed contract addresses for this chain. + * @param environment.DelegationManager - DelegationManager address. + * @param environment.caveatEnforcers - Enforcer contract addresses. + */ + async registerContracts( + chainId: number, + environment: { + DelegationManager: Hex; + caveatEnforcers?: Record; + }, + ): Promise { + const rawEnforcers = environment.caveatEnforcers ?? {}; + const enforcers = { ...PLACEHOLDER_CONTRACTS.enforcers }; + for (const [key, addr] of Object.entries(rawEnforcers)) { + const caveatType = ENFORCER_CONTRACT_KEY_MAP[key]; + if (caveatType !== undefined) { + enforcers[caveatType] = addr; + } + } + registerChainContracts(chainId, { + delegationManager: environment.DelegationManager, + enforcers, + } as ChainContracts); + }, + async storeGrant(grant: DelegationGrant): Promise { grants.set(grant.delegation.id, grant); persistGrants(); diff --git a/packages/evm-wallet-experiment/src/vats/home-coordinator.ts b/packages/evm-wallet-experiment/src/vats/home-coordinator.ts new file mode 100644 index 0000000000..d70c29faa5 --- /dev/null +++ b/packages/evm-wallet-experiment/src/vats/home-coordinator.ts @@ -0,0 +1,2418 @@ +import { E } from '@endo/eventual-send'; +import { M } from '@endo/patterns'; +import { makeDiscoverableExo } from '@metamask/kernel-utils/discoverable'; +import { makeDefaultExo } from '@metamask/kernel-utils/exo'; +import { Logger } from '@metamask/logger'; +import type { Baggage } from '@metamask/ocap-kernel'; + +import { + ENFORCER_CONTRACT_KEY_MAP, + PLACEHOLDER_CONTRACTS, + getChainContracts, + registerChainContracts, +} from '../constants.ts'; +import type { ChainContracts } from '../constants.ts'; +import { + prepareDelegationTypedData, + finalizeDelegation, +} from '../lib/delegation.ts'; +import { + decodeAllowanceResult, + decodeBalanceOfResult, + decodeDecimalsResult, + decodeNameResult, + decodeSymbolResult, + encodeAllowance, + encodeBalanceOf, + encodeDecimals, + encodeName, + encodeSymbol, + encodeTransfer, +} from '../lib/erc20.ts'; +import { METHOD_CATALOG } from '../lib/method-catalog.ts'; +import { + buildBatchExecuteCallData, + buildSdkDisableCallData, + buildSdkRedeemCallData, + computeSmartAccountAddress, + isEip7702Delegated, + prepareUserOpTypedData, + registerEnvironment, + resolveEnvironment, + setSdkLogger, +} from '../lib/sdk.ts'; +import { ENTRY_POINT_V07 } from '../lib/userop.ts'; +import type { + Address, + ChainConfig, + Delegation, + DelegationGrant, + Eip712TypedData, + Execution, + Hex, + SmartAccountConfig, + SwapQuote, + SwapResult, + TransactionRequest, + TransferFungibleGrant, + TransferNativeGrant, + UserOperation, + WalletCapabilities, +} from '../types.ts'; + +const harden = globalThis.harden ?? ((value: T): T => value); + +// --------------------------------------------------------------------------- +// Helper functions +// --------------------------------------------------------------------------- + +/** + * Apply a percentage buffer to a hex gas value. + * + * @param gasHex - The gas value as a hex string. + * @param bufferPercent - The buffer percentage to add (e.g. 10 for 10%). + * @returns The buffered gas value as a hex string. + */ +function applyGasBuffer(gasHex: Hex, bufferPercent: number): Hex { + const gas = BigInt(gasHex); + const buffered = gas + (gas * BigInt(bufferPercent)) / 100n; + return `0x${buffered.toString(16)}`; +} + +/** + * Validate that an `eth_estimateGas` response is a valid hex string. + * + * @param result - The raw RPC response. + * @returns The validated hex string. + * @throws If the result is not a hex string. + */ +function validateGasEstimate(result: unknown): Hex { + if (typeof result !== 'string' || !result.startsWith('0x')) { + throw new Error( + `eth_estimateGas returned unexpected value: ${String(result)}`, + ); + } + return result as Hex; +} + +/** + * Validate that a token `eth_call` response is a usable hex string. + * + * @param result - The raw RPC response. + * @param method - The ERC-20 method name (for error context). + * @param token - The token address (for error context). + * @returns The validated hex string. + * @throws If the result is not a non-empty hex string. + */ +function validateTokenCallResult( + result: unknown, + method: string, + token: Address, +): Hex { + if ( + typeof result !== 'string' || + !result.startsWith('0x') || + result === '0x' + ) { + throw new Error( + `${method}() call to token ${token} returned unexpected value: ${String(result)}`, + ); + } + return result as Hex; +} + +// --------------------------------------------------------------------------- +// Vat types +// --------------------------------------------------------------------------- + +/** + * Vat powers for the home coordinator vat. + */ +type VatPowers = { + logger?: Logger; +}; + +/** + * Vat references available in the home wallet subcluster. + */ +type WalletVats = { + keyring?: unknown; + provider?: unknown; + delegator?: unknown; +}; + +/** + * Services available to the home wallet subcluster. + */ +type WalletServices = { + ocapURLIssuerService?: unknown; + ocapURLRedemptionService?: unknown; +}; + +// --------------------------------------------------------------------------- +// Facet types (typed remote references for E() calls) +// --------------------------------------------------------------------------- + +type KeyringFacet = { + initialize: ( + options: { type: string; mnemonic?: string }, + password?: string, + salt?: string, + ) => Promise; + unlock: (password: string) => Promise; + isLocked: () => Promise; + hasKeys: () => Promise; + getAccounts: () => Promise; + deriveAccount: (index: number) => Promise
; + signTransaction: (tx: TransactionRequest) => Promise; + signTypedData: (data: Eip712TypedData, from?: Address) => Promise; + signMessage: (message: string, from?: Address) => Promise; + signHash: (hash: Hex, from?: Address) => Promise; + signAuthorization: (options: { + contractAddress: Address; + chainId: number; + nonce?: number; + from?: Address; + }) => Promise; +}; + +type ProviderFacet = { + configure: (config: ChainConfig) => Promise; + request: (method: string, params?: unknown[]) => Promise; + broadcastTransaction: (signedTx: Hex) => Promise; + getChainId: () => Promise; + getNonce: (address: Address) => Promise; + getEntryPointNonce: (options: { + entryPoint: Address; + sender: Address; + key?: Hex; + }) => Promise; + submitUserOp: (options: { + bundlerUrl: string; + entryPoint: Hex; + userOp: UserOperation; + }) => Promise; + estimateUserOpGas: (options: { + bundlerUrl: string; + entryPoint: Hex; + userOp: UserOperation; + }) => Promise<{ + callGasLimit: Hex; + verificationGasLimit: Hex; + preVerificationGas: Hex; + }>; + getUserOpReceipt: (options: { + bundlerUrl: string; + userOpHash: Hex; + }) => Promise; + getGasFees: () => Promise<{ + maxFeePerGas: Hex; + maxPriorityFeePerGas: Hex; + }>; + configureBundler: (config: { + bundlerUrl: string; + chainId: number; + }) => Promise; + httpGetJson: (url: string) => Promise; + getUserOperationGasPrice: () => Promise<{ + fast: { maxFeePerGas: Hex; maxPriorityFeePerGas: Hex }; + }>; + sponsorUserOp: (options: { + bundlerUrl: string; + entryPoint: Hex; + userOp: UserOperation; + context?: Record; + }) => Promise<{ + paymaster: Address; + paymasterData: Hex; + paymasterVerificationGasLimit: Hex; + paymasterPostOpGasLimit: Hex; + callGasLimit: Hex; + verificationGasLimit: Hex; + preVerificationGas: Hex; + }>; +}; + +type ExternalSignerFacet = { + getAccounts: () => Promise; + signTypedData: (data: Eip712TypedData, from: Address) => Promise; + signMessage: (message: string, from: Address) => Promise; + signTransaction: (tx: TransactionRequest) => Promise; +}; + +type OcapURLIssuerFacet = { + issue: (target: unknown) => Promise; +}; + +type DelegatorFacet = { + buildTransferNativeGrant: (options: { + delegator: Address; + delegate: Address; + to?: Address; + maxAmount?: bigint; + chainId: number; + }) => Promise; + buildTransferFungibleGrant: (options: { + delegator: Address; + delegate: Address; + token: Address; + to?: Address; + maxAmount?: bigint; + chainId: number; + }) => Promise; + storeGrant: (grant: DelegationGrant) => Promise; + removeGrant: (id: string) => Promise; + listGrants: () => Promise; + registerContracts: ( + chainId: number, + environment: { + DelegationManager: Hex; + caveatEnforcers?: Record; + }, + ) => Promise; +}; + +// --------------------------------------------------------------------------- +// buildRootObject — home coordinator vat entry point +// --------------------------------------------------------------------------- + +/** + * Build the root object for the home coordinator vat. + * + * The home coordinator owns the keyring, provider, and external signer. + * It manages delegation grant creation (signing + storing) and exposes a + * homeSection exo for the away coordinator's call-home path. + * + * @param vatPowers - Special powers granted to this vat. + * @param _parameters - Initialization parameters (role: 'home'). + * @param baggage - Root of vat's persistent state. + * @returns The root object for the home coordinator vat. + */ +export function buildRootObject( + vatPowers: VatPowers, + _parameters: unknown, + baggage: Baggage, +): object { + const logger = (vatPowers.logger ?? new Logger()).subLogger({ + tags: ['home-coordinator-vat'], + }); + + // Wire SDK logger so resolveEnvironment/registerEnvironment are visible + setSdkLogger((level, message, data) => { + if (level === 'info') { + logger.info(message, data); + } else { + logger.debug(message, data); + } + }); + + // References to other vats (set during bootstrap) + let keyringVat: KeyringFacet | undefined; + let providerVat: ProviderFacet | undefined; + let delegatorVat: DelegatorFacet | undefined; + + // External signer reference (e.g. MetaMask). + // Note: external signers are transient — they must be reconnected after + // kernel restart via connectExternalSigner(). The baggage entry tracks + // the reference but it may be stale after resuscitation. + let externalSigner: ExternalSignerFacet | undefined; + + // Bundler configuration for ERC-4337 UserOps + let bundlerConfig: + | { + bundlerUrl: string; + entryPoint: Hex; + chainId: number; + usePaymaster?: boolean; + sponsorshipPolicyId?: string; + environment?: { + EntryPoint: Hex; + DelegationManager: Hex; + SimpleFactory: Hex; + implementations: Record; + caveatEnforcers: Record; + }; + } + | undefined; + + // Smart account configuration (persisted in baggage) + let smartAccountConfig: SmartAccountConfig | undefined; + + // OcapURL service references + let issuerService: OcapURLIssuerFacet | undefined; + + /** + * Typed helper for restoring values from baggage (resuscitation). + * + * @param key - The baggage key to look up. + * @returns The stored value cast to T, or undefined if not present. + */ + function restoreFromBaggage(key: string): T | undefined { + return baggage.has(key) ? (baggage.get(key) as T) : undefined; + } + + // Restore vat references from baggage if available (resuscitation) + keyringVat = restoreFromBaggage('keyringVat'); + providerVat = restoreFromBaggage('providerVat'); + delegatorVat = restoreFromBaggage('delegatorVat'); + externalSigner = restoreFromBaggage('externalSigner'); + bundlerConfig = restoreFromBaggage('bundlerConfig'); + if (bundlerConfig?.environment) { + registerEnvironment(bundlerConfig.chainId, bundlerConfig.environment); + // Re-register chain contracts so signDelegationInGrant can find the + // DelegationManager address after a kernel restart (resuscitation). + const rawEnforcers = bundlerConfig.environment.caveatEnforcers ?? {}; + const restoredEnforcers = { ...PLACEHOLDER_CONTRACTS.enforcers }; + for (const [key, addr] of Object.entries(rawEnforcers)) { + const caveatType = ENFORCER_CONTRACT_KEY_MAP[key]; + if (caveatType !== undefined) { + restoredEnforcers[caveatType] = addr; + } + } + registerChainContracts(bundlerConfig.chainId, { + delegationManager: bundlerConfig.environment.DelegationManager, + enforcers: restoredEnforcers, + } as ChainContracts); + } + smartAccountConfig = + restoreFromBaggage('smartAccountConfig'); + + /** Chain ID from the last `configureProvider` call (avoids RPC on every send). */ + let cachedProviderChainId: number | undefined = restoreFromBaggage( + 'cachedProviderChainId', + ); + + /** + * Persist a baggage key-value pair, handling both init and update. + * + * @param key - The baggage key. + * @param value - The value to persist. + */ + function persistBaggage(key: string, value: unknown): void { + if (baggage.has(key)) { + baggage.set(key, value); + } else { + baggage.init(key, value); + } + } + + /** + * Resolve the wallet chain ID for SDK addresses and txs. + * + * Order: bundler config → cached provider config → `eth_chainId` RPC. + * + * @returns The resolved chain ID. + */ + async function resolveChainId(): Promise { + if (bundlerConfig?.chainId !== undefined) { + return bundlerConfig.chainId; + } + if (cachedProviderChainId !== undefined) { + return cachedProviderChainId; + } + if (!providerVat) { + throw new Error( + 'Provider not configured — call configureProvider() first', + ); + } + return E(providerVat).getChainId(); + } + + /** + * Whether smart-account operations for this sender should use Infura-style + * raw transactions (stateless 7702) instead of ERC-4337 UserOps. + * + * @param sender - Smart account address (same as EOA for stateless 7702). + * @returns True when direct EIP-1559 submission should be used. + */ + async function useDirect7702Tx(sender: Address): Promise { + if (smartAccountConfig?.implementation === 'stateless7702') { + if ( + smartAccountConfig.address !== undefined && + smartAccountConfig.address.toLowerCase() !== sender.toLowerCase() + ) { + // Config points at a different account — fall through to lazy check. + } else { + return true; + } + } + if (smartAccountConfig?.implementation === 'hybrid') { + return false; + } + if (!providerVat) { + throw new Error( + 'Cannot determine account type: provider not configured and ' + + 'smartAccountConfig is absent. Call configureProvider() first.', + ); + } + const code = (await E(providerVat).request('eth_getCode', [ + sender, + 'latest', + ])) as string; + const chainId = await resolveChainId(); + return isEip7702Delegated(code, chainId); + } + + /** + * Sign and broadcast a self-call tx with SDK-encoded DeleGator calldata + * (7702 EOA). Returns the transaction hash immediately after broadcast. + * + * @param options - Direct submission options. + * @param options.sender - Upgraded EOA / smart account address. + * @param options.callData - SDK-wrapped `execute` calldata. + * @param options.maxFeePerGas - Optional max fee per gas override. + * @param options.maxPriorityFeePerGas - Optional priority fee override. + * @returns The transaction hash from `eth_sendRawTransaction`. + */ + async function buildAndSubmitDirect7702Tx(options: { + sender: Address; + callData: Hex; + maxFeePerGas?: Hex; + maxPriorityFeePerGas?: Hex; + }): Promise { + if (!providerVat) { + throw new Error('Provider vat not available'); + } + const chainId = await resolveChainId(); + let { maxFeePerGas, maxPriorityFeePerGas } = options; + if (!maxFeePerGas || !maxPriorityFeePerGas) { + const fees = await E(providerVat).getGasFees(); + maxFeePerGas = maxFeePerGas ?? fees.maxFeePerGas; + maxPriorityFeePerGas = maxPriorityFeePerGas ?? fees.maxPriorityFeePerGas; + } + const nonce = await E(providerVat).getNonce(options.sender); + const estimatedGas = validateGasEstimate( + await E(providerVat).request('eth_estimateGas', [ + { + from: options.sender, + to: options.sender, + data: options.callData, + }, + ]), + ); + const gasLimit = applyGasBuffer(estimatedGas, 10); + const filledTx: TransactionRequest = { + from: options.sender, + to: options.sender, + chainId, + nonce, + maxFeePerGas, + maxPriorityFeePerGas, + gasLimit, + data: options.callData, + value: '0x0' as Hex, + }; + const signedTx = await resolveTransactionSigning(filledTx); + return E(providerVat).broadcastTransaction(signedTx); + } + + /** + * Poll until an EIP-1559 transaction is mined or timeout. + * + * @param options - Polling options. + * @param options.txHash - Transaction hash to wait for. + * @param options.pollIntervalMs - Delay between RPC polls in milliseconds. + * @param options.timeoutMs - Maximum time to wait in milliseconds. + * @returns Whether the mined transaction succeeded (`status` 0x1). + */ + async function pollTransactionReceipt(options: { + txHash: Hex; + pollIntervalMs?: number; + timeoutMs?: number; + }): Promise<{ success: boolean }> { + if (!providerVat) { + throw new Error('Provider not configured'); + } + if ( + typeof globalThis.Date?.now !== 'function' || + typeof globalThis.setTimeout !== 'function' + ) { + throw new Error( + 'Transaction receipt polling requires Date.now and setTimeout', + ); + } + const interval = options.pollIntervalMs ?? 2000; + const timeout = options.timeoutMs ?? 120_000; + const start = Date.now(); + while (Date.now() - start < timeout) { + let receipt: { status?: string | number } | null = null; + try { + receipt = (await E(providerVat).request('eth_getTransactionReceipt', [ + options.txHash, + ])) as { status?: string | number } | null; + } catch (error) { + // Transient RPC errors (network hiccups, rate limits) should not + // abort polling — the tx was already broadcast and may still mine. + logger.warn( + `RPC error polling receipt for ${options.txHash}, will retry`, + error, + ); + await new Promise((resolve) => setTimeout(resolve, interval)); + continue; + } + if (receipt) { + // Normalize: some providers return status as a number (1) rather + // than the standard hex string ('0x1'). EIP-1559 receipts must have + // a status field; a missing one likely indicates a malformed response. + const { status } = receipt; + if (status === undefined || status === null) { + logger.warn( + `Receipt for ${options.txHash} has no status field — assuming success`, + ); + return harden({ success: true }); + } + const normalizedStatus = + typeof status === 'number' ? `0x${status.toString(16)}` : status; + return harden({ success: normalizedStatus === '0x1' }); + } + await new Promise((resolve) => setTimeout(resolve, interval)); + } + throw new Error( + `Transaction ${options.txHash} not mined after ${String(timeout)}ms`, + ); + } + + /** + * Resolve the EOA owner address from the keyring or external signer. + * + * @returns The first available EOA address. + * @throws If no accounts are available. + */ + async function resolveOwnerAddress(): Promise
{ + if (keyringVat) { + const accounts = await E(keyringVat).getAccounts(); + if (accounts.length > 0) { + return accounts[0] as Address; + } + } + if (externalSigner) { + const accounts = await E(externalSigner).getAccounts(); + if (accounts.length > 0) { + return accounts[0] as Address; + } + } + throw new Error('No accounts available'); + } + + /** + * Resolve the signing strategy for typed data. + * Priority: keyring → external signer → error + * + * @param data - The EIP-712 typed data to sign. + * @param from - Optional sender address. + * @returns The signature as a hex string. + */ + async function resolveTypedDataSigning( + data: Eip712TypedData, + from?: Address, + ): Promise { + if (keyringVat) { + const hasKeys = await E(keyringVat).hasKeys(); + if (hasKeys) { + return E(keyringVat).signTypedData(data, from); + } + } + + if (externalSigner) { + const accounts = await E(externalSigner).getAccounts(); + if (accounts.length > 0) { + return E(externalSigner).signTypedData(data, from ?? accounts[0]); + } + } + + throw new Error('No authority to sign typed data'); + } + + /** + * Resolve the signing strategy for a personal message. + * Priority: keyring → external signer → error + * + * @param message - The message to sign. + * @param from - Optional sender address. + * @returns The signature as a hex string. + */ + async function resolveMessageSigning( + message: string, + from?: Address, + ): Promise { + if (keyringVat) { + const hasKeys = await E(keyringVat).hasKeys(); + if (hasKeys) { + return E(keyringVat).signMessage(message, from); + } + } + + if (externalSigner) { + const accounts = await E(externalSigner).getAccounts(); + if (accounts.length > 0) { + return E(externalSigner).signMessage(message, from ?? accounts[0]); + } + } + + throw new Error('No authority to sign message'); + } + + /** + * Resolve the signing strategy for a transaction. + * Priority: local key → external signer → reject + * + * @param tx - The transaction request to sign. + * @returns The signed transaction as a hex string. + */ + async function resolveTransactionSigning( + tx: TransactionRequest, + ): Promise { + // Strategy 1: Check if local keyring owns this account + if (keyringVat) { + const accounts = await E(keyringVat).getAccounts(); + if (accounts.includes(tx.from.toLowerCase() as Address)) { + return E(keyringVat).signTransaction(tx); + } + } + + // Strategy 2: Check if external signer can handle it + if (externalSigner) { + return E(externalSigner).signTransaction({ + ...tx, + from: tx.from.toLowerCase() as Address, + }); + } + + throw new Error('No authority to sign this transaction'); + } + + /** + * Build, sign, and submit a UserOp. Shared pipeline for both direct + * smart account operations and on-chain delegation revocation. + * + * @param options - Pipeline options. + * @param options.sender - The smart account address that sends the UserOp. + * @param options.callData - The encoded callData for the UserOp. + * @param options.maxFeePerGas - Optional max fee per gas override. + * @param options.maxPriorityFeePerGas - Optional max priority fee per gas override. + * @returns The UserOp hash from the bundler. + */ + async function buildAndSubmitUserOp(options: { + sender: Address; + callData: Hex; + maxFeePerGas?: Hex; + maxPriorityFeePerGas?: Hex; + }): Promise { + if (!providerVat) { + throw new Error('Provider vat not available'); + } + if (!bundlerConfig) { + throw new Error('Bundler not configured'); + } + + const { sender, callData } = options; + + // Get gas prices from the bundler (pimlico_getUserOperationGasPrice) + // which returns prices the bundler will accept, avoiding rejection + // due to stale node-reported fees. + let { maxFeePerGas, maxPriorityFeePerGas } = options; + if (!maxFeePerGas || !maxPriorityFeePerGas) { + const gasPrice = await E(providerVat).getUserOperationGasPrice(); + maxFeePerGas = maxFeePerGas ?? gasPrice.fast.maxFeePerGas; + maxPriorityFeePerGas = + maxPriorityFeePerGas ?? gasPrice.fast.maxPriorityFeePerGas; + } + + // Get nonce from EntryPoint contract (ERC-4337 nonce) + const nonceHex = await E(providerVat).getEntryPointNonce({ + entryPoint: bundlerConfig.entryPoint, + sender, + }); + + // Detect signing mode: check smartAccountConfig first, then fall back + // to on-chain code inspection. This ensures the correct signing mode + // even if smartAccountConfig is lost from baggage. + let isStateless7702 = + smartAccountConfig?.implementation === 'stateless7702'; + + // Always fetch on-chain code — needed for both factory detection and + // signing mode fallback. + const onChainCode = (await E(providerVat).request('eth_getCode', [ + sender, + 'latest', + ])) as string | undefined; + + if (typeof onChainCode !== 'string') { + throw new Error( + `eth_getCode for ${sender} returned ${String(onChainCode)}; check provider configuration`, + ); + } + + // Fall back to on-chain code detection for 7702 accounts that weren't + // configured via smartAccountConfig (e.g., restored from stale baggage). + // Any EIP-7702 designator prefix (0xef0100) indicates a Stateless7702 + // DeleGator, which uses a different EIP-712 domain name for signing. + if (!isStateless7702 && onChainCode.toLowerCase().startsWith('0xef0100')) { + isStateless7702 = true; + } + + // Check on-chain whether the smart account is deployed (eth_getCode). + // This avoids relying on a cached flag that could be stale if the + // deployment UserOp failed on-chain. + let includeFactory = false; + if ( + !isStateless7702 && + smartAccountConfig?.factory && + smartAccountConfig.factoryData + ) { + includeFactory = onChainCode === '0x' || onChainCode === '0x0'; + + if (!includeFactory && smartAccountConfig.deployed === false) { + smartAccountConfig = harden({ + ...smartAccountConfig, + deployed: true, + }); + persistBaggage('smartAccountConfig', smartAccountConfig); + } + } + + // Build unsigned UserOp with a dummy 65-byte signature so that the + // smart account's validateUserOp can parse the ECDSA signature during + // bundler/paymaster simulation. An empty signature (0x) causes revert. + const unsignedUserOp: UserOperation = { + sender, + nonce: nonceHex, + callData, + callGasLimit: '0x50000' as Hex, + verificationGasLimit: '0x60000' as Hex, + preVerificationGas: '0x10000' as Hex, + maxFeePerGas, + maxPriorityFeePerGas, + signature: + '0xfffffffffffffffffffffffffffffff0000000000000000000000000000000007aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1c' as Hex, + ...(includeFactory && smartAccountConfig + ? { + factory: smartAccountConfig.factory as Hex, + factoryData: smartAccountConfig.factoryData as Hex, + } + : {}), + }; + + let userOpWithGas: UserOperation; + + if (bundlerConfig.usePaymaster) { + // Use paymaster sponsorship instead of gas estimation + const sponsorContext: Record = {}; + if (bundlerConfig.sponsorshipPolicyId) { + sponsorContext.sponsorshipPolicyId = bundlerConfig.sponsorshipPolicyId; + } + + const sponsorResult = await E(providerVat).sponsorUserOp({ + bundlerUrl: bundlerConfig.bundlerUrl, + entryPoint: bundlerConfig.entryPoint, + userOp: unsignedUserOp, + context: sponsorContext, + }); + + userOpWithGas = { + ...unsignedUserOp, + paymaster: sponsorResult.paymaster, + paymasterData: sponsorResult.paymasterData, + paymasterVerificationGasLimit: + sponsorResult.paymasterVerificationGasLimit, + paymasterPostOpGasLimit: sponsorResult.paymasterPostOpGasLimit, + callGasLimit: sponsorResult.callGasLimit, + verificationGasLimit: sponsorResult.verificationGasLimit, + preVerificationGas: sponsorResult.preVerificationGas, + }; + } else { + // Estimate gas via bundler + const gasEstimate = await E(providerVat).estimateUserOpGas({ + bundlerUrl: bundlerConfig.bundlerUrl, + entryPoint: bundlerConfig.entryPoint, + userOp: unsignedUserOp, + }); + + userOpWithGas = { + ...unsignedUserOp, + callGasLimit: applyGasBuffer(gasEstimate.callGasLimit, 10), + verificationGasLimit: applyGasBuffer( + gasEstimate.verificationGasLimit, + 10, + ), + preVerificationGas: gasEstimate.preVerificationGas, + }; + } + + // Sign the UserOp via EIP-712 typed data. Both Hybrid and Stateless7702 + // DeleGators validate signatures using EIP-712 — the only difference is + // the domain name. + const userOpTypedData = prepareUserOpTypedData({ + userOp: userOpWithGas, + entryPoint: bundlerConfig.entryPoint, + chainId: bundlerConfig.chainId, + smartAccountAddress: sender, + ...(isStateless7702 + ? { smartAccountName: 'EIP7702StatelessDeleGator' } + : {}), + }); + const signature: Hex = await resolveTypedDataSigning(userOpTypedData); + + // Attach signature and submit + const signedUserOp: UserOperation = { + ...userOpWithGas, + signature, + }; + + return E(providerVat).submitUserOp({ + bundlerUrl: bundlerConfig.bundlerUrl, + entryPoint: bundlerConfig.entryPoint, + userOp: signedUserOp, + }); + } + + /** + * Submit a transaction that calls `DelegationManager.disableDelegation` to + * revoke a delegation on-chain — either via a direct EIP-1559 tx (7702) or + * an ERC-4337 UserOp (hybrid). + * + * @param delegation - The delegation to disable. + * @returns The hash and whether the direct 7702 path was used. + */ + async function submitDisableUserOp( + delegation: Delegation, + ): Promise<{ hash: Hex; isDirect: boolean }> { + const sender = smartAccountConfig?.address ?? delegation.delegator; + + const chainId = await resolveChainId(); + const disableCallData = buildSdkDisableCallData({ + delegation, + chainId, + }); + + try { + const isDirect = await useDirect7702Tx(sender); + if (isDirect) { + const hash = await buildAndSubmitDirect7702Tx({ + sender, + callData: disableCallData, + }); + return { hash, isDirect: true }; + } + if (!bundlerConfig) { + throw new Error( + 'Bundler not configured (required for hybrid on-chain revocation)', + ); + } + const hash = await buildAndSubmitUserOp({ + sender, + callData: disableCallData, + }); + return { hash, isDirect: false }; + } catch (error) { + throw new Error( + `Failed to submit on-chain revocation for delegator ${delegation.delegator}`, + { cause: error }, + ); + } + } + + /** + * Create a Stateless7702 smart account by signing and broadcasting + * an EIP-7702 authorization transaction. The user's EOA address + * becomes the smart account — no factory deployment or funding needed. + * + * @param chainId - The chain ID. + * @returns The smart account configuration. + */ + async function createStateless7702SmartAccount( + chainId: number, + ): Promise { + if (!providerVat) { + throw new Error('Provider vat required for EIP-7702 authorization'); + } + + // Resolve EOA address: keyring first, then external signer. + let eoaAddress: Address; + try { + eoaAddress = await resolveOwnerAddress(); + } catch { + throw new Error('No accounts available for EIP-7702 smart account'); + } + + // Check if already set up (persisted from a prior call) + if ( + smartAccountConfig?.implementation === 'stateless7702' && + smartAccountConfig.address === eoaAddress + ) { + return smartAccountConfig; + } + + // Best-effort on-chain check — works on providers that support + // EIP-7702 designator codes via eth_getCode (not all do, e.g. Infura). + const code = (await E(providerVat).request('eth_getCode', [ + eoaAddress, + 'latest', + ])) as string; + + if (isEip7702Delegated(code, chainId)) { + // eslint-disable-next-line require-atomic-updates + smartAccountConfig = harden({ + implementation: 'stateless7702' as const, + address: eoaAddress, + deployed: true, + }); + persistBaggage('smartAccountConfig', smartAccountConfig); + return smartAccountConfig; + } + + // EIP-7702 promotion requires signAuthorization which is only + // available on the local keyring (not supported by external signers). + if (!keyringVat || !(await E(keyringVat).hasKeys())) { + throw new Error( + 'EIP-7702 promotion requires a local keyring with initialized keys. ' + + 'Use implementation: "hybrid", or promote the account through MetaMask first.', + ); + } + + // Sign EIP-7702 authorization + const env = resolveEnvironment(chainId); + const implAddress = ( + env.implementations as Record + ).EIP7702StatelessDeleGatorImpl; + if (!implAddress) { + throw new Error( + `EIP7702StatelessDeleGatorImpl not found in environment for chain ${String(chainId)}`, + ); + } + + // Fetch the EOA nonce, gas fees, and sign the authorization in parallel. + // EIP-7702 self-execution: the tx sender is the same EOA as the + // authorization authority. The sender's nonce is incremented by the tx + // validity check BEFORE authorizations are processed, so the + // authorization nonce must be txNonce + 1. + const EIP7702_FALLBACK_GAS = '0x19000' as Hex; // 102400 + // Minimum plausible gas for an EIP-7702 auth tx (~40k). Estimates + // below this likely indicate the RPC ignored the authorizationList + // and returned a plain-transfer estimate (21000). + const EIP7702_MIN_GAS = 0xa000n; // 40960 + const [nonce, fees, estimatedAuthGas] = await Promise.all([ + E(providerVat).getNonce(eoaAddress), + E(providerVat).getGasFees(), + ( + E(providerVat).request('eth_estimateGas', [ + { + from: eoaAddress, + to: eoaAddress, + authorizationList: [{ address: implAddress, chainId }], + }, + ]) as Promise + ).then( + (result) => { + if (typeof result !== 'string' || !result.startsWith('0x')) { + logger.warn( + `eth_estimateGas returned non-hex for EIP-7702 auth: ${String(result)}, using fallback`, + ); + return EIP7702_FALLBACK_GAS; + } + if (BigInt(result) < EIP7702_MIN_GAS) { + logger.warn( + `eth_estimateGas returned suspiciously low value ${result} for EIP-7702 auth, using fallback`, + ); + return EIP7702_FALLBACK_GAS; + } + return result; + }, + (error: unknown) => { + const message = + error instanceof Error ? error.message : String(error); + // Only fall back when the RPC doesn't support authorizationList param + if ( + message.includes('-32602') || + message.includes('-32601') || + message.includes('not supported') || + message.includes('unknown field') + ) { + logger.warn( + 'eth_estimateGas does not support authorizationList, using fallback gas', + ); + return EIP7702_FALLBACK_GAS; + } + throw new Error( + `eth_estimateGas failed for EIP-7702 authorization: ${message}`, + ); + }, + ), + ]); + const authGasLimit = applyGasBuffer(estimatedAuthGas, 20); + const signedAuth = await E(keyringVat).signAuthorization({ + contractAddress: implAddress as Address, + chainId, + nonce: nonce + 1, + }); + + const signedTx = await E(keyringVat).signTransaction({ + from: eoaAddress, + to: eoaAddress, + chainId, + nonce, + maxFeePerGas: fees.maxFeePerGas, + maxPriorityFeePerGas: fees.maxPriorityFeePerGas, + gasLimit: authGasLimit, + authorizationList: [signedAuth], + }); + + const txHash = await E(providerVat).broadcastTransaction(signedTx); + + // Wait for the authorization tx to be mined. Some RPC providers (e.g. + // Infura) don't expose EIP-7702 designator code via eth_getCode, so we + // poll eth_getTransactionReceipt instead (status 0x1 = success). + if (typeof globalThis.setTimeout !== 'function') { + throw new Error( + 'EIP-7702 confirmation polling requires setTimeout ' + + '(not available in SES compartments without timer endowments)', + ); + } + const maxAttempts = 45; + for (let i = 0; i < maxAttempts; i++) { + const receipt = (await E(providerVat).request( + 'eth_getTransactionReceipt', + [txHash], + )) as { status?: string } | null; + if (receipt?.status === '0x1') { + break; + } + if (receipt?.status === '0x0') { + throw new Error( + `EIP-7702 authorization tx ${txHash as string} reverted on-chain`, + ); + } + if (i === maxAttempts - 1) { + throw new Error( + `EIP-7702 authorization tx ${txHash} not confirmed after 90s`, + ); + } + await new Promise((resolve) => setTimeout(resolve, 2000)); + } + + // eslint-disable-next-line require-atomic-updates + smartAccountConfig = harden({ + implementation: 'stateless7702' as const, + address: eoaAddress, + deployed: true, + }); + persistBaggage('smartAccountConfig', smartAccountConfig); + return smartAccountConfig; + } + + /** + * Sign the delegation inside an unsigned grant and return the grant with the + * delegation's signature field filled in. + * + * Resolves the DelegationManager verifying contract from the chain's registered + * contracts (see {@link getChainContracts}), prepares EIP-712 typed data, and + * signs via keyring or external signer. + * + * @param unsignedGrant - A grant whose `delegation.signature` is undefined. + * @returns The same grant with `delegation.signature` set and `delegation.status` 'signed'. + */ + async function signDelegationInGrant( + unsignedGrant: T, + ): Promise { + const { delegation } = unsignedGrant; + + // Resolve the DelegationManager address for this chain + const contracts = getChainContracts(delegation.chainId); + const verifyingContract = contracts.delegationManager; + + const typedData = prepareDelegationTypedData({ + delegation, + verifyingContract, + }); + + const signature = await resolveTypedDataSigning(typedData); + + const signedDelegation = finalizeDelegation(delegation, signature); + + return harden({ + ...unsignedGrant, + delegation: signedDelegation, + }); + } + + // --------------------------------------------------------------------------- + // homeSection exo — built once, after all internal functions are defined + // --------------------------------------------------------------------------- + + // Demo limit: each method throws after 2 uses + let transferNativeUses = 0; + let transferFungibleUses = 0; + const HOME_SECTION_LIMIT = 2; + + const homeSection = makeDiscoverableExo( + 'HomeWallet', + { + async transferNative(to: Address, amount: bigint): Promise { + if (transferNativeUses >= HOME_SECTION_LIMIT) { + throw new Error( + `Home transferNative limit (${HOME_SECTION_LIMIT}) exhausted`, + ); + } + transferNativeUses += 1; + const from = await resolveOwnerAddress(); + const amountHex: Hex = `0x${amount.toString(16)}`; + if (!providerVat) { + throw new Error('Provider not configured'); + } + const chainId = await resolveChainId(); + const nonce = await E(providerVat).getNonce(from); + const fees = await E(providerVat).getGasFees(); + const estimatedGas = validateGasEstimate( + await E(providerVat).request('eth_estimateGas', [ + { from, to, value: amountHex }, + ]), + ); + const gasLimit = applyGasBuffer(estimatedGas, 10); + const tx: TransactionRequest = { + from, + to, + chainId, + nonce, + value: amountHex, + maxFeePerGas: fees.maxFeePerGas, + maxPriorityFeePerGas: fees.maxPriorityFeePerGas, + gasLimit, + data: '0x' as Hex, + }; + const signed = await resolveTransactionSigning(tx); + return E(providerVat).broadcastTransaction(signed); + }, + + async transferFungible( + token: Address, + to: Address, + amount: bigint, + ): Promise { + if (transferFungibleUses >= HOME_SECTION_LIMIT) { + throw new Error( + `Home transferFungible limit (${HOME_SECTION_LIMIT}) exhausted`, + ); + } + transferFungibleUses += 1; + const from = await resolveOwnerAddress(); + if (!providerVat) { + throw new Error('Provider not configured'); + } + const callData = encodeTransfer( + to, + BigInt(amount as unknown as string | number | bigint), + ); + const chainId = await resolveChainId(); + const nonce = await E(providerVat).getNonce(from); + const fees = await E(providerVat).getGasFees(); + const estimatedGas = validateGasEstimate( + await E(providerVat).request('eth_estimateGas', [ + { from, to: token, data: callData, value: '0x0' }, + ]), + ); + const gasLimit = applyGasBuffer(estimatedGas, 10); + const tx: TransactionRequest = { + from, + to: token, + chainId, + nonce, + value: '0x0' as Hex, + data: callData, + maxFeePerGas: fees.maxFeePerGas, + maxPriorityFeePerGas: fees.maxPriorityFeePerGas, + gasLimit, + }; + const signed = await resolveTransactionSigning(tx); + return E(providerVat).broadcastTransaction(signed); + }, + }, + { + transferNative: METHOD_CATALOG.transferNative, + transferFungible: METHOD_CATALOG.transferFungible, + }, + M.interface( + 'HomeWallet', + { + transferNative: M.callWhen(M.string(), M.bigint()).returns(M.string()), + transferFungible: M.callWhen( + M.string(), + M.string(), + M.bigint(), + ).returns(M.string()), + }, + { defaultGuards: 'passable' }, + ), + ); + + // --------------------------------------------------------------------------- + // Public exo — the home coordinator's exported interface + // --------------------------------------------------------------------------- + + const homeCoordinator = makeDefaultExo('walletHomeCoordinator', { + // ------------------------------------------------------------------ + // Lifecycle + // ------------------------------------------------------------------ + + async bootstrap(vats: WalletVats, services: WalletServices): Promise { + keyringVat = vats.keyring as KeyringFacet | undefined; + providerVat = vats.provider as ProviderFacet | undefined; + delegatorVat = vats.delegator as DelegatorFacet | undefined; + issuerService = services.ocapURLIssuerService as + | OcapURLIssuerFacet + | undefined; + + if (keyringVat) { + persistBaggage('keyringVat', keyringVat); + } + if (providerVat) { + persistBaggage('providerVat', providerVat); + } + if (delegatorVat) { + persistBaggage('delegatorVat', delegatorVat); + } + // On resuscitation, propagate bundler environment to delegator-vat so + // its isolated module-level Map has the chain contracts it needs. + if (delegatorVat && bundlerConfig?.environment) { + await E(delegatorVat).registerContracts( + bundlerConfig.chainId, + bundlerConfig.environment, + ); + } + + logger.info('bootstrap complete', { + hasKeyring: Boolean(keyringVat), + hasProvider: Boolean(providerVat), + hasDelegator: Boolean(delegatorVat), + }); + }, + + // ------------------------------------------------------------------ + // Wallet initialization + // ------------------------------------------------------------------ + + async initializeKeyring(options: { + type: 'srp' | 'throwaway'; + mnemonic?: string; + entropy?: Hex; + password?: string; + salt?: string; + addressIndex?: number; + }): Promise { + if (!keyringVat) { + throw new Error('Keyring vat not available'); + } + let initOptions: + | { type: 'srp'; mnemonic: string; addressIndex?: number } + | { type: 'throwaway'; entropy?: Hex }; + if (options.type === 'throwaway') { + initOptions = { type: 'throwaway', entropy: options.entropy }; + } else { + initOptions = + options.addressIndex === undefined + ? { type: 'srp', mnemonic: options.mnemonic ?? '' } + : { + type: 'srp', + mnemonic: options.mnemonic ?? '', + addressIndex: options.addressIndex, + }; + } + + const password = options.type === 'srp' ? options.password : undefined; + await E(keyringVat).initialize(initOptions, password, options.salt); + }, + + async unlockKeyring(password: string): Promise { + if (!keyringVat) { + throw new Error('Keyring vat not available'); + } + await E(keyringVat).unlock(password); + }, + + async isKeyringLocked(): Promise { + if (!keyringVat) { + throw new Error('Keyring vat not available'); + } + return E(keyringVat).isLocked(); + }, + + async configureProvider(chainConfig: ChainConfig): Promise { + if (!providerVat) { + throw new Error('Provider vat not available'); + } + + // Validate RPC URL (regex — URL constructor unavailable under SES) + if (!/^https?:\/\/.+/u.test(chainConfig.rpcUrl)) { + throw new Error( + `Invalid RPC URL: "${chainConfig.rpcUrl}". Must be a valid HTTP(S) URL.`, + ); + } + + if (!Number.isInteger(chainConfig.chainId) || chainConfig.chainId <= 0) { + throw new Error( + `Invalid chain ID: ${String(chainConfig.chainId)}. Must be a positive integer.`, + ); + } + + await E(providerVat).configure(chainConfig); + + cachedProviderChainId = chainConfig.chainId; + persistBaggage('cachedProviderChainId', cachedProviderChainId); + }, + + // ------------------------------------------------------------------ + // External signer & bundler configuration + // ------------------------------------------------------------------ + + async connectExternalSigner(signer: ExternalSignerFacet): Promise { + if (!signer || typeof signer !== 'object') { + throw new Error('Invalid external signer: must be a non-null object'); + } + externalSigner = signer; + persistBaggage('externalSigner', externalSigner); + }, + + async configureBundler(config: { + bundlerUrl: string; + entryPoint?: Hex; + chainId: number; + usePaymaster?: boolean; + sponsorshipPolicyId?: string; + environment?: { + EntryPoint: Hex; + DelegationManager: Hex; + SimpleFactory: Hex; + implementations: Record; + caveatEnforcers: Record; + }; + }): Promise { + // Validate bundler URL (regex — URL constructor unavailable under SES) + if (!/^https?:\/\/.+/u.test(config.bundlerUrl)) { + throw new Error( + `Invalid bundler URL: "${config.bundlerUrl}". Must be a valid HTTP(S) URL.`, + ); + } + + if (!Number.isInteger(config.chainId) || config.chainId <= 0) { + throw new Error( + `Invalid chain ID: ${String(config.chainId)}. Must be a positive integer.`, + ); + } + + // Register a custom SDK environment for chains not in the SDK's built-in + // registry (e.g. local Anvil at chain 31337). + if (config.environment) { + registerEnvironment(config.chainId, config.environment); + + // Also register in our own getChainContracts() registry so that + // signDelegationInGrant() can find the DelegationManager for this chain. + const rawEnforcers = config.environment.caveatEnforcers ?? {}; + const enforcers = { ...PLACEHOLDER_CONTRACTS.enforcers }; + for (const [key, addr] of Object.entries(rawEnforcers)) { + const caveatType = ENFORCER_CONTRACT_KEY_MAP[key]; + if (caveatType !== undefined) { + enforcers[caveatType] = addr; + } + } + registerChainContracts(config.chainId, { + delegationManager: config.environment.DelegationManager, + enforcers, + } as ChainContracts); + + // Propagate to the delegator vat so its module-level Map is also + // populated (each vat has isolated module state). + if (delegatorVat) { + await E(delegatorVat).registerContracts( + config.chainId, + config.environment, + ); + } + } + + bundlerConfig = harden({ + bundlerUrl: config.bundlerUrl, + entryPoint: config.entryPoint ?? ENTRY_POINT_V07, + chainId: config.chainId, + usePaymaster: config.usePaymaster, + sponsorshipPolicyId: config.sponsorshipPolicyId, + environment: config.environment, + }); + persistBaggage('bundlerConfig', bundlerConfig); + + logger.info('bundler configured', { + bundlerUrl: config.bundlerUrl, + chainId: config.chainId, + entryPoint: bundlerConfig.entryPoint, + hasEnvironment: Boolean(config.environment), + }); + + if (!providerVat) { + throw new Error( + 'Provider vat not available. Call configureProvider() before configureBundler().', + ); + } + await E(providerVat).configureBundler({ + bundlerUrl: config.bundlerUrl, + chainId: config.chainId, + }); + }, + + // ------------------------------------------------------------------ + // Smart account configuration + // ------------------------------------------------------------------ + + async createSmartAccount(config: { + deploySalt?: Hex; + chainId: number; + address?: Address; + implementation?: 'hybrid' | 'stateless7702'; + }): Promise { + const implementation = config.implementation ?? 'hybrid'; + + if (implementation === 'stateless7702') { + return createStateless7702SmartAccount(config.chainId); + } + + // Hybrid path (existing logic) + let { address } = config; + let factory: Address | undefined; + let factoryData: Hex | undefined; + const deploySalt = + config.deploySalt ?? + ('0x0000000000000000000000000000000000000000000000000000000000000001' as Hex); + + // Derive counterfactual address if not explicitly provided + if (!address) { + // Find the owner EOA from keyring or external signer + let owner: Address; + try { + owner = await resolveOwnerAddress(); + } catch { + throw new Error( + 'No owner account available to derive smart account address', + ); + } + + const env = resolveEnvironment(config.chainId); + factory = env.SimpleFactory; + + const derived = await computeSmartAccountAddress({ + owner, + deploySalt, + chainId: config.chainId, + }); + address = derived.address; + factoryData = derived.factoryData; + } + + smartAccountConfig = harden({ + implementation: 'hybrid' as const, + deploySalt, + address, + factory, + factoryData, + deployed: false, + }); + persistBaggage('smartAccountConfig', smartAccountConfig); + return smartAccountConfig; + }, + + async getSmartAccountAddress(): Promise
{ + return smartAccountConfig?.address; + }, + + // ------------------------------------------------------------------ + // Public wallet API + // ------------------------------------------------------------------ + + async getAccounts(): Promise { + const localAccounts: Address[] = keyringVat + ? await E(keyringVat).getAccounts() + : []; + + const extAccounts: Address[] = externalSigner + ? await E(externalSigner).getAccounts() + : []; + + // Deduplicate by lowercasing + const seen = new Set(localAccounts.map((a) => a.toLowerCase())); + const merged = [...localAccounts]; + for (const account of extAccounts) { + if (!seen.has(account.toLowerCase())) { + seen.add(account.toLowerCase()); + merged.push(account); + } + } + return merged; + }, + + async signTransaction(tx: TransactionRequest): Promise { + return resolveTransactionSigning(tx); + }, + + async sendTransaction(tx: TransactionRequest): Promise { + if (!providerVat) { + throw new Error('Provider not configured'); + } + logger.debug('sendTransaction', { + from: tx.from, + to: tx.to, + value: tx.value, + hasBundlerConfig: Boolean(bundlerConfig), + }); + + // Home sends direct transactions only — no delegation routing. + logger.debug('sendTransaction: using direct send'); + + // Estimate missing gas fields for direct (non-delegation) sends + const filledTx = { ...tx }; + + filledTx.nonce ??= await E(providerVat).getNonce(filledTx.from); + filledTx.chainId ??= await E(providerVat).getChainId(); + if (!filledTx.maxFeePerGas || !filledTx.maxPriorityFeePerGas) { + const fees = await E(providerVat).getGasFees(); + filledTx.maxFeePerGas ??= fees.maxFeePerGas; + filledTx.maxPriorityFeePerGas ??= fees.maxPriorityFeePerGas; + } + filledTx.gasLimit ??= applyGasBuffer( + validateGasEstimate( + await E(providerVat).request('eth_estimateGas', [ + { + from: filledTx.from, + to: filledTx.to, + value: filledTx.value, + data: filledTx.data, + }, + ]), + ), + 10, + ); + + const signedTx = await resolveTransactionSigning(filledTx); + return E(providerVat).broadcastTransaction(signedTx); + }, + + async sendBatchTransaction( + txs: TransactionRequest[], + ): Promise { + if (txs.length === 0) { + throw new Error('No transactions to send'); + } + + if (txs.length === 1) { + return homeCoordinator.sendTransaction(txs[0]); + } + + if (!providerVat) { + throw new Error('Provider not configured'); + } + + const batchSender = + smartAccountConfig?.address ?? (await homeCoordinator.getAccounts())[0]; + + // Cache the predicate result — useDirect7702Tx is impure (eth_getCode) + // and must not be called twice for the same sender. + const isDirect7702Batch = + batchSender !== undefined && + smartAccountConfig?.implementation === 'stateless7702' && + (await useDirect7702Tx(batchSender)); + + // Home batches smart account txs directly — no delegation batch path. + const useSmartAccountBatchPath = + bundlerConfig !== undefined || isDirect7702Batch; + + if (useSmartAccountBatchPath) { + const executions: Execution[] = txs.map((tx) => ({ + target: tx.to, + value: tx.value ?? ('0x0' as Hex), + callData: tx.data ?? ('0x' as Hex), + })); + + const sender = batchSender; + if (!sender) { + throw new Error('No accounts available for batch'); + } + + const callData = buildBatchExecuteCallData({ executions }); + if (isDirect7702Batch) { + return buildAndSubmitDirect7702Tx({ sender, callData }); + } + if (!bundlerConfig) { + throw new Error( + 'Non-delegation batch execution requires a bundler or direct 7702', + ); + } + return buildAndSubmitUserOp({ sender, callData }); + } + + // EOA fallback: execute sequentially + const hashes: Hex[] = []; + for (const tx of txs) { + hashes.push(await homeCoordinator.sendTransaction(tx)); + } + return hashes; + }, + + async signTypedData(data: Eip712TypedData, from?: Address): Promise { + return resolveTypedDataSigning(data, from); + }, + + async signMessage(message: string, account?: Address): Promise { + return resolveMessageSigning(message, account); + }, + + async request(method: string, params?: unknown[]): Promise { + if (!providerVat) { + throw new Error('Provider not configured'); + } + return E(providerVat).request(method, params); + }, + + /** + * Look up a transaction by hash. Tries the bundler first (in case the + * hash is a UserOp hash from a smart account operation), then falls back + * to a regular `eth_getTransactionReceipt` RPC call. + * + * @param hash - A UserOp hash or regular tx hash. + * @returns An object with `txHash` and `receipt`, or null if not found. + */ + async getTransactionReceipt(hash: Hex): Promise<{ + txHash: Hex; + userOpHash?: Hex; + success: boolean; + } | null> { + if (!providerVat) { + throw new Error('Provider not configured'); + } + + // Try bundler first (UserOp hash) + if (bundlerConfig) { + try { + const userOpReceipt = (await E(providerVat).getUserOpReceipt({ + bundlerUrl: bundlerConfig.bundlerUrl, + userOpHash: hash, + })) as { + success: boolean; + receipt?: { transactionHash?: string }; + } | null; + + if (userOpReceipt?.receipt?.transactionHash) { + return harden({ + txHash: userOpReceipt.receipt.transactionHash as Hex, + userOpHash: hash, + success: userOpReceipt.success, + }); + } + } catch (error) { + // Not a UserOp hash — fall through to regular RPC + logger.debug( + 'UserOp receipt lookup failed, trying regular RPC', + error, + ); + } + } + + // Try regular tx receipt + const receipt = (await E(providerVat).request( + 'eth_getTransactionReceipt', + [hash], + )) as { status?: string; transactionHash?: string } | null; + + if (receipt?.transactionHash) { + return harden({ + txHash: receipt.transactionHash as Hex, + success: receipt.status === '0x1', + }); + } + + return null; + }, + + // ------------------------------------------------------------------ + // Delegation grant management (via delegator vat) + // ------------------------------------------------------------------ + + /** + * Build, sign, and store a TransferNative delegation grant. + * + * Calls the delegator vat to construct the unsigned delegation, signs it + * locally with the available keyring or external signer, then stores the + * signed grant back in the delegator vat. + * + * @param options - Grant construction options. + * @param options.delegate - The delegate address. + * @param options.to - Optional restricted recipient. + * @param options.maxAmount - Optional per-call ETH value limit (wei). + * @param options.chainId - The chain ID. + * @returns The signed TransferNativeGrant. + */ + async buildTransferNativeGrant(options: { + delegate: Address; + to?: Address; + maxAmount?: bigint; + chainId: number; + }): Promise { + if (!delegatorVat) { + throw new Error('Delegator vat not available'); + } + const delegator = + smartAccountConfig?.address ?? (await resolveOwnerAddress()); + const unsignedGrant = await E(delegatorVat).buildTransferNativeGrant({ + delegator, + ...options, + }); + const signedGrant = await signDelegationInGrant(unsignedGrant); + await E(delegatorVat).storeGrant(signedGrant); + return signedGrant; + }, + + /** + * Build, sign, and store a TransferFungible delegation grant. + * + * Calls the delegator vat to construct the unsigned delegation, signs it + * locally with the available keyring or external signer, then stores the + * signed grant back in the delegator vat. + * + * @param options - Grant construction options. + * @param options.delegate - The delegate address. + * @param options.token - The ERC-20 token contract address. + * @param options.to - Optional restricted recipient. + * @param options.maxAmount - Optional cumulative transfer cap (token units). + * @param options.chainId - The chain ID. + * @returns The signed TransferFungibleGrant. + */ + async buildTransferFungibleGrant(options: { + delegate: Address; + token: Address; + to?: Address; + maxAmount?: bigint; + chainId: number; + }): Promise { + if (!delegatorVat) { + throw new Error('Delegator vat not available'); + } + const delegator = + smartAccountConfig?.address ?? (await resolveOwnerAddress()); + const unsignedGrant = await E(delegatorVat).buildTransferFungibleGrant({ + delegator, + ...options, + }); + const signedGrant = await signDelegationInGrant(unsignedGrant); + await E(delegatorVat).storeGrant(signedGrant); + return signedGrant; + }, + + /** + * List all delegation grants stored in the delegator vat. + * + * @returns An array of all DelegationGrant objects. + */ + async listGrants(): Promise { + if (!delegatorVat) { + throw new Error('Delegator vat not available'); + } + return E(delegatorVat).listGrants(); + }, + + /** + * Revoke a delegation grant on-chain and remove it from the delegator vat. + * + * Submits an on-chain `disableDelegation` call (either via direct EIP-1559 + * or ERC-4337 UserOp depending on the smart account type), waits for + * confirmation, then removes the grant from the delegator vat. + * + * @param id - The delegation ID to revoke. + * @returns The transaction or UserOp hash of the on-chain revocation. + */ + async revokeGrant(id: string): Promise { + if (!delegatorVat) { + throw new Error('Delegator vat not available'); + } + + // Find the grant by id + const grants = await E(delegatorVat).listGrants(); + const grant = grants.find((gr) => gr.delegation.id === id); + if (!grant) { + throw new Error(`Grant ${id} not found`); + } + + const { delegation } = grant; + if (delegation.status === 'revoked') { + throw new Error(`Grant ${id} is already revoked`); + } + if (delegation.status !== 'signed') { + throw new Error( + `Grant ${id} has status '${delegation.status}', expected 'signed'`, + ); + } + + // Submit on-chain disable — returns the hash and which path was used + // so we poll the right receipt endpoint without calling useDirect7702Tx + // a second time (the predicate is impure due to eth_getCode). + const { hash: submissionHash, isDirect } = + await submitDisableUserOp(delegation); + + if (isDirect) { + const receipt = await pollTransactionReceipt({ + txHash: submissionHash, + }); + if (!receipt.success) { + throw new Error( + `On-chain revocation reverted for grant ${id} (tx: ${submissionHash})`, + ); + } + } else { + // waitForUserOpReceipt either returns a non-null receipt or throws + // on timeout — validate the shape to catch unexpected bundler responses. + const rawReceipt = await homeCoordinator.waitForUserOpReceipt({ + userOpHash: submissionHash, + }); + const receipt = rawReceipt as { success?: boolean } | undefined; + if ( + !receipt || + typeof receipt !== 'object' || + !('success' in receipt) + ) { + throw new Error( + `Unexpected UserOp receipt format for grant ${id} ` + + `(userOpHash: ${submissionHash})`, + ); + } + if (!receipt.success) { + throw new Error( + `On-chain revocation reverted for grant ${id} (userOpHash: ${submissionHash})`, + ); + } + } + + // Remove local grant record after on-chain confirmation + await E(delegatorVat).removeGrant(id); + + return submissionHash; + }, + + /** + * Relay a delegation redemption from an away coordinator that has no bundler. + * Used by the peer-relay away kernel to submit delegation UserOps via home's + * bundler. The delegation's delegate must be home's smart account address. + * + * @param options - Redemption options. + * @param options.delegation - The signed delegation to redeem. + * @param options.execution - The execution to perform. + * @returns The transaction or UserOp hash. + */ + async redeemDelegation(options: { + delegation: Delegation; + execution: Execution; + }): Promise { + const sender = smartAccountConfig?.address ?? options.delegation.delegate; + const chainId = await resolveChainId(); + const sdkCallData = buildSdkRedeemCallData({ + delegations: [options.delegation], + execution: options.execution, + chainId, + }); + if (await useDirect7702Tx(sender)) { + return buildAndSubmitDirect7702Tx({ sender, callData: sdkCallData }); + } + if (!bundlerConfig) { + throw new Error( + 'Bundler not configured — cannot relay delegation redemption', + ); + } + return buildAndSubmitUserOp({ sender, callData: sdkCallData }); + }, + + // ------------------------------------------------------------------ + // ERC-20 token utilities + // ------------------------------------------------------------------ + + async getTokenBalance(options: { + token: Address; + owner: Address; + }): Promise { + if (!providerVat) { + throw new Error('Provider not configured'); + } + const callData = encodeBalanceOf(options.owner); + const result = await E(providerVat).request('eth_call', [ + { to: options.token, data: callData }, + 'latest', + ]); + const validated = validateTokenCallResult( + result, + 'balanceOf', + options.token, + ); + return decodeBalanceOfResult(validated).toString(); + }, + + async getTokenMetadata(options: { + token: Address; + }): Promise<{ name: string; symbol: string; decimals: number }> { + if (!providerVat) { + throw new Error('Provider not configured'); + } + const [nameSettled, symbolSettled, decimalsSettled] = + await Promise.allSettled([ + E(providerVat).request('eth_call', [ + { to: options.token, data: encodeName() }, + 'latest', + ]), + E(providerVat).request('eth_call', [ + { to: options.token, data: encodeSymbol() }, + 'latest', + ]), + E(providerVat).request('eth_call', [ + { to: options.token, data: encodeDecimals() }, + 'latest', + ]), + ]); + + // decimals is mandatory — wrong decimals causes financial errors + if (decimalsSettled.status === 'rejected') { + throw new Error( + `decimals() call failed for token ${options.token}: ${ + decimalsSettled.reason instanceof Error + ? decimalsSettled.reason.message + : String(decimalsSettled.reason) + }`, + ); + } + + // name and symbol are optional in ERC-20; fall back gracefully + let name = 'Unknown'; + if (nameSettled.status === 'fulfilled') { + try { + name = decodeNameResult( + validateTokenCallResult(nameSettled.value, 'name', options.token), + ); + } catch { + // name() not implemented or returned invalid data + } + } + + let symbol = 'Unknown'; + if (symbolSettled.status === 'fulfilled') { + try { + symbol = decodeSymbolResult( + validateTokenCallResult( + symbolSettled.value, + 'symbol', + options.token, + ), + ); + } catch { + // symbol() not implemented or returned invalid data + } + } + + return harden({ + name, + symbol, + decimals: decodeDecimalsResult( + validateTokenCallResult( + decimalsSettled.value, + 'decimals', + options.token, + ), + ), + }); + }, + + async sendErc20Transfer(options: { + token: Address; + to: Address; + amount: bigint | Hex; + from?: Address; + }): Promise { + const accounts = await homeCoordinator.getAccounts(); + const from = options.from ?? accounts[0]; + if (!from) { + throw new Error('No accounts available'); + } + const rawAmount = + typeof options.amount === 'bigint' + ? options.amount + : BigInt(options.amount); + const callData = encodeTransfer(options.to, rawAmount); + return homeCoordinator.sendTransaction({ + from, + to: options.token, + data: callData, + value: '0x0' as Hex, + }); + }, + + // ------------------------------------------------------------------ + // Token swaps (MetaSwap API) + // ------------------------------------------------------------------ + + async getSwapQuote(options: { + srcToken: Address; + destToken: Address; + srcAmount: Hex; + slippage: number; + walletAddress?: Address; + }): Promise { + if (!providerVat) { + throw new Error('Provider not configured'); + } + + if (options.slippage < 0.1 || options.slippage > 50) { + throw new Error('Slippage must be between 0.1 and 50'); + } + + const walletAddress = + options.walletAddress ?? (await homeCoordinator.getAccounts())[0]; + if (!walletAddress) { + throw new Error('No accounts available'); + } + + const chainId = await resolveChainId(); + + const rawAmount = BigInt(options.srcAmount).toString(); + + // Build query string manually — URLSearchParams is unavailable in SES vats. + const queryEntries: [string, string][] = [ + ['sourceToken', options.srcToken.toLowerCase()], + ['destinationToken', options.destToken.toLowerCase()], + ['sourceAmount', rawAmount], + ['slippage', String(options.slippage)], + ['walletAddress', walletAddress], + ['timeout', '10000'], + ]; + const query = queryEntries + .map( + ([key, val]) => + `${encodeURIComponent(key)}=${encodeURIComponent(val)}`, + ) + .join('&'); + + const url = `https://swap.api.cx.metamask.io/networks/${String(chainId)}/trades?${query}`; + + const response = await E(providerVat).httpGetJson(url); + + if (!Array.isArray(response) || response.length === 0) { + throw new Error( + 'No swap quotes available for this token pair and amount', + ); + } + + // Select the best quote by highest destinationAmount + let best: SwapQuote | undefined; + let bestAmount = -1n; + + for (const entry of response) { + const quote = entry as Record; + if (quote.error) { + continue; + } + const rawDest = + typeof quote.destinationAmount === 'string' + ? quote.destinationAmount + : '0'; + const destAmount = BigInt(rawDest); + if (destAmount > bestAmount) { + bestAmount = destAmount; + best = quote as unknown as SwapQuote; + } + } + + if (!best) { + throw new Error( + 'All swap aggregators returned errors. Try a different amount or token pair.', + ); + } + + return harden(best); + }, + + async swapTokens(options: { + srcToken: Address; + destToken: Address; + srcAmount: Hex; + slippage: number; + }): Promise { + const ZERO_ADDRESS = + '0x0000000000000000000000000000000000000000' as Address; + + const accounts = await homeCoordinator.getAccounts(); + const from = accounts[0]; + if (!from) { + throw new Error('No accounts available'); + } + + // Fetch a fresh quote at execution time, reusing the resolved account + const quote = await homeCoordinator.getSwapQuote({ + ...options, + walletAddress: from, + }); + + // Determine if approval is needed + const needsApproval = + quote.approvalNeeded !== null && + options.srcToken.toLowerCase() !== ZERO_ADDRESS; + + const approvalInfo = needsApproval ? quote.approvalNeeded : null; + let approvalNeeded = false; + if (approvalInfo) { + if (!providerVat) { + throw new Error('Provider not configured'); + } + + const spender = approvalInfo.to as Address; + const allowanceCallData = encodeAllowance(from, spender); + const allowanceResult = await E(providerVat).request('eth_call', [ + { to: options.srcToken, data: allowanceCallData }, + 'latest', + ]); + + const currentAllowance = + typeof allowanceResult === 'string' && allowanceResult !== '0x' + ? decodeAllowanceResult(allowanceResult as Hex) + : 0n; + + approvalNeeded = currentAllowance < BigInt(options.srcAmount); + } + + const swapTx: TransactionRequest = { + from, + to: quote.trade.to as Address, + data: quote.trade.data as Hex, + value: (quote.trade.value ?? '0x0') as Hex, + }; + + // Batch path: combine approve + swap in a single UserOp when + // the bundler is configured (smart account). + if (approvalNeeded && approvalInfo && bundlerConfig) { + const approvalTx: TransactionRequest = { + from, + to: options.srcToken, + data: approvalInfo.data as Hex, + value: (approvalInfo.value ?? '0x0') as Hex, + }; + + const batchResult = await homeCoordinator.sendBatchTransaction([ + approvalTx, + swapTx, + ]); + + // sendBatchTransaction returns a single Hex for batched UserOps + const batchHash = Array.isArray(batchResult) + ? (batchResult[0] as Hex) + : batchResult; + + return harden({ + approvalTxHash: undefined, + swapTxHash: batchHash, + sourceAmount: quote.sourceAmount, + destinationAmount: quote.destinationAmount, + aggregator: quote.aggregator, + batched: true, + }); + } + + // Sequential path: approve then swap (EOA or no approval needed) + let approvalTxHash: Hex | undefined; + if (approvalNeeded && approvalInfo) { + approvalTxHash = await homeCoordinator.sendTransaction({ + from, + to: options.srcToken, + data: approvalInfo.data as Hex, + value: (approvalInfo.value ?? '0x0') as Hex, + }); + } + + try { + const swapTxHash = await homeCoordinator.sendTransaction(swapTx); + + return harden({ + approvalTxHash, + swapTxHash, + sourceAmount: quote.sourceAmount, + destinationAmount: quote.destinationAmount, + aggregator: quote.aggregator, + }); + } catch (error: unknown) { + if (approvalTxHash) { + const message = + error instanceof Error ? error.message : String(error); + throw new Error( + `Swap transaction failed after approval was sent (approval tx: ${approvalTxHash}). ` + + `The token allowance was set but the swap did not complete: ${message}`, + ); + } + throw error; + } + }, + + async waitForUserOpReceipt(options: { + userOpHash: Hex; + pollIntervalMs?: number; + timeoutMs?: number; + }): Promise { + if (!providerVat || !bundlerConfig) { + throw new Error('Provider and bundler must be configured'); + } + + if ( + typeof globalThis.Date?.now !== 'function' || + typeof globalThis.setTimeout !== 'function' + ) { + throw new Error( + 'waitForUserOpReceipt requires Date.now and setTimeout ' + + '(not available in SES compartments without timer endowments)', + ); + } + + const interval = options.pollIntervalMs ?? 2000; + const timeout = options.timeoutMs ?? 60000; + const start = Date.now(); + + while (Date.now() - start < timeout) { + const receipt = await E(providerVat).getUserOpReceipt({ + bundlerUrl: bundlerConfig.bundlerUrl, + userOpHash: options.userOpHash, + }); + if (receipt !== null) { + return receipt; + } + await new Promise((resolve) => setTimeout(resolve, interval)); + } + throw new Error( + `UserOp ${options.userOpHash} not found after ${timeout}ms`, + ); + }, + + /** + * Poll until a regular EIP-1559 transaction is mined (e.g. stateless 7702 + * direct sends). Prefer `waitForUserOpReceipt` for ERC-4337 UserOp hashes. + * + * @param options - Polling options. + * @param options.txHash - Transaction hash to wait for. + * @param options.pollIntervalMs - Delay between RPC polls in milliseconds. + * @param options.timeoutMs - Maximum time to wait in milliseconds. + * @returns Whether the mined transaction succeeded (`status` 0x1). + */ + async waitForTransactionReceipt(options: { + txHash: Hex; + pollIntervalMs?: number; + timeoutMs?: number; + }): Promise<{ success: boolean }> { + return pollTransactionReceipt(options); + }, + + // ------------------------------------------------------------------ + // OcapURL and homeSection + // ------------------------------------------------------------------ + + /** + * Issue an OcapURL that grants the bearer access to this home coordinator. + * + * The away coordinator calls this URL via `connectToPeer` to obtain a + * reference to the home coordinator and then fetches the homeSection. + * + * @returns The OcapURL string. + */ + async issueOcapUrl(): Promise { + if (!issuerService) { + throw new Error('OCAP URL issuer service not available'); + } + return E(issuerService).issue(homeCoordinator); + }, + + /** + * Return the pre-built homeSection exo. + * + * The away coordinator fetches this reference at connectToPeer time and + * stores as the call-home fallback for the away coordinator's routing. + * + * @returns The homeSection exo object. + */ + async getHomeSection(): Promise { + return homeSection; + }, + + // ------------------------------------------------------------------ + // Introspection + // ------------------------------------------------------------------ + + /** + * Return a summary of the home wallet's current capabilities. + * + * Reflects local state only: keys, accounts, external signer, bundler, + * smart account, and grant count from the delegator vat (if available). + * + * @returns A WalletCapabilities object. + */ + async getCapabilities(): Promise { + const hasLocalKeys = keyringVat ? await E(keyringVat).hasKeys() : false; + + const localAccounts: Address[] = keyringVat + ? await E(keyringVat).getAccounts() + : []; + + // Fetch grant count from delegator vat if available + let grantsCount = 0; + if (delegatorVat) { + try { + const grants = await E(delegatorVat).listGrants(); + grantsCount = grants.length; + } catch (error) { + logger.warn('Failed to fetch grants from delegator vat', error); + } + } + + // Resolve signing mode based on available authorities + let signingMode: string = 'none'; + if (externalSigner) { + signingMode = 'external:metamask'; + } else if (hasLocalKeys) { + signingMode = 'local'; + } + + // Determine autonomy level based on smart account and bundler config + let autonomy: string; + const canSendDirectly = + bundlerConfig !== undefined || + (smartAccountConfig?.implementation === 'stateless7702' && + providerVat !== undefined); + if (canSendDirectly) { + autonomy = + grantsCount > 0 + ? `autonomous (${grantsCount} delegation grant(s) issued)` + : 'autonomous (direct smart account)'; + } else if (hasLocalKeys || externalSigner) { + autonomy = 'EOA signing'; + } else { + autonomy = 'no signing authority'; + } + + let capabilityChainId: number | undefined; + try { + capabilityChainId = await resolveChainId(); + } catch (error) { + logger.warn('Failed to resolve chain ID for capabilities', error); + } + + return harden({ + hasLocalKeys, + localAccounts, + delegationCount: grantsCount, + delegations: undefined, + hasPeerWallet: false, + hasExternalSigner: externalSigner !== undefined, + hasBundlerConfig: bundlerConfig !== undefined, + smartAccountAddress: smartAccountConfig?.address, + chainId: capabilityChainId, + signingMode, + autonomy, + peerAccountsCached: false, + cachedPeerAccounts: [], + hasAwayWallet: false, + }); + }, + }); + + return homeCoordinator; +} From 9711ca5e817b2021e1df95c3008430afe6d082b3 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Sun, 19 Apr 2026 12:53:27 -0400 Subject: [PATCH 05/23] feat(evm-wallet): rewrite delegation-twin for semantic grant enforcement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit makeDelegationTwin({ grant, redeemFn }) builds a discoverable exo that enforces the grant's constraints locally before submitting an Execution: - transferNative: validates recipient (eq constraint or any) and amount (lte constraint or any), using M.interface guards - transferFungible: validates token (must match), recipient (optional eq), and tracks cumulative spend with reserve-before-await rollback on error; concurrent calls cannot together exceed the budget DelegationSection is a discriminated union carrying method and (for transferFungible) token — used by away-coordinator routing to filter matching sections and propagate constraint errors correctly. Co-Authored-By: Claude Sonnet 4.6 --- .../src/lib/delegation-twin.test.ts | 414 ++++++++---------- .../src/lib/delegation-twin.ts | 325 +++++--------- 2 files changed, 295 insertions(+), 444 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 184485c712..b0c4074eaf 100644 --- a/packages/evm-wallet-experiment/src/lib/delegation-twin.test.ts +++ b/packages/evm-wallet-experiment/src/lib/delegation-twin.test.ts @@ -1,8 +1,14 @@ import { describe, expect, it, vi } from 'vitest'; -import type { Address, DelegationGrant, Execution, Hex } from '../types.ts'; +import type { + Address, + DelegationGrant, + Execution, + Hex, + TransferFungibleGrant, + TransferNativeGrant, +} from '../types.ts'; import { makeDelegationTwin } from './delegation-twin.ts'; -import { encodeBalanceOf } from './erc20.ts'; let lastInterfaceGuard: unknown; @@ -21,133 +27,169 @@ vi.mock('@metamask/kernel-utils/discoverable', () => ({ }, })); +vi.mock('@metamask/kernel-utils', () => ({ + constant: (value: unknown) => ({ kind: 'constant', value }), +})); + const ALICE = '0x1111111111111111111111111111111111111111' as Address; const BOB = '0x2222222222222222222222222222222222222222' as Address; const TOKEN = '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as Address; const TX_HASH = '0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef' as Hex; -function makeTransferGrant(max: bigint): DelegationGrant { +const BASE_DELEGATION = { + id: '0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890', + delegator: ALICE, + delegate: BOB, + authority: + '0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff' as Hex, + caveats: [], + salt: '0x01' as Hex, + chainId: 11155111, + status: 'signed' as const, +}; + +function makeTransferNativeGrant(opts?: { + to?: Address; + maxAmount?: bigint; +}): TransferNativeGrant { return { - delegation: { - id: '0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890', - delegator: ALICE, - delegate: BOB, - authority: - '0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff' as Hex, - caveats: [], - salt: '0x01' as Hex, - chainId: 11155111, - status: 'signed', - }, - methodName: 'transfer', - caveatSpecs: [{ type: 'cumulativeSpend' as const, token: TOKEN, max }], - token: TOKEN, + method: 'transferNative', + delegation: BASE_DELEGATION, + ...(opts?.to !== undefined && { to: opts.to }), + ...(opts?.maxAmount !== undefined && { maxAmount: opts.maxAmount }), }; } -function makeCallGrant(): DelegationGrant { +function makeTransferFungibleGrant(opts?: { + to?: Address; + maxAmount?: bigint; +}): TransferFungibleGrant { return { - delegation: { - id: '0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890', - delegator: ALICE, - delegate: BOB, - authority: - '0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff' as Hex, - caveats: [], - salt: '0x01' as Hex, - chainId: 11155111, - status: 'signed', - }, - methodName: 'call', - caveatSpecs: [], + method: 'transferFungible', + token: TOKEN, + delegation: BASE_DELEGATION, + ...(opts?.to !== undefined && { to: opts.to }), + ...(opts?.maxAmount !== undefined && { maxAmount: opts.maxAmount }), }; } describe('makeDelegationTwin', () => { - describe('transfer twin', () => { - it('exposes transfer method', () => { + describe('transferNative twin', () => { + it('exposes transferNative method', () => { const redeemFn = vi.fn().mockResolvedValue(TX_HASH); - const twin = makeDelegationTwin({ - grant: makeTransferGrant(10000n), + const section = makeDelegationTwin({ + grant: makeTransferNativeGrant({ maxAmount: 10000n }), redeemFn, - }) as Record; - expect(twin).toHaveProperty('transfer'); - expect(typeof twin.transfer).toBe('function'); + }); + expect( + typeof (section.exo as Record).transferNative, + ).toBe('function'); }); it('builds correct Execution and calls redeemFn', async () => { const redeemFn = vi.fn().mockResolvedValue(TX_HASH); - const twin = makeDelegationTwin({ - grant: makeTransferGrant(10000n), + const section = makeDelegationTwin({ + grant: makeTransferNativeGrant({ maxAmount: 10000n }), redeemFn, - }) as Record Promise>; + }); + const exo = section.exo as Record< + string, + (...args: unknown[]) => Promise + >; - const result = await twin.transfer(BOB, 100n); + const result = await exo.transferNative(BOB, 100n); expect(result).toBe(TX_HASH); expect(redeemFn).toHaveBeenCalledOnce(); const execution = redeemFn.mock.calls[0]?.[0] as Execution; - expect(execution.target).toBe(TOKEN); - expect(execution.value).toBe('0x0'); + expect(execution.target).toBe(BOB); + expect(execution.value).toBe('0x64'); + expect(execution.callData).toBe('0x'); }); - it('returns tx hash from redeemFn', async () => { + it('rejects when maxAmount exceeded', async () => { const redeemFn = vi.fn().mockResolvedValue(TX_HASH); - const twin = makeDelegationTwin({ - grant: makeTransferGrant(10000n), + const section = makeDelegationTwin({ + grant: makeTransferNativeGrant({ maxAmount: 100n }), redeemFn, - }) as Record Promise>; + }); + const exo = section.exo as Record< + string, + (...args: unknown[]) => Promise + >; - const hash = await twin.transfer(BOB, 50n); - expect(hash).toBe(TX_HASH); + await expect(exo.transferNative(BOB, 101n)).rejects.toThrow( + /exceeds limit/u, + ); }); - it('tracks cumulative spend across calls', async () => { - const redeemFn = vi.fn().mockResolvedValue(TX_HASH); - const twin = makeDelegationTwin({ - grant: makeTransferGrant(1000n), + it('attaches constant delegation metadata', () => { + const redeemFn = vi.fn(); + const section = makeDelegationTwin({ + grant: makeTransferNativeGrant(), redeemFn, - }) as Record Promise>; - - await twin.transfer(BOB, 600n); - await twin.transfer(BOB, 300n); - await expect(twin.transfer(BOB, 200n)).rejects.toThrow( - /Insufficient budget/u, - ); + }); + expect(section.metadata).toStrictEqual({ + kind: 'constant', + value: { + mode: 'delegation', + delegationId: BASE_DELEGATION.id, + }, + }); }); + }); - it('rejects call when budget exhausted', async () => { + describe('transferFungible twin', () => { + it('exposes transferFungible method', () => { const redeemFn = vi.fn().mockResolvedValue(TX_HASH); - const twin = makeDelegationTwin({ - grant: makeTransferGrant(100n), + const section = makeDelegationTwin({ + grant: makeTransferFungibleGrant({ maxAmount: 10000n }), redeemFn, - }) as Record Promise>; + }); + expect( + typeof (section.exo as Record).transferFungible, + ).toBe('function'); + }); - await twin.transfer(BOB, 100n); - await expect(twin.transfer(BOB, 1n)).rejects.toThrow( + it('tracks cumulative spend across calls', async () => { + const redeemFn = vi.fn().mockResolvedValue(TX_HASH); + const section = makeDelegationTwin({ + grant: makeTransferFungibleGrant({ maxAmount: 1000n }), + redeemFn, + }); + const exo = section.exo as Record< + string, + (...args: unknown[]) => Promise + >; + + await exo.transferFungible(TOKEN, BOB, 600n); + await exo.transferFungible(TOKEN, BOB, 300n); + await expect(exo.transferFungible(TOKEN, BOB, 200n)).rejects.toThrow( /Insufficient budget/u, ); }); it('does not commit on redeemFn failure', async () => { const redeemFn = vi.fn().mockRejectedValue(new Error('tx reverted')); - const twin = makeDelegationTwin({ - grant: makeTransferGrant(1000n), + const section = makeDelegationTwin({ + grant: makeTransferFungibleGrant({ maxAmount: 1000n }), redeemFn, - }) as Record Promise>; + }); + const exo = section.exo as Record< + string, + (...args: unknown[]) => Promise + >; - await expect(twin.transfer(BOB, 500n)).rejects.toThrow('tx reverted'); + await expect(exo.transferFungible(TOKEN, BOB, 500n)).rejects.toThrow( + 'tx reverted', + ); redeemFn.mockResolvedValue(TX_HASH); - const result = await twin.transfer(BOB, 1000n); + const result = await exo.transferFungible(TOKEN, BOB, 1000n); expect(result).toBe(TX_HASH); }); it('does not allow concurrent calls to exceed the budget', async () => { - // Two calls issued concurrently both pass the budget check before either - // commits — unless the budget is reserved before the await. With a max of - // 5 and two concurrent calls for 3, the second must be rejected even - // though the first hasn't settled yet. let resolveFirst!: (hash: Hex) => void; const redeemFn = vi .fn() @@ -159,203 +201,109 @@ describe('makeDelegationTwin', () => { ) .mockResolvedValue(TX_HASH); - const twin = makeDelegationTwin({ - grant: makeTransferGrant(5n), + const section = makeDelegationTwin({ + grant: makeTransferFungibleGrant({ maxAmount: 5n }), redeemFn, - }) as Record Promise>; + }); + const exo = section.exo as Record< + string, + (...args: unknown[]) => Promise + >; - const first = twin.transfer(BOB, 3n); - // Second call issued before first resolves — must see only 2 remaining. - await expect(twin.transfer(BOB, 3n)).rejects.toThrow( + const first = exo.transferFungible(TOKEN, BOB, 3n); + await expect(exo.transferFungible(TOKEN, BOB, 3n)).rejects.toThrow( 'Insufficient budget', ); resolveFirst(TX_HASH); expect(await first).toBe(TX_HASH); }); - }); - describe('discoverability', () => { - it('returns method schemas from __getDescription__', () => { + it('has no maxAmount limit when not specified', async () => { const redeemFn = vi.fn().mockResolvedValue(TX_HASH); - const twin = makeDelegationTwin({ - grant: makeTransferGrant(1000n), + const section = makeDelegationTwin({ + grant: makeTransferFungibleGrant(), redeemFn, - }) as Record; + }); + const exo = section.exo as Record< + string, + (...args: unknown[]) => Promise + >; - const desc = (twin.__getDescription__ as () => Record)(); - expect(desc).toHaveProperty('transfer'); - expect( - (desc.transfer as Record).description, - ).toBeDefined(); + // Very large amount — should succeed (no cap enforced locally) + await exo.transferFungible(TOKEN, BOB, 10n ** 30n); + expect(redeemFn).toHaveBeenCalledOnce(); }); }); - describe('getBalance', () => { - it('is present when readFn provided', () => { + describe('discoverability', () => { + it('returns method schemas from __getDescription__ for transferNative', () => { const redeemFn = vi.fn().mockResolvedValue(TX_HASH); - const readFn = vi - .fn() - .mockResolvedValue( - '0x00000000000000000000000000000000000000000000000000000000000f4240' as Hex, - ); - const twin = makeDelegationTwin({ - grant: makeTransferGrant(1000n), + const section = makeDelegationTwin({ + grant: makeTransferNativeGrant({ maxAmount: 1000n }), redeemFn, - readFn, - }) as Record; - expect(twin).toHaveProperty('getBalance'); - }); + }); + const exo = section.exo as Record; - it('is absent when readFn not provided', () => { - const redeemFn = vi.fn().mockResolvedValue(TX_HASH); - const twin = makeDelegationTwin({ - grant: makeTransferGrant(1000n), - redeemFn, - }) as Record; - expect(twin).not.toHaveProperty('getBalance'); + const desc = (exo.__getDescription__ as () => Record)(); + expect(desc).toHaveProperty('transferNative'); + expect( + (desc.transferNative as Record).description, + ).toBeDefined(); }); - it('calls readFn with correct args and decodes result', async () => { + it('returns method schemas from __getDescription__ for transferFungible', () => { const redeemFn = vi.fn().mockResolvedValue(TX_HASH); - const readFn = vi - .fn() - .mockResolvedValue( - '0x00000000000000000000000000000000000000000000000000000000000f4240' as Hex, - ); - const twin = makeDelegationTwin({ - grant: makeTransferGrant(1000n), + const section = makeDelegationTwin({ + grant: makeTransferFungibleGrant({ maxAmount: 1000n }), redeemFn, - readFn, - }) as Record Promise>; - - const balance = await twin.getBalance(); - expect(balance).toBe(1000000n); - expect(readFn).toHaveBeenCalledWith({ - to: TOKEN, - data: encodeBalanceOf(ALICE), }); - }); - }); + const exo = section.exo as Record; - describe('call twin', () => { - it('builds raw execution from args', async () => { - const redeemFn = vi.fn().mockResolvedValue(TX_HASH); - const target = '0x3333333333333333333333333333333333333333' as Address; - const twin = makeDelegationTwin({ - grant: makeCallGrant(), - redeemFn, - }) as Record Promise>; - - await twin.call(target, 0n, '0xdeadbeef' as Hex); - expect(redeemFn).toHaveBeenCalledOnce(); - const execution = redeemFn.mock.calls[0]?.[0] as Execution; - expect(execution.target).toBe(target); - expect(execution.callData).toBe('0xdeadbeef'); + const desc = (exo.__getDescription__ as () => Record)(); + expect(desc).toHaveProperty('transferFungible'); + expect( + (desc.transferFungible as Record).description, + ).toBeDefined(); }); }); describe('interfaceGuard', () => { - it('passes an InterfaceGuard to makeDiscoverableExo', () => { + it('passes an InterfaceGuard to makeDiscoverableExo for transferNative', () => { const redeemFn = vi.fn().mockResolvedValue(TX_HASH); makeDelegationTwin({ - grant: makeTransferGrant(1000n), + grant: makeTransferNativeGrant({ maxAmount: 1000n }), redeemFn, }); expect(lastInterfaceGuard).toBeDefined(); }); - it('guard covers the primary method', () => { + it('passes an InterfaceGuard to makeDiscoverableExo for transferFungible', () => { const redeemFn = vi.fn().mockResolvedValue(TX_HASH); makeDelegationTwin({ - grant: makeTransferGrant(1000n), + grant: makeTransferFungibleGrant({ maxAmount: 1000n }), redeemFn, }); - const guard = lastInterfaceGuard as { - payload: { methodGuards: Record }; - }; - expect(guard.payload.methodGuards).toHaveProperty('transfer'); + expect(lastInterfaceGuard).toBeDefined(); }); + }); - it('guard includes getBalance when readFn provided', () => { - const redeemFn = vi.fn().mockResolvedValue(TX_HASH); - const readFn = vi - .fn() - .mockResolvedValue( - '0x00000000000000000000000000000000000000000000000000000000000f4240' as Hex, + describe('grant type narrowing', () => { + it.each([ + ['transferNative', makeTransferNativeGrant(), 'transferNative'] as const, + [ + 'transferFungible', + makeTransferFungibleGrant(), + 'transferFungible', + ] as const, + ])( + 'builds a %s twin exposing the %s method', + (_label, grant: DelegationGrant, method) => { + const redeemFn = vi.fn().mockResolvedValue(TX_HASH); + const section = makeDelegationTwin({ grant, redeemFn }); + expect(typeof (section.exo as Record)[method]).toBe( + 'function', ); - makeDelegationTwin({ - grant: makeTransferGrant(1000n), - redeemFn, - readFn, - }); - const guard = lastInterfaceGuard as { - payload: { methodGuards: Record }; - }; - expect(guard.payload.methodGuards).toHaveProperty('transfer'); - expect(guard.payload.methodGuards).toHaveProperty('getBalance'); - }); - - it('guard does not include getBalance when readFn absent', () => { - const redeemFn = vi.fn().mockResolvedValue(TX_HASH); - makeDelegationTwin({ - grant: makeTransferGrant(1000n), - redeemFn, - }); - const guard = lastInterfaceGuard as { - payload: { methodGuards: Record }; - }; - expect(guard.payload.methodGuards).not.toHaveProperty('getBalance'); - }); - - it('uses generic string guard for address arg by default', () => { - const redeemFn = vi.fn().mockResolvedValue(TX_HASH); - makeDelegationTwin({ - grant: makeTransferGrant(1000n), - redeemFn, - }); - const guard = lastInterfaceGuard as { - payload: { - methodGuards: Record; - }; - }; - const argGuard = guard.payload.methodGuards.transfer.payload.argGuards[0]; - expect(typeof argGuard).not.toBe('string'); - }); - - it('restricts address arg to literal when allowedCalldata caveat present', () => { - const redeemFn = vi.fn().mockResolvedValue(TX_HASH); - const grant = makeTransferGrant(1000n); - grant.caveatSpecs.push({ - type: 'allowedCalldata' as const, - dataStart: 4, - value: `0x${BOB.slice(2).padStart(64, '0')}`, - }); - makeDelegationTwin({ grant, redeemFn }); - const guard = lastInterfaceGuard as { - payload: { - methodGuards: Record; - }; - }; - const argGuard = guard.payload.methodGuards.transfer.payload.argGuards[0]; - expect(argGuard).toBe(BOB); - }); - - it('does not restrict address arg for call method', () => { - const redeemFn = vi.fn().mockResolvedValue(TX_HASH); - const grant = makeCallGrant(); - grant.caveatSpecs.push({ - type: 'allowedCalldata' as const, - dataStart: 4, - value: `0x${BOB.slice(2).padStart(64, '0')}`, - }); - makeDelegationTwin({ grant, redeemFn }); - const guard = lastInterfaceGuard as { - payload: { - methodGuards: Record; - }; - }; - const argGuard = guard.payload.methodGuards.call.payload.argGuards[0]; - expect(typeof argGuard).not.toBe('string'); - }); + }, + ); }); }); diff --git a/packages/evm-wallet-experiment/src/lib/delegation-twin.ts b/packages/evm-wallet-experiment/src/lib/delegation-twin.ts index e8ebf12271..d9255cef72 100644 --- a/packages/evm-wallet-experiment/src/lib/delegation-twin.ts +++ b/packages/evm-wallet-experiment/src/lib/delegation-twin.ts @@ -1,240 +1,143 @@ import { M } from '@endo/patterns'; -import type { MethodSchema } from '@metamask/kernel-utils'; import { makeDiscoverableExo } from '@metamask/kernel-utils/discoverable'; -import { - decodeBalanceOfResult, - encodeBalanceOf, - FIRST_ARG_OFFSET, -} from './erc20.ts'; -import { GET_BALANCE_SCHEMA, METHOD_CATALOG } from './method-catalog.ts'; -import type { CatalogMethodName } from './method-catalog.ts'; -import type { - Address, - CaveatSpec, - DelegationGrant, - Execution, - Hex, -} from '../types.ts'; - -const METHODS_WITH_ADDRESS_ARG: ReadonlySet = new Set([ - 'transfer', - 'approve', -]); +import { encodeTransfer } from './erc20.ts'; +import { METHOD_CATALOG } from './method-catalog.ts'; +import type { Address, DelegationGrant, Execution, Hex } from '../types.ts'; -/** - * Extract a restricted address from an `allowedCalldata` caveat spec that - * pins the first argument (offset 4, 32-byte ABI-encoded address). - * - * @param caveatSpecs - The caveat specs to search. - * @returns The restricted address, or undefined if none found. - */ -function findRestrictedAddress(caveatSpecs: CaveatSpec[]): Address | undefined { - const spec = caveatSpecs.find( - (cs): cs is CaveatSpec & { type: 'allowedCalldata' } => - cs.type === 'allowedCalldata' && cs.dataStart === FIRST_ARG_OFFSET, - ); - if (!spec) { - return undefined; - } - // The value is a 32-byte ABI-encoded address; extract the last 40 hex chars. - return `0x${spec.value.slice(-40)}`; -} - -/** - * Build the method guard for a catalog method, optionally restricting the - * first (address) argument to a single literal value. - * - * @param methodName - The catalog method name. - * @param restrictAddress - If provided, lock the first arg to this literal. - * @returns A method guard for use in an InterfaceGuard. - */ -function buildMethodGuard( - methodName: CatalogMethodName, - restrictAddress?: Address, -): ReturnType { - const addrGuard = - restrictAddress !== undefined && METHODS_WITH_ADDRESS_ARG.has(methodName) - ? restrictAddress - : M.string(); - - switch (methodName) { - case 'transfer': - case 'approve': - return M.callWhen(addrGuard, M.scalar()).returns(M.string()); - case 'call': - return M.callWhen(M.string(), M.scalar(), M.string()).returns(M.string()); - default: - throw new Error(`Unknown catalog method: ${String(methodName)}`); - } -} - -type SpendTracker = { - spent: bigint; - max: bigint; - remaining: () => bigint; - commit: (amount: bigint) => void; - rollback: (amount: bigint) => void; -}; - -/** - * Create a spend tracker for a cumulative-spend caveat spec. - * - * @param spec - The cumulative-spend caveat spec. - * @returns A spend tracker with commit/rollback semantics. - */ -function makeSpendTracker( - spec: CaveatSpec & { type: 'cumulativeSpend' }, -): SpendTracker { - let spent = 0n; - return { - get spent() { - return spent; - }, - max: spec.max, - remaining: () => spec.max - spent, - commit: (amount: bigint) => { - spent += amount; - }, - rollback: (amount: bigint) => { - spent -= amount; - }, - }; -} - -/** - * Find and create a spend tracker from a list of caveat specs. - * - * @param caveatSpecs - The caveat specs to search. - * @returns A spend tracker if a cumulative-spend spec is found. - */ -function findSpendTracker(caveatSpecs: CaveatSpec[]): SpendTracker | undefined { - const spec = caveatSpecs.find( - (cs): cs is CaveatSpec & { type: 'cumulativeSpend' } => - cs.type === 'cumulativeSpend', - ); - return spec ? makeSpendTracker(spec) : undefined; -} +export type DelegationSection = + | { exo: object; method: 'transferNative' } + | { exo: object; method: 'transferFungible'; token: Address }; type DelegationTwinOptions = { grant: DelegationGrant; redeemFn: (execution: Execution) => Promise; - readFn?: (opts: { to: Address; data: Hex }) => Promise; }; /** - * Create a discoverable exo twin for a delegation grant. + * Build a DelegationSection 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 delegation grant to wrap. - * @param options.redeemFn - Function to redeem a delegation execution. - * @param options.readFn - Optional function for read-only calls. - * @returns A discoverable exo with delegation methods. + * @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. */ export function makeDelegationTwin( options: DelegationTwinOptions, -): ReturnType { - const { grant, redeemFn, readFn } = options; - const { methodName, caveatSpecs, delegation } = grant; - - const entry = METHOD_CATALOG[methodName as CatalogMethodName]; - if (!entry) { - throw new Error(`Unknown method in grant: ${methodName}`); - } - - const tracker = findSpendTracker(caveatSpecs); - const valueLteSpec = caveatSpecs.find( - (cs): cs is CaveatSpec & { type: 'valueLte' } => cs.type === 'valueLte', - ); - const { token } = grant; +): DelegationSection { + const { grant, redeemFn } = options; + const { delegation } = grant; const idPrefix = delegation.id.slice(0, 12); - const primaryMethod = async (...args: unknown[]): Promise => { - // Coerce args[1] (amount/value) to BigInt for transfer, approve, and call - // — necessary when args arrive as strings over the daemon JSON-RPC boundary. - const normalizedArgs = - args.length > 1 - ? [ - args[0], - BigInt(args[1] as string | number | bigint), - ...args.slice(2), - ] - : args; - - // Local valueLte check for call twins (mirrors on-chain ValueLteEnforcer). - if (valueLteSpec !== undefined) { - const value = normalizedArgs[1] as bigint; - if (value > valueLteSpec.max) { - throw new Error(`Value ${value} exceeds limit ${valueLteSpec.max}`); - } - } - - let trackAmount: bigint | undefined; - if (tracker) { - trackAmount = normalizedArgs[1] as bigint; - if (trackAmount > tracker.remaining()) { - throw new Error( - `Insufficient budget: requested ${trackAmount}, remaining ${tracker.remaining()}`, - ); - } - // Reserve before the await so concurrent calls see the updated budget - // and cannot both pass the check. Roll back if redeemFn fails. - tracker.commit(trackAmount); - } - - const execution = entry.buildExecution( - token ?? ('' as Address), - normalizedArgs, + if (grant.method === 'transferNative') { + const { to } = grant; + // maxAmount may arrive as a string when the grant crosses a JSON boundary + // (e.g. CLI args or test helpers). Normalize to bigint so M.lte() and the + // method body comparison work correctly regardless of the source. + const maxAmount = + grant.maxAmount === undefined ? undefined : BigInt(grant.maxAmount); + + const toGuard = to === undefined ? M.string() : M.eq(to); + const amountGuard = maxAmount === undefined ? M.bigint() : M.lte(maxAmount); + + const interfaceGuard = M.interface( + `DelegationTwin:transferNative:${idPrefix}`, + { + transferNative: M.callWhen(toGuard, amountGuard).returns(M.string()), + }, + { defaultGuards: 'passable' }, + ); + + const exo = makeDiscoverableExo( + `DelegationTwin:transferNative:${idPrefix}`, + { + async transferNative( + recipient: Address, + amount: string | number | bigint, + ): Promise { + const amt = BigInt(amount); + + if (maxAmount !== undefined && amt > maxAmount) { + throw new Error(`Amount ${amt} exceeds limit ${maxAmount}`); + } + + const execution: Execution = { + target: recipient, + value: `0x${amt.toString(16)}`, + callData: '0x' as Hex, + }; + + return redeemFn(execution); + }, + }, + { transferNative: METHOD_CATALOG.transferNative }, + interfaceGuard, ); - try { - return await redeemFn(execution); - } catch (error) { - if (tracker && trackAmount !== undefined) { - tracker.rollback(trackAmount); - } - throw error; - } - }; - - const methods: Record unknown> = { - [methodName]: primaryMethod, - }; - const schema: Record = { - [methodName]: entry.schema, - }; - - const restrictedAddress = findRestrictedAddress(caveatSpecs); - - const methodGuards: Record> = { - [methodName]: buildMethodGuard( - methodName as CatalogMethodName, - restrictedAddress, - ), - }; - - if (readFn && token) { - methods.getBalance = async (): Promise => { - const result = await readFn({ - to: token, - data: encodeBalanceOf(delegation.delegator), - }); - return decodeBalanceOfResult(result); - }; - schema.getBalance = GET_BALANCE_SCHEMA; - methodGuards.getBalance = M.callWhen().returns(M.bigint()); + return { exo, method: 'transferNative' }; } + // transferFungible + const { token, to } = grant; + // maxAmount may arrive as a string when the grant crosses a JSON boundary. + // Normalize to bigint so arithmetic and M.lte comparisons work correctly. + const maxAmount = + grant.maxAmount === undefined ? undefined : BigInt(grant.maxAmount); + + let spent = 0n; + const max = maxAmount ?? 2n ** 256n - 1n; + + const toGuard = to === undefined ? M.string() : M.eq(to); + const interfaceGuard = M.interface( - `DelegationTwin:${methodName}:${idPrefix}`, - methodGuards, + `DelegationTwin:transferFungible:${idPrefix}`, + { + transferFungible: M.callWhen(M.eq(token), toGuard, M.bigint()).returns( + M.string(), + ), + }, { defaultGuards: 'passable' }, ); - return makeDiscoverableExo( - `DelegationTwin:${methodName}:${idPrefix}`, - methods, - schema, + const exo = makeDiscoverableExo( + `DelegationTwin:transferFungible:${idPrefix}`, + { + async transferFungible( + tokenAddress: Address, + recipient: Address, + amount: string | number | bigint, + ): Promise { + const amt = BigInt(amount); + + if (amt > max - spent) { + throw new Error( + `Insufficient budget: requested ${amt}, remaining ${max - spent}`, + ); + } + + // Reserve before the await so concurrent calls see the updated budget. + spent += amt; + + const execution: Execution = { + target: tokenAddress, + value: '0x0' as Hex, + callData: encodeTransfer(recipient, amt), + }; + + try { + return await redeemFn(execution); + } catch (error) { + // Roll back on redeemFn failure. + spent -= amt; + throw error; + } + }, + }, + { transferFungible: METHOD_CATALOG.transferFungible }, interfaceGuard, ); + + return { exo, method: 'transferFungible', token }; } From d7c9162b26acbb8711a43f10479922a10f420575 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Sun, 19 Apr 2026 12:56:31 -0400 Subject: [PATCH 06/23] feat(evm-wallet): add away-coordinator with delegation routing and peer-relay support Away coordinator manages the wallet API on the agent (away) side. Shares infrastructure with home (provider, smart account, tx submission) and routes semantic calls through delegation twins before falling back to the home section. Key design: - receiveDelegation(grant): stores in redeemer-vat, rebuilds delegation sections from all stored grants via makeDelegationTwin - transferNative / transferFungible: filter delegation sections by method and token; if any matched sections exist, try them and propagate errors (constraint violations are not swallowed); fall back to homeSection only if no sections match - makeRedeemFn: submits delegation UserOp locally when bundler/smart account is available; relays to homeCoordRef.redeemDelegation() in peer-relay mode (away has no bundler; home's smart account is delegate) - connectToPeer(ocapUrl): redeems URL, fetches homeSection, rebuilds routing - initializeKeyring / unlockKeyring / isKeyringLocked, signMessage, signTypedData, listGrants, refreshPeerAccounts, sendDelegateAddressToPeer: peer setup and key management Co-Authored-By: Claude Sonnet 4.6 --- .../src/vats/away-coordinator.ts | 2012 +++++++++++++++++ .../src/vats/home-coordinator.ts | 39 +- 2 files changed, 2049 insertions(+), 2 deletions(-) create mode 100644 packages/evm-wallet-experiment/src/vats/away-coordinator.ts diff --git a/packages/evm-wallet-experiment/src/vats/away-coordinator.ts b/packages/evm-wallet-experiment/src/vats/away-coordinator.ts new file mode 100644 index 0000000000..5ab2e0dd6f --- /dev/null +++ b/packages/evm-wallet-experiment/src/vats/away-coordinator.ts @@ -0,0 +1,2012 @@ +import { E } from '@endo/eventual-send'; +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, + decodeDecimalsResult, + decodeNameResult, + decodeSymbolResult, + encodeBalanceOf, + encodeDecimals, + encodeName, + encodeSymbol, +} from '../lib/erc20.ts'; +import { + registerEnvironment, + resolveEnvironment, + buildSdkRedeemCallData, + isEip7702Delegated, + prepareUserOpTypedData, + setSdkLogger, + computeSmartAccountAddress, +} from '../lib/sdk.ts'; +import { ENTRY_POINT_V07 } from '../lib/userop.ts'; +import type { + Address, + ChainConfig, + Delegation, + DelegationGrant, + Eip712TypedData, + Execution, + Hex, + SmartAccountConfig, + TransactionRequest, + UserOperation, + WalletCapabilities, +} from '../types.ts'; + +const harden = globalThis.harden ?? ((value: T): T => value); + +// --------------------------------------------------------------------------- +// Helper functions +// --------------------------------------------------------------------------- + +/** + * Apply a percentage buffer to a hex gas value. + * + * @param gasHex - The gas value as a hex string. + * @param bufferPercent - The buffer percentage to add (e.g. 10 for 10%). + * @returns The buffered gas value as a hex string. + */ +function applyGasBuffer(gasHex: Hex, bufferPercent: number): Hex { + const gas = BigInt(gasHex); + const buffered = gas + (gas * BigInt(bufferPercent)) / 100n; + return `0x${buffered.toString(16)}`; +} + +/** + * Validate that an `eth_estimateGas` response is a valid hex string. + * + * @param result - The raw RPC response. + * @returns The validated hex string. + * @throws If the result is not a hex string. + */ +function validateGasEstimate(result: unknown): Hex { + if (typeof result !== 'string' || !result.startsWith('0x')) { + throw new Error( + `eth_estimateGas returned unexpected value: ${String(result)}`, + ); + } + return result as Hex; +} + +/** + * Validate that a token `eth_call` response is a usable hex string. + * + * @param result - The raw RPC response. + * @param method - The ERC-20 method name (for error context). + * @param token - The token address (for error context). + * @returns The validated hex string. + * @throws If the result is not a non-empty hex string. + */ +function validateTokenCallResult( + result: unknown, + method: string, + token: Address, +): Hex { + if ( + typeof result !== 'string' || + !result.startsWith('0x') || + result === '0x' + ) { + throw new Error( + `${method}() call to token ${token} returned unexpected value: ${String(result)}`, + ); + } + return result as Hex; +} + +/** + * Convert a wei amount in hex to a human-readable ETH string. + * + * @param weiHex - The wei amount as a hex string. + * @returns A formatted string like "1.5 ETH". + */ +function weiToEth(weiHex: string): string { + const wei = BigInt(weiHex); + const whole = wei / 10n ** 18n; + const frac = wei % 10n ** 18n; + if (frac === 0n) { + return `${String(whole)} ETH`; + } + const fracStr = frac.toString().padStart(18, '0').replace(/0+$/u, ''); + return `${String(whole)}.${fracStr} ETH`; +} + +// --------------------------------------------------------------------------- +// Vat powers and wiring types +// --------------------------------------------------------------------------- + +/** + * Vat powers for the away coordinator vat. + */ +type VatPowers = { + logger?: Logger; +}; + +/** + * Vat references available in the away wallet subcluster. + */ +type WalletVats = { + keyring?: unknown; + provider?: unknown; + redeemer?: unknown; +}; + +/** + * Services available to the away wallet subcluster. + */ +type WalletServices = { + ocapURLRedemptionService?: unknown; +}; + +// --------------------------------------------------------------------------- +// Typed facets for E() calls (no `any` — explicit method signatures) +// --------------------------------------------------------------------------- + +type KeyringFacet = { + initialize: ( + options: { type: string; mnemonic?: string }, + password?: string, + salt?: string, + ) => Promise; + unlock: (password: string) => Promise; + isLocked: () => Promise; + hasKeys: () => Promise; + getAccounts: () => Promise; + deriveAccount: (index: number) => Promise
; + signTransaction: (tx: TransactionRequest) => Promise; + signTypedData: (data: Eip712TypedData, from?: Address) => Promise; + signMessage: (message: string, from?: Address) => Promise; + signHash: (hash: Hex, from?: Address) => Promise; + signAuthorization: (options: { + contractAddress: Address; + chainId: number; + nonce?: number; + from?: Address; + }) => Promise; +}; + +type ProviderFacet = { + configure: (config: ChainConfig) => Promise; + request: (method: string, params?: unknown[]) => Promise; + broadcastTransaction: (signedTx: Hex) => Promise; + getChainId: () => Promise; + getNonce: (address: Address) => Promise; + getEntryPointNonce: (options: { + entryPoint: Address; + sender: Address; + key?: Hex; + }) => Promise; + submitUserOp: (options: { + bundlerUrl: string; + entryPoint: Hex; + userOp: UserOperation; + }) => Promise; + estimateUserOpGas: (options: { + bundlerUrl: string; + entryPoint: Hex; + userOp: UserOperation; + }) => Promise<{ + callGasLimit: Hex; + verificationGasLimit: Hex; + preVerificationGas: Hex; + }>; + getUserOpReceipt: (options: { + bundlerUrl: string; + userOpHash: Hex; + }) => Promise; + getGasFees: () => Promise<{ + maxFeePerGas: Hex; + maxPriorityFeePerGas: Hex; + }>; + configureBundler: (config: { + bundlerUrl: string; + chainId: number; + }) => Promise; + httpGetJson: (url: string) => Promise; + getUserOperationGasPrice: () => Promise<{ + fast: { maxFeePerGas: Hex; maxPriorityFeePerGas: Hex }; + }>; + sponsorUserOp: (options: { + bundlerUrl: string; + entryPoint: Hex; + userOp: UserOperation; + context?: Record; + }) => Promise<{ + paymaster: Address; + paymasterData: Hex; + paymasterVerificationGasLimit: Hex; + paymasterPostOpGasLimit: Hex; + callGasLimit: Hex; + verificationGasLimit: Hex; + preVerificationGas: Hex; + }>; +}; + +type ExternalSignerFacet = { + getAccounts: () => Promise; + signTypedData: (data: Eip712TypedData, from: Address) => Promise; + signMessage: (message: string, from: Address) => Promise; + signTransaction: (tx: TransactionRequest) => Promise; +}; + +type RedeemerFacet = { + receiveGrant: (grant: DelegationGrant) => Promise; + removeGrant: (id: string) => Promise; + listGrants: () => Promise; +}; + +type OcapURLRedemptionFacet = { + redeem: (url: string) => Promise; +}; + +// --------------------------------------------------------------------------- +// buildRootObject +// --------------------------------------------------------------------------- + +/** + * 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. + * + * @param vatPowers - Special powers granted to this vat. + * @param _parameters - Initialization parameters (role: 'away'). + * @param baggage - Root of vat's persistent state. + * @returns The root object for the away coordinator vat. + */ +export function buildRootObject( + vatPowers: VatPowers, + _parameters: unknown, + baggage: Baggage, +): object { + const logger = (vatPowers.logger ?? new Logger()).subLogger({ + tags: ['away-coordinator'], + }); + + // Wire SDK logger so resolveEnvironment/registerEnvironment are visible + setSdkLogger((level, message, data) => { + if (level === 'info') { + logger.info(message, data); + } else { + logger.debug(message, data); + } + }); + + // ------------------------------------------------------------------------- + // State variables + // ------------------------------------------------------------------------- + + // References to other vats (set during bootstrap) + let keyringVat: KeyringFacet | undefined; + let providerVat: ProviderFacet | undefined; + let redeemerVat: RedeemerFacet | undefined; + + // External signer reference (e.g. MetaMask). + // Note: external signers are transient — they must be reconnected after + // kernel restart via connectExternalSigner(). The baggage entry tracks + // the reference but it may be stale after resuscitation. + let externalSigner: ExternalSignerFacet | undefined; + + // Bundler configuration for ERC-4337 UserOps + let bundlerConfig: + | { + bundlerUrl: string; + entryPoint: Hex; + chainId: number; + usePaymaster?: boolean; + sponsorshipPolicyId?: string; + environment?: { + EntryPoint: Hex; + DelegationManager: Hex; + SimpleFactory: Hex; + implementations: Record; + caveatEnforcers: Record; + }; + } + | undefined; + + // Smart account configuration (persisted in baggage) + let smartAccountConfig: SmartAccountConfig | undefined; + + // OCAP URL redemption service (wired from services in bootstrap) + let redemptionService: OcapURLRedemptionFacet | undefined; + + // Routing state + 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[] = []; + + // ------------------------------------------------------------------------- + // Baggage helpers + // ------------------------------------------------------------------------- + + /** + * Typed helper for restoring values from baggage (resuscitation). + * + * @param key - The baggage key to look up. + * @returns The stored value cast to T, or undefined if not present. + */ + function restoreFromBaggage(key: string): T | undefined { + return baggage.has(key) ? (baggage.get(key) as T) : undefined; + } + + /** + * Persist a baggage key-value pair, handling both init and update. + * + * @param key - The baggage key. + * @param value - The value to persist. + */ + function persistBaggage(key: string, value: unknown): void { + if (baggage.has(key)) { + baggage.set(key, value); + } else { + baggage.init(key, value); + } + } + + // ------------------------------------------------------------------------- + // Restore state from baggage (resuscitation) + // ------------------------------------------------------------------------- + + keyringVat = restoreFromBaggage('keyringVat'); + providerVat = restoreFromBaggage('providerVat'); + redeemerVat = restoreFromBaggage('redeemerVat'); + externalSigner = restoreFromBaggage('externalSigner'); + bundlerConfig = restoreFromBaggage('bundlerConfig'); + if (bundlerConfig?.environment) { + registerEnvironment(bundlerConfig.chainId, bundlerConfig.environment); + } + smartAccountConfig = + restoreFromBaggage('smartAccountConfig'); + homeSection = restoreFromBaggage('homeSection'); + + /** Chain ID from the last `configureProvider` call (avoids RPC on every send). */ + let cachedProviderChainId: number | undefined = restoreFromBaggage( + 'cachedProviderChainId', + ); + + // ------------------------------------------------------------------------- + // Internal async helpers + // ------------------------------------------------------------------------- + + /** + * Resolve the wallet chain ID for SDK addresses and txs. + * + * Order: bundler config → cached provider config → `eth_chainId` RPC. + * + * @returns The resolved chain ID. + */ + async function resolveChainId(): Promise { + if (bundlerConfig?.chainId !== undefined) { + return bundlerConfig.chainId; + } + if (cachedProviderChainId !== undefined) { + return cachedProviderChainId; + } + if (!providerVat) { + throw new Error( + 'Provider not configured — call configureProvider() first', + ); + } + return E(providerVat).getChainId(); + } + + /** + * Whether smart-account operations for this sender should use Infura-style + * raw transactions (stateless 7702) instead of ERC-4337 UserOps. + * + * @param sender - Smart account address (same as EOA for stateless 7702). + * @returns True when direct EIP-1559 submission should be used. + */ + async function useDirect7702Tx(sender: Address): Promise { + if (smartAccountConfig?.implementation === 'stateless7702') { + if ( + smartAccountConfig.address !== undefined && + smartAccountConfig.address.toLowerCase() !== sender.toLowerCase() + ) { + // Config points at a different account — fall through to lazy check. + } else { + return true; + } + } + if (smartAccountConfig?.implementation === 'hybrid') { + return false; + } + if (!providerVat) { + throw new Error( + 'Cannot determine account type: provider not configured and ' + + 'smartAccountConfig is absent. Call configureProvider() first.', + ); + } + const code = (await E(providerVat).request('eth_getCode', [ + sender, + 'latest', + ])) as string; + const chainId = await resolveChainId(); + return isEip7702Delegated(code, chainId); + } + + /** + * Resolve the signing strategy for typed data. + * Priority: keyring → external signer → error. + * Away has no peer wallet fallback for typed data signing. + * + * @param data - The EIP-712 typed data to sign. + * @returns The signature as a hex string. + */ + async function resolveTypedDataSigning(data: Eip712TypedData): Promise { + if (keyringVat) { + const hasKeys = await E(keyringVat).hasKeys(); + if (hasKeys) { + return E(keyringVat).signTypedData(data); + } + } + + if (externalSigner) { + const accounts = await E(externalSigner).getAccounts(); + if (accounts.length > 0) { + return E(externalSigner).signTypedData(data, accounts[0] as Address); + } + } + + throw new Error('No authority to sign typed data'); + } + + /** + * Resolve the signing strategy for a transaction. + * LOCAL KEY ONLY on away side — no peer fallback. + * Priority: keyring → external signer → error. + * + * @param tx - The transaction request to sign. + * @returns The signed transaction as a hex string. + */ + async function resolveTransactionSigning( + tx: TransactionRequest, + ): Promise { + // Strategy 1: Check if local keyring owns this account + if (keyringVat) { + const accounts = await E(keyringVat).getAccounts(); + if (accounts.includes(tx.from.toLowerCase() as Address)) { + return E(keyringVat).signTransaction(tx); + } + } + + // Strategy 2: Check if external signer can handle it + if (externalSigner) { + return E(externalSigner).signTransaction({ + ...tx, + from: tx.from.toLowerCase() as Address, + }); + } + + throw new Error('No authority to sign this transaction'); + } + + /** + * Sign and broadcast a self-call tx with SDK-encoded DeleGator calldata + * (7702 EOA). Returns the transaction hash immediately after broadcast. + * + * @param options - Direct submission options. + * @param options.sender - Upgraded EOA / smart account address. + * @param options.callData - SDK-wrapped `execute` calldata. + * @param options.maxFeePerGas - Optional max fee per gas override. + * @param options.maxPriorityFeePerGas - Optional priority fee override. + * @returns The transaction hash from `eth_sendRawTransaction`. + */ + async function buildAndSubmitDirect7702Tx(options: { + sender: Address; + callData: Hex; + maxFeePerGas?: Hex; + maxPriorityFeePerGas?: Hex; + }): Promise { + if (!providerVat) { + throw new Error('Provider vat not available'); + } + const chainId = await resolveChainId(); + let { maxFeePerGas, maxPriorityFeePerGas } = options; + if (!maxFeePerGas || !maxPriorityFeePerGas) { + const fees = await E(providerVat).getGasFees(); + maxFeePerGas = maxFeePerGas ?? fees.maxFeePerGas; + maxPriorityFeePerGas = maxPriorityFeePerGas ?? fees.maxPriorityFeePerGas; + } + const nonce = await E(providerVat).getNonce(options.sender); + const estimatedGas = validateGasEstimate( + await E(providerVat).request('eth_estimateGas', [ + { + from: options.sender, + to: options.sender, + data: options.callData, + }, + ]), + ); + const gasLimit = applyGasBuffer(estimatedGas, 10); + const filledTx: TransactionRequest = { + from: options.sender, + to: options.sender, + chainId, + nonce, + maxFeePerGas, + maxPriorityFeePerGas, + gasLimit, + data: options.callData, + value: '0x0' as Hex, + }; + const signedTx = await resolveTransactionSigning(filledTx); + return E(providerVat).broadcastTransaction(signedTx); + } + + /** + * Build, sign, and submit a UserOp. Shared pipeline for delegation + * redemption. + * + * @param options - Pipeline options. + * @param options.sender - The smart account address that sends the UserOp. + * @param options.callData - The encoded callData for the UserOp. + * @param options.maxFeePerGas - Optional max fee per gas override. + * @param options.maxPriorityFeePerGas - Optional max priority fee per gas override. + * @returns The UserOp hash from the bundler. + */ + async function buildAndSubmitUserOp(options: { + sender: Address; + callData: Hex; + maxFeePerGas?: Hex; + maxPriorityFeePerGas?: Hex; + }): Promise { + if (!providerVat) { + throw new Error('Provider vat not available'); + } + if (!bundlerConfig) { + throw new Error('Bundler not configured'); + } + + const { sender, callData } = options; + + // Get gas prices from the bundler (pimlico_getUserOperationGasPrice) + // which returns prices the bundler will accept, avoiding rejection + // due to stale node-reported fees. + let { maxFeePerGas, maxPriorityFeePerGas } = options; + if (!maxFeePerGas || !maxPriorityFeePerGas) { + const gasPrice = await E(providerVat).getUserOperationGasPrice(); + maxFeePerGas = maxFeePerGas ?? gasPrice.fast.maxFeePerGas; + maxPriorityFeePerGas = + maxPriorityFeePerGas ?? gasPrice.fast.maxPriorityFeePerGas; + } + + // Get nonce from EntryPoint contract (ERC-4337 nonce) + const nonceHex = await E(providerVat).getEntryPointNonce({ + entryPoint: bundlerConfig.entryPoint, + sender, + }); + + // Detect signing mode: check smartAccountConfig first, then fall back + // to on-chain code inspection. This ensures the correct signing mode + // even if smartAccountConfig is lost from baggage. + let isStateless7702 = + smartAccountConfig?.implementation === 'stateless7702'; + + // Always fetch on-chain code — needed for both factory detection and + // signing mode fallback. + const onChainCode = (await E(providerVat).request('eth_getCode', [ + sender, + 'latest', + ])) as string | undefined; + + if (typeof onChainCode !== 'string') { + throw new Error( + `eth_getCode for ${sender} returned ${String(onChainCode)}; check provider configuration`, + ); + } + + // Fall back to on-chain code detection for 7702 accounts that weren't + // configured via smartAccountConfig (e.g., restored from stale baggage). + // Any EIP-7702 designator prefix (0xef0100) indicates a Stateless7702 + // DeleGator, which uses a different EIP-712 domain name for signing. + if (!isStateless7702 && onChainCode.toLowerCase().startsWith('0xef0100')) { + isStateless7702 = true; + } + + // Check on-chain whether the smart account is deployed (eth_getCode). + // This avoids relying on a cached flag that could be stale if the + // deployment UserOp failed on-chain. + let includeFactory = false; + if ( + !isStateless7702 && + smartAccountConfig?.factory && + smartAccountConfig.factoryData + ) { + includeFactory = onChainCode === '0x' || onChainCode === '0x0'; + + if (!includeFactory && smartAccountConfig.deployed === false) { + smartAccountConfig = harden({ + ...smartAccountConfig, + deployed: true, + }); + persistBaggage('smartAccountConfig', smartAccountConfig); + } + } + + // Build unsigned UserOp with a dummy 65-byte signature so that the + // smart account's validateUserOp can parse the ECDSA signature during + // bundler/paymaster simulation. An empty signature (0x) causes revert. + const unsignedUserOp: UserOperation = { + sender, + nonce: nonceHex, + callData, + callGasLimit: '0x50000' as Hex, + verificationGasLimit: '0x60000' as Hex, + preVerificationGas: '0x10000' as Hex, + maxFeePerGas, + maxPriorityFeePerGas, + signature: + '0xfffffffffffffffffffffffffffffff0000000000000000000000000000000007aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1c' as Hex, + ...(includeFactory && smartAccountConfig + ? { + factory: smartAccountConfig.factory as Hex, + factoryData: smartAccountConfig.factoryData as Hex, + } + : {}), + }; + + let userOpWithGas: UserOperation; + + if (bundlerConfig.usePaymaster) { + // Use paymaster sponsorship instead of gas estimation + const sponsorContext: Record = {}; + if (bundlerConfig.sponsorshipPolicyId) { + sponsorContext.sponsorshipPolicyId = bundlerConfig.sponsorshipPolicyId; + } + + const sponsorResult = await E(providerVat).sponsorUserOp({ + bundlerUrl: bundlerConfig.bundlerUrl, + entryPoint: bundlerConfig.entryPoint, + userOp: unsignedUserOp, + context: sponsorContext, + }); + + userOpWithGas = { + ...unsignedUserOp, + paymaster: sponsorResult.paymaster, + paymasterData: sponsorResult.paymasterData, + paymasterVerificationGasLimit: + sponsorResult.paymasterVerificationGasLimit, + paymasterPostOpGasLimit: sponsorResult.paymasterPostOpGasLimit, + callGasLimit: sponsorResult.callGasLimit, + verificationGasLimit: sponsorResult.verificationGasLimit, + preVerificationGas: sponsorResult.preVerificationGas, + }; + } else { + // Estimate gas via bundler + const gasEstimate = await E(providerVat).estimateUserOpGas({ + bundlerUrl: bundlerConfig.bundlerUrl, + entryPoint: bundlerConfig.entryPoint, + userOp: unsignedUserOp, + }); + + userOpWithGas = { + ...unsignedUserOp, + callGasLimit: applyGasBuffer(gasEstimate.callGasLimit, 10), + verificationGasLimit: applyGasBuffer( + gasEstimate.verificationGasLimit, + 10, + ), + preVerificationGas: gasEstimate.preVerificationGas, + }; + } + + // Sign the UserOp via EIP-712 typed data. Both Hybrid and Stateless7702 + // DeleGators validate signatures using EIP-712 — the only difference is + // the domain name. + const userOpTypedData = prepareUserOpTypedData({ + userOp: userOpWithGas, + entryPoint: bundlerConfig.entryPoint, + chainId: bundlerConfig.chainId, + smartAccountAddress: sender, + ...(isStateless7702 + ? { smartAccountName: 'EIP7702StatelessDeleGator' } + : {}), + }); + const signature: Hex = await resolveTypedDataSigning(userOpTypedData); + + // Attach signature and submit + const signedUserOp: UserOperation = { + ...userOpWithGas, + signature, + }; + + return E(providerVat).submitUserOp({ + bundlerUrl: bundlerConfig.bundlerUrl, + entryPoint: bundlerConfig.entryPoint, + userOp: signedUserOp, + }); + } + + /** + * Build, sign, and submit a UserOp that redeems one or more delegations. + * + * @param options - UserOp pipeline options. + * @param options.delegations - The delegation chain (leaf to root). + * @param options.execution - The execution to perform. + * @param options.maxFeePerGas - Max fee per gas. + * @param options.maxPriorityFeePerGas - Max priority fee per gas. + * @returns The UserOp hash from the bundler. + */ + async function submitDelegationUserOp(options: { + delegations: Delegation[]; + execution: Execution; + maxFeePerGas?: Hex | undefined; + maxPriorityFeePerGas?: Hex | undefined; + }): Promise { + if (!bundlerConfig && !smartAccountConfig) { + throw new Error( + 'Bundler not configured — cannot redeem delegation without bundler or smart account config', + ); + } + + const sender = + smartAccountConfig?.address ?? options.delegations[0].delegate; + + const chainId = await resolveChainId(); + const sdkCallData = buildSdkRedeemCallData({ + delegations: options.delegations, + execution: options.execution, + chainId, + }); + + if (await useDirect7702Tx(sender)) { + return buildAndSubmitDirect7702Tx({ + sender, + callData: sdkCallData, + maxFeePerGas: options.maxFeePerGas, + maxPriorityFeePerGas: options.maxPriorityFeePerGas, + }); + } + + if (!bundlerConfig) { + throw new Error( + 'Bundler not configured (required for hybrid smart account redemption)', + ); + } + + const userOpOptions: { + sender: Address; + callData: Hex; + maxFeePerGas?: Hex; + maxPriorityFeePerGas?: Hex; + } = { sender, callData: sdkCallData }; + if (options.maxFeePerGas) { + userOpOptions.maxFeePerGas = options.maxFeePerGas; + } + if (options.maxPriorityFeePerGas) { + userOpOptions.maxPriorityFeePerGas = options.maxPriorityFeePerGas; + } + + return buildAndSubmitUserOp(userOpOptions); + } + + /** + * Poll until an EIP-1559 transaction is mined or timeout. + * + * @param options - Polling options. + * @param options.txHash - Transaction hash to wait for. + * @param options.pollIntervalMs - Delay between RPC polls in milliseconds. + * @param options.timeoutMs - Maximum time to wait in milliseconds. + * @returns Whether the mined transaction succeeded (`status` 0x1). + */ + async function pollTransactionReceipt(options: { + txHash: Hex; + pollIntervalMs?: number; + timeoutMs?: number; + }): Promise<{ success: boolean }> { + if (!providerVat) { + throw new Error('Provider not configured'); + } + if ( + typeof globalThis.Date?.now !== 'function' || + typeof globalThis.setTimeout !== 'function' + ) { + throw new Error( + 'Transaction receipt polling requires Date.now and setTimeout', + ); + } + const interval = options.pollIntervalMs ?? 2000; + const timeout = options.timeoutMs ?? 120_000; + const start = Date.now(); + while (Date.now() - start < timeout) { + let receipt: { status?: string | number } | null = null; + try { + receipt = (await E(providerVat).request('eth_getTransactionReceipt', [ + options.txHash, + ])) as { status?: string | number } | null; + } catch (error) { + // Transient RPC errors (network hiccups, rate limits) should not + // abort polling — the tx was already broadcast and may still mine. + logger.warn( + `RPC error polling receipt for ${options.txHash}, will retry`, + error, + ); + await new Promise((resolve) => setTimeout(resolve, interval)); + continue; + } + if (receipt) { + // Normalize: some providers return status as a number (1) rather + // than the standard hex string ('0x1'). EIP-1559 receipts must have + // a status field; a missing one likely indicates a malformed response. + const { status } = receipt; + if (status === undefined || status === null) { + logger.warn( + `Receipt for ${options.txHash} has no status field — assuming success`, + ); + return harden({ success: true }); + } + const normalizedStatus = + typeof status === 'number' ? `0x${status.toString(16)}` : status; + return harden({ success: normalizedStatus === '0x1' }); + } + await new Promise((resolve) => setTimeout(resolve, interval)); + } + throw new Error( + `Transaction ${options.txHash} not mined after ${String(timeout)}ms`, + ); + } + + /** + * Resolve the EOA owner address from the keyring or external signer. + * + * @returns The first available EOA address. + * @throws If no accounts are available. + */ + async function resolveOwnerAddress(): Promise
{ + if (keyringVat) { + const accounts = await E(keyringVat).getAccounts(); + if (accounts.length > 0) { + return accounts[0] as Address; + } + } + if (externalSigner) { + const accounts = await E(externalSigner).getAccounts(); + if (accounts.length > 0) { + return accounts[0] as Address; + } + } + throw new Error('No accounts available'); + } + + /** + * Create a Stateless7702 smart account by signing and broadcasting + * an EIP-7702 authorization transaction. The user's EOA address + * becomes the smart account — no factory deployment or funding needed. + * + * @param chainId - The chain ID. + * @returns The smart account configuration. + */ + async function createStateless7702SmartAccount( + chainId: number, + ): Promise { + if (!providerVat) { + throw new Error('Provider vat required for EIP-7702 authorization'); + } + + // Resolve EOA address: keyring first, then external signer. + let eoaAddress: Address; + try { + eoaAddress = await resolveOwnerAddress(); + } catch { + throw new Error('No accounts available for EIP-7702 smart account'); + } + + // Check if already set up (persisted from a prior call) + if ( + smartAccountConfig?.implementation === 'stateless7702' && + smartAccountConfig.address === eoaAddress + ) { + return smartAccountConfig; + } + + // Best-effort on-chain check — works on providers that support + // EIP-7702 designator codes via eth_getCode (not all do, e.g. Infura). + const code = (await E(providerVat).request('eth_getCode', [ + eoaAddress, + 'latest', + ])) as string; + + if (isEip7702Delegated(code, chainId)) { + // eslint-disable-next-line require-atomic-updates + smartAccountConfig = harden({ + implementation: 'stateless7702' as const, + address: eoaAddress, + deployed: true, + }); + persistBaggage('smartAccountConfig', smartAccountConfig); + return smartAccountConfig; + } + + // EIP-7702 promotion requires signAuthorization which is only + // available on the local keyring (not supported by external signers). + if (!keyringVat || !(await E(keyringVat).hasKeys())) { + throw new Error( + 'EIP-7702 promotion requires a local keyring with initialized keys. ' + + 'Use implementation: "hybrid", or promote the account through MetaMask first.', + ); + } + + // Sign EIP-7702 authorization + const env = resolveEnvironment(chainId); + const implAddress = ( + env.implementations as Record + ).EIP7702StatelessDeleGatorImpl; + if (!implAddress) { + throw new Error( + `EIP7702StatelessDeleGatorImpl not found in environment for chain ${String(chainId)}`, + ); + } + + // Fetch the EOA nonce, gas fees, and sign the authorization in parallel. + // EIP-7702 self-execution: the tx sender is the same EOA as the + // authorization authority. The sender's nonce is incremented by the tx + // validity check BEFORE authorizations are processed, so the + // authorization nonce must be txNonce + 1. + const EIP7702_FALLBACK_GAS = '0x19000' as Hex; // 102400 + // Minimum plausible gas for an EIP-7702 auth tx (~40k). Estimates + // below this likely indicate the RPC ignored the authorizationList + // and returned a plain-transfer estimate (21000). + const EIP7702_MIN_GAS = 0xa000n; // 40960 + const [nonce, fees, estimatedAuthGas] = await Promise.all([ + E(providerVat).getNonce(eoaAddress), + E(providerVat).getGasFees(), + ( + E(providerVat).request('eth_estimateGas', [ + { + from: eoaAddress, + to: eoaAddress, + authorizationList: [{ address: implAddress, chainId }], + }, + ]) as Promise + ).then( + (result) => { + if (typeof result !== 'string' || !result.startsWith('0x')) { + logger.warn( + `eth_estimateGas returned non-hex for EIP-7702 auth: ${String(result)}, using fallback`, + ); + return EIP7702_FALLBACK_GAS; + } + if (BigInt(result) < EIP7702_MIN_GAS) { + logger.warn( + `eth_estimateGas returned suspiciously low value ${result} for EIP-7702 auth, using fallback`, + ); + return EIP7702_FALLBACK_GAS; + } + return result; + }, + (error: unknown) => { + const message = + error instanceof Error ? error.message : String(error); + // Only fall back when the RPC doesn't support authorizationList param + if ( + message.includes('-32602') || + message.includes('-32601') || + message.includes('not supported') || + message.includes('unknown field') + ) { + logger.warn( + 'eth_estimateGas does not support authorizationList, using fallback gas', + ); + return EIP7702_FALLBACK_GAS; + } + throw new Error( + `eth_estimateGas failed for EIP-7702 authorization: ${message}`, + ); + }, + ), + ]); + const authGasLimit = applyGasBuffer(estimatedAuthGas, 20); + const signedAuth = await E(keyringVat).signAuthorization({ + contractAddress: implAddress as Address, + chainId, + nonce: nonce + 1, + }); + + const signedTx = await E(keyringVat).signTransaction({ + from: eoaAddress, + to: eoaAddress, + chainId, + nonce, + maxFeePerGas: fees.maxFeePerGas, + maxPriorityFeePerGas: fees.maxPriorityFeePerGas, + gasLimit: authGasLimit, + authorizationList: [signedAuth], + }); + + const txHash = await E(providerVat).broadcastTransaction(signedTx); + + // Wait for the authorization tx to be mined. Some RPC providers (e.g. + // Infura) don't expose EIP-7702 designator code via eth_getCode, so we + // poll eth_getTransactionReceipt instead (status 0x1 = success). + if (typeof globalThis.setTimeout !== 'function') { + throw new Error( + 'EIP-7702 confirmation polling requires setTimeout ' + + '(not available in SES compartments without timer endowments)', + ); + } + const maxAttempts = 45; + for (let i = 0; i < maxAttempts; i++) { + const receipt = (await E(providerVat).request( + 'eth_getTransactionReceipt', + [txHash], + )) as { status?: string } | null; + if (receipt?.status === '0x1') { + break; + } + if (receipt?.status === '0x0') { + throw new Error( + `EIP-7702 authorization tx ${txHash as string} reverted on-chain`, + ); + } + if (i === maxAttempts - 1) { + throw new Error( + `EIP-7702 authorization tx ${txHash} not confirmed after 90s`, + ); + } + await new Promise((resolve) => setTimeout(resolve, 2000)); + } + + // eslint-disable-next-line require-atomic-updates + smartAccountConfig = harden({ + implementation: 'stateless7702' as const, + address: eoaAddress, + deployed: true, + }); + persistBaggage('smartAccountConfig', smartAccountConfig); + return smartAccountConfig; + } + + // ------------------------------------------------------------------------- + // Routing helpers + // ------------------------------------------------------------------------- + + /** + * Build a `redeemFn` closure for a given delegation. + * Used by `makeDelegationTwin` to submit delegation redemptions. + * + * @param delegation - The delegation to redeem against. + * @returns An async function that submits an execution as a delegation UserOp. + */ + function makeRedeemFn( + delegation: Delegation, + ): (execution: Execution) => Promise { + return async (execution: Execution): Promise => { + if (bundlerConfig || smartAccountConfig) { + return submitDelegationUserOp({ delegations: [delegation], execution }); + } + if (homeCoordRef) { + return E(homeCoordRef).redeemDelegation({ delegation, execution }); + } + throw new Error( + 'Bundler not configured — cannot redeem delegation without bundler or smart account config', + ); + }; + } + + /** + * Rebuild the delegation sections from current redeemer grants. + * Called after `receiveDelegation` or `connectToPeer`. + */ + async function rebuildRouting(): Promise { + const grants = redeemerVat ? await E(redeemerVat).listGrants() : []; + delegationSections = grants.map((grant) => + makeDelegationTwin({ + grant, + redeemFn: makeRedeemFn(grant.delegation), + }), + ); + } + + // ------------------------------------------------------------------------- + // Exo (public API) + // ------------------------------------------------------------------------- + + const awayCoordinator = makeDefaultExo('awayCoordinator', { + // ------------------------------------------------------------------ + // Lifecycle + // ------------------------------------------------------------------ + + /** + * Wire vat references and services. Called by the kernel during subcluster + * bootstrap. + * + * @param vats - References to co-located vats. + * @param services - External services available to this subcluster. + */ + async bootstrap(vats: WalletVats, services: WalletServices): Promise { + keyringVat = vats.keyring as KeyringFacet | undefined; + providerVat = vats.provider as ProviderFacet | undefined; + redeemerVat = vats.redeemer as RedeemerFacet | undefined; + redemptionService = services.ocapURLRedemptionService as + | OcapURLRedemptionFacet + | undefined; + + if (keyringVat) { + persistBaggage('keyringVat', keyringVat); + } + if (providerVat) { + persistBaggage('providerVat', providerVat); + } + if (redeemerVat) { + persistBaggage('redeemerVat', redeemerVat); + } + + logger.info('away bootstrap complete', { + hasKeyring: Boolean(keyringVat), + hasProvider: Boolean(providerVat), + hasRedeemer: Boolean(redeemerVat), + }); + + // Rebuild routing from persisted state (e.g. after kernel restart). + // homeSection is already restored from baggage; grants come from redeemerVat. + if (redeemerVat || homeSection) { + await rebuildRouting(); + } + }, + + // ------------------------------------------------------------------ + // Keyring + // ------------------------------------------------------------------ + + /** + * Initialize the keyring vat with a seed phrase or throwaway entropy. + * + * @param options - Keyring initialization options. + * @param options.type - 'srp' for a seed phrase, 'throwaway' for ephemeral key. + * @param options.mnemonic - BIP-39 mnemonic (srp only). + * @param options.entropy - Random entropy hex (throwaway only). + * @param options.password - Encryption password (srp only). + * @param options.salt - Encryption salt (srp only). + * @param options.addressIndex - HD derivation index (srp only). + */ + async initializeKeyring(options: { + type: 'srp' | 'throwaway'; + mnemonic?: string; + entropy?: Hex; + password?: string; + salt?: string; + addressIndex?: number; + }): Promise { + if (!keyringVat) { + throw new Error('Keyring vat not available'); + } + let initOptions: + | { type: 'srp'; mnemonic: string; addressIndex?: number } + | { type: 'throwaway'; entropy?: Hex }; + if (options.type === 'throwaway') { + initOptions = { type: 'throwaway', entropy: options.entropy }; + } else { + initOptions = + options.addressIndex === undefined + ? { type: 'srp', mnemonic: options.mnemonic ?? '' } + : { + type: 'srp', + mnemonic: options.mnemonic ?? '', + addressIndex: options.addressIndex, + }; + } + + const password = options.type === 'srp' ? options.password : undefined; + await E(keyringVat).initialize(initOptions, password, options.salt); + }, + + /** + * Unlock the keyring with the stored password. + * + * @param password - The decryption password. + */ + async unlockKeyring(password: string): Promise { + if (!keyringVat) { + throw new Error('Keyring vat not available'); + } + await E(keyringVat).unlock(password); + }, + + /** + * Check whether the keyring is currently locked. + * + * @returns True if the keyring is locked, false otherwise. + */ + async isKeyringLocked(): Promise { + if (!keyringVat) { + throw new Error('Keyring vat not available'); + } + return E(keyringVat).isLocked(); + }, + + // ------------------------------------------------------------------ + // Provider & bundler configuration + // ------------------------------------------------------------------ + + /** + * Configure the JSON-RPC provider (sets RPC URL and chain ID). + * + * @param chainConfig - Chain configuration with RPC URL and chain ID. + */ + async configureProvider(chainConfig: ChainConfig): Promise { + if (!providerVat) { + throw new Error('Provider vat not available'); + } + + // Validate RPC URL (regex — URL constructor unavailable under SES) + if (!/^https?:\/\/.+/u.test(chainConfig.rpcUrl)) { + throw new Error( + `Invalid RPC URL: "${chainConfig.rpcUrl}". Must be a valid HTTP(S) URL.`, + ); + } + + if (!Number.isInteger(chainConfig.chainId) || chainConfig.chainId <= 0) { + throw new Error( + `Invalid chain ID: ${String(chainConfig.chainId)}. Must be a positive integer.`, + ); + } + + await E(providerVat).configure(chainConfig); + + cachedProviderChainId = chainConfig.chainId; + persistBaggage('cachedProviderChainId', cachedProviderChainId); + }, + + /** + * Configure the ERC-4337 bundler for UserOp submission. + * + * @param config - Bundler configuration. + * @param config.bundlerUrl - The bundler RPC URL. + * @param config.entryPoint - EntryPoint contract address (defaults to v0.7). + * @param config.chainId - The chain ID the bundler operates on. + * @param config.usePaymaster - Whether to use paymaster sponsorship. + * @param config.sponsorshipPolicyId - Paymaster policy ID for sponsored ops. + * @param config.environment - Custom SDK environment for non-standard chains. + * @param config.environment.EntryPoint - EntryPoint contract address. + * @param config.environment.DelegationManager - DelegationManager contract address. + * @param config.environment.SimpleFactory - SimpleFactory contract address. + * @param config.environment.implementations - Map of implementation names to addresses. + * @param config.environment.caveatEnforcers - Map of enforcer names to addresses. + */ + async configureBundler(config: { + bundlerUrl: string; + entryPoint?: Hex; + chainId: number; + usePaymaster?: boolean; + sponsorshipPolicyId?: string; + environment?: { + EntryPoint: Hex; + DelegationManager: Hex; + SimpleFactory: Hex; + implementations: Record; + caveatEnforcers: Record; + }; + }): Promise { + // Validate bundler URL (regex — URL constructor unavailable under SES) + if (!/^https?:\/\/.+/u.test(config.bundlerUrl)) { + throw new Error( + `Invalid bundler URL: "${config.bundlerUrl}". Must be a valid HTTP(S) URL.`, + ); + } + + if (!Number.isInteger(config.chainId) || config.chainId <= 0) { + throw new Error( + `Invalid chain ID: ${String(config.chainId)}. Must be a positive integer.`, + ); + } + + // Register a custom SDK environment for chains not in the SDK's built-in + // registry (e.g. local Anvil at chain 31337). + if (config.environment) { + registerEnvironment(config.chainId, config.environment); + } + + bundlerConfig = harden({ + bundlerUrl: config.bundlerUrl, + entryPoint: config.entryPoint ?? ENTRY_POINT_V07, + chainId: config.chainId, + usePaymaster: config.usePaymaster, + sponsorshipPolicyId: config.sponsorshipPolicyId, + environment: config.environment, + }); + persistBaggage('bundlerConfig', bundlerConfig); + logger.info('bundler configured', { + bundlerUrl: config.bundlerUrl, + chainId: config.chainId, + entryPoint: bundlerConfig.entryPoint, + hasEnvironment: Boolean(config.environment), + }); + + if (!providerVat) { + throw new Error( + 'Provider vat not available. Call configureProvider() before configureBundler().', + ); + } + await E(providerVat).configureBundler({ + bundlerUrl: config.bundlerUrl, + chainId: config.chainId, + }); + }, + + /** + * Connect an external signer (e.g. MetaMask) for transaction signing. + * + * @param signer - The external signer facet. + */ + async connectExternalSigner(signer: ExternalSignerFacet): Promise { + if (!signer || typeof signer !== 'object') { + throw new Error('Invalid external signer: must be a non-null object'); + } + externalSigner = signer; + persistBaggage('externalSigner', externalSigner); + }, + + // ------------------------------------------------------------------ + // Smart account configuration + // ------------------------------------------------------------------ + + /** + * Create or restore a smart account configuration. + * + * @param config - Smart account creation options. + * @param config.deploySalt - Optional deploy salt for counterfactual address derivation. + * @param config.chainId - The chain ID the smart account is deployed on. + * @param config.address - Optional explicit smart account address (skips derivation). + * @param config.implementation - 'hybrid' (ERC-4337) or 'stateless7702' (EIP-7702). + * @returns The smart account configuration including the derived address. + */ + async createSmartAccount(config: { + deploySalt?: Hex; + chainId: number; + address?: Address; + implementation?: 'hybrid' | 'stateless7702'; + }): Promise { + const implementation = config.implementation ?? 'hybrid'; + + if (implementation === 'stateless7702') { + return createStateless7702SmartAccount(config.chainId); + } + + // Hybrid path (existing logic) + let { address } = config; + let factory: Address | undefined; + let factoryData: Hex | undefined; + const deploySalt = + config.deploySalt ?? + ('0x0000000000000000000000000000000000000000000000000000000000000001' as Hex); + + // Derive counterfactual address if not explicitly provided + if (!address) { + // Find the owner EOA from keyring or external signer + let owner: Address; + try { + owner = await resolveOwnerAddress(); + } catch { + throw new Error( + 'No owner account available to derive smart account address', + ); + } + + const env = resolveEnvironment(config.chainId); + factory = env.SimpleFactory; + + const derived = await computeSmartAccountAddress({ + owner, + deploySalt, + chainId: config.chainId, + }); + address = derived.address; + factoryData = derived.factoryData; + } + + smartAccountConfig = harden({ + implementation: 'hybrid' as const, + deploySalt, + address, + factory, + factoryData, + deployed: false, + }); + persistBaggage('smartAccountConfig', smartAccountConfig); + return smartAccountConfig; + }, + + /** + * Return the smart account address, if one is configured. + * + * @returns The smart account address, or undefined. + */ + async getSmartAccountAddress(): Promise
{ + return smartAccountConfig?.address; + }, + + // ------------------------------------------------------------------ + // Public wallet API + // ------------------------------------------------------------------ + + /** + * Return all locally available accounts (keyring + external signer). + * + * @returns Array of Ethereum addresses. + */ + async getAccounts(): Promise { + const localAccounts: Address[] = keyringVat + ? await E(keyringVat).getAccounts() + : []; + + const extAccounts: Address[] = externalSigner + ? await E(externalSigner).getAccounts() + : []; + + // Deduplicate by lowercasing + const seen = new Set(localAccounts.map((a) => a.toLowerCase())); + const merged = [...localAccounts]; + for (const account of extAccounts) { + if (!seen.has(account.toLowerCase())) { + seen.add(account.toLowerCase()); + merged.push(account); + } + } + return merged; + }, + + /** + * Sign a raw transaction using the local key (keyring or external signer). + * Away coordinator has no peer wallet fallback for signing. + * + * @param tx - The transaction request to sign. + * @returns The signed transaction as a hex string. + */ + async signTransaction(tx: TransactionRequest): Promise { + return resolveTransactionSigning(tx); + }, + + /** + * Sign an arbitrary message using the local key (keyring or external signer). + * + * @param message - The message to sign. + * @returns The signature as a hex string. + */ + async signMessage(message: string): Promise { + if (keyringVat) { + const hasKeys = await E(keyringVat).hasKeys(); + if (hasKeys) { + return E(keyringVat).signMessage(message); + } + } + if (externalSigner) { + const accounts = await E(externalSigner).getAccounts(); + if (accounts.length > 0) { + return E(externalSigner).signMessage(message, accounts[0] as Address); + } + } + throw new Error('No authority to sign message'); + }, + + /** + * Sign EIP-712 typed data using the local key (keyring or external signer). + * + * @param data - The EIP-712 typed data to sign. + * @returns The signature as a hex string. + */ + async signTypedData(data: Eip712TypedData): Promise { + return resolveTypedDataSigning(data); + }, + + /** + * Send a transaction using the direct path only (no delegation matching). + * Away's delegation routing goes through transferNative/transferFungible, not sendTransaction. + * + * @param tx - The transaction request. + * @returns The transaction hash. + */ + async sendTransaction(tx: TransactionRequest): Promise { + if (!providerVat) { + throw new Error('Provider not configured'); + } + logger.debug('sendTransaction (direct path only)', { + from: tx.from, + to: tx.to, + value: tx.value, + hasBundlerConfig: Boolean(bundlerConfig), + }); + + // Direct (non-delegation) send: estimate missing gas fields + const filledTx = { ...tx }; + + filledTx.nonce ??= await E(providerVat).getNonce(filledTx.from); + filledTx.chainId ??= await E(providerVat).getChainId(); + if (!filledTx.maxFeePerGas || !filledTx.maxPriorityFeePerGas) { + const fees = await E(providerVat).getGasFees(); + filledTx.maxFeePerGas ??= fees.maxFeePerGas; + filledTx.maxPriorityFeePerGas ??= fees.maxPriorityFeePerGas; + } + filledTx.gasLimit ??= applyGasBuffer( + validateGasEstimate( + await E(providerVat).request('eth_estimateGas', [ + { + from: filledTx.from, + to: filledTx.to, + value: filledTx.value, + data: filledTx.data, + }, + ]), + ), + 10, + ); + + const signedTx = await resolveTransactionSigning(filledTx); + return E(providerVat).broadcastTransaction(signedTx); + }, + + /** + * Pass a JSON-RPC method call through to the provider vat. + * + * @param method - The JSON-RPC method name. + * @param params - Optional method parameters. + * @returns The raw RPC response. + */ + async request(method: string, params?: unknown[]): Promise { + if (!providerVat) { + throw new Error('Provider not configured'); + } + return E(providerVat).request(method, params); + }, + + /** + * Look up a transaction by hash. Tries the bundler first (in case the + * hash is a UserOp hash from delegation redemption), then falls back + * to a regular `eth_getTransactionReceipt` RPC call. + * + * @param hash - A UserOp hash or regular tx hash. + * @returns An object with `txHash` and `receipt`, or null if not found. + */ + async getTransactionReceipt(hash: Hex): Promise<{ + txHash: Hex; + userOpHash?: Hex; + success: boolean; + } | null> { + if (!providerVat) { + throw new Error('Provider not configured'); + } + + // Try bundler first (UserOp hash) + if (bundlerConfig) { + try { + const userOpReceipt = (await E(providerVat).getUserOpReceipt({ + bundlerUrl: bundlerConfig.bundlerUrl, + userOpHash: hash, + })) as { + success: boolean; + receipt?: { transactionHash?: string }; + } | null; + + if (userOpReceipt?.receipt?.transactionHash) { + return harden({ + txHash: userOpReceipt.receipt.transactionHash as Hex, + userOpHash: hash, + success: userOpReceipt.success, + }); + } + } catch (error) { + // Not a UserOp hash — fall through to regular RPC + logger.debug( + 'UserOp receipt lookup failed, trying regular RPC', + error, + ); + } + } + + // Try regular tx receipt + const receipt = (await E(providerVat).request( + 'eth_getTransactionReceipt', + [hash], + )) as { status?: string; transactionHash?: string } | null; + + if (receipt?.transactionHash) { + return harden({ + txHash: receipt.transactionHash as Hex, + success: receipt.status === '0x1', + }); + } + + return null; + }, + + /** + * Poll for a UserOp receipt until it appears or the timeout elapses. + * + * @param options - Polling options. + * @param options.userOpHash - The UserOp hash returned by the bundler. + * @param options.pollIntervalMs - Delay between polls in milliseconds. + * @param options.timeoutMs - Maximum time to wait in milliseconds. + * @returns The raw receipt object from the bundler. + */ + async waitForUserOpReceipt(options: { + userOpHash: Hex; + pollIntervalMs?: number; + timeoutMs?: number; + }): Promise { + if (!providerVat || !bundlerConfig) { + throw new Error('Provider and bundler must be configured'); + } + + if ( + typeof globalThis.Date?.now !== 'function' || + typeof globalThis.setTimeout !== 'function' + ) { + throw new Error( + 'waitForUserOpReceipt requires Date.now and setTimeout ' + + '(not available in SES compartments without timer endowments)', + ); + } + + const interval = options.pollIntervalMs ?? 2000; + const timeout = options.timeoutMs ?? 60000; + const start = Date.now(); + + while (Date.now() - start < timeout) { + const receipt = await E(providerVat).getUserOpReceipt({ + bundlerUrl: bundlerConfig.bundlerUrl, + userOpHash: options.userOpHash, + }); + if (receipt !== null) { + return receipt; + } + await new Promise((resolve) => setTimeout(resolve, interval)); + } + throw new Error( + `UserOp ${options.userOpHash} not found after ${timeout}ms`, + ); + }, + + /** + * Poll until a regular EIP-1559 transaction is mined. + * Prefer `waitForUserOpReceipt` for ERC-4337 UserOp hashes. + * + * @param options - Polling options. + * @param options.txHash - Transaction hash to wait for. + * @param options.pollIntervalMs - Delay between RPC polls in milliseconds. + * @param options.timeoutMs - Maximum time to wait in milliseconds. + * @returns Whether the mined transaction succeeded (`status` 0x1). + */ + async waitForTransactionReceipt(options: { + txHash: Hex; + pollIntervalMs?: number; + timeoutMs?: number; + }): Promise<{ success: boolean }> { + return pollTransactionReceipt(options); + }, + + // ------------------------------------------------------------------ + // ERC-20 token utilities + // ------------------------------------------------------------------ + + /** + * Query the ERC-20 token balance for an owner address. + * + * @param options - Query options. + * @param options.token - Token contract address. + * @param options.owner - Account address to query. + * @returns The balance as a decimal string. + */ + async getTokenBalance(options: { + token: Address; + owner: Address; + }): Promise { + if (!providerVat) { + throw new Error('Provider not configured'); + } + const callData = encodeBalanceOf(options.owner); + const result = await E(providerVat).request('eth_call', [ + { to: options.token, data: callData }, + 'latest', + ]); + const validated = validateTokenCallResult( + result, + 'balanceOf', + options.token, + ); + return decodeBalanceOfResult(validated).toString(); + }, + + /** + * Query the name, symbol, and decimals of an ERC-20 token. + * + * @param options - Query options. + * @param options.token - Token contract address. + * @returns An object with name, symbol, and decimals. + */ + async getTokenMetadata(options: { + token: Address; + }): Promise<{ name: string; symbol: string; decimals: number }> { + if (!providerVat) { + throw new Error('Provider not configured'); + } + const [nameSettled, symbolSettled, decimalsSettled] = + await Promise.allSettled([ + E(providerVat).request('eth_call', [ + { to: options.token, data: encodeName() }, + 'latest', + ]), + E(providerVat).request('eth_call', [ + { to: options.token, data: encodeSymbol() }, + 'latest', + ]), + E(providerVat).request('eth_call', [ + { to: options.token, data: encodeDecimals() }, + 'latest', + ]), + ]); + + // decimals is mandatory — wrong decimals causes financial errors + if (decimalsSettled.status === 'rejected') { + throw new Error( + `decimals() call failed for token ${options.token}: ${ + decimalsSettled.reason instanceof Error + ? decimalsSettled.reason.message + : String(decimalsSettled.reason) + }`, + ); + } + + // name and symbol are optional in ERC-20; fall back gracefully + let name = 'Unknown'; + if (nameSettled.status === 'fulfilled') { + try { + name = decodeNameResult( + validateTokenCallResult(nameSettled.value, 'name', options.token), + ); + } catch { + // name() not implemented or returned invalid data + } + } + + let symbol = 'Unknown'; + if (symbolSettled.status === 'fulfilled') { + try { + symbol = decodeSymbolResult( + validateTokenCallResult( + symbolSettled.value, + 'symbol', + options.token, + ), + ); + } catch { + // symbol() not implemented or returned invalid data + } + } + + return harden({ + name, + symbol, + decimals: decodeDecimalsResult( + validateTokenCallResult( + decimalsSettled.value, + 'decimals', + options.token, + ), + ), + }); + }, + + // ------------------------------------------------------------------ + // Semantic wallet API + // ------------------------------------------------------------------ + + /** + * Transfer native ETH. + * Tries each delegation twin in order; falls back to calling home. + * + * @param to - Recipient address. + * @param amount - Amount in wei. + * @returns The transaction hash. + */ + async transferNative(to: Address, amount: bigint): Promise { + const amt = BigInt(amount as unknown as string | number | bigint); + const matching = delegationSections.filter( + (sec) => sec.method === 'transferNative', + ); + if (matching.length > 0) { + let lastError: unknown; + for (const section of matching) { + try { + return await E(section.exo).transferNative(to, amt); + } catch (error) { + lastError = error; + } + } + throw lastError; + } + if (homeSection) { + return E(homeSection).transferNative(to, amt); + } + throw new Error( + 'No routing available — call connectToPeer first or receive a delegation', + ); + }, + + /** + * Transfer ERC-20 tokens. + * Tries delegation twins for this token first; if none match, falls back to + * calling home. Errors from matched twins propagate — they are not swallowed + * and do not fall through to the home section. + * + * @param token - ERC-20 token contract address. + * @param to - Recipient address. + * @param amount - Amount in token units. + * @returns The transaction hash. + */ + async transferFungible( + token: Address, + to: Address, + amount: bigint, + ): Promise { + const amt = BigInt(amount as unknown as string | number | bigint); + const matching = delegationSections.filter( + (sec) => sec.method === 'transferFungible' && sec.token === token, + ); + if (matching.length > 0) { + let lastError: unknown; + for (const section of matching) { + try { + return await E(section.exo).transferFungible(token, to, amt); + } catch (error) { + lastError = error; + } + } + throw lastError; + } + if (homeSection) { + return E(homeSection).transferFungible(token, to, amt); + } + throw new Error( + 'No routing available — call connectToPeer first or receive a delegation', + ); + }, + + /** + * Receive a delegation grant from home and persist it to the redeemer vat. + * Rebuilds the delegation sections to incorporate the new grant. + * + * @param grant - The semantic delegation grant to store. + */ + async receiveDelegation(grant: DelegationGrant): Promise { + if (!redeemerVat) { + throw new Error('Redeemer vat not available'); + } + await E(redeemerVat).receiveGrant(grant); + await rebuildRouting(); + }, + + /** + * List all delegation grants stored in the redeemer vat. + * + * @returns An array of all DelegationGrant objects received on this device. + */ + async listGrants(): Promise { + if (!redeemerVat) { + return []; + } + return E(redeemerVat).listGrants(); + }, + + /** + * 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 the homeSection reference and rebuilds routing. + * + * @param ocapUrl - The OCAP URL issued by the home coordinator. + */ + async connectToPeer(ocapUrl: string): Promise { + if (!redemptionService) { + throw new Error('OCAP URL redemption service not available'); + } + homeCoordRef = await E(redemptionService).redeem(ocapUrl); + homeSection = await E(homeCoordRef).getHomeSection(); + persistBaggage('homeSection', homeSection); + await rebuildRouting(); + }, + + /** + * Refresh cached peer accounts from the home coordinator. + * No-op if no home coordinator connection has been established. + */ + async refreshPeerAccounts(): Promise { + if (!homeCoordRef) { + return; + } + await E(homeCoordRef).getAccounts(); + }, + + /** + * Register this device's delegate address on the home coordinator. + * Called after connecting to the peer to allow the home coordinator to + * record the away wallet's on-chain delegate address. + * + * @param address - The away wallet's delegate address (0x-prefixed). + */ + async sendDelegateAddressToPeer(address: string): Promise { + if (!homeCoordRef) { + throw new Error('Not connected to a peer — call connectToPeer first'); + } + await E(homeCoordRef).registerDelegateAddress(address); + }, + + // ------------------------------------------------------------------ + // Introspection + // ------------------------------------------------------------------ + + /** + * Return a summary of the away coordinator's current capabilities. + * Reflects away-side state: local key, bundler, smart account, grants + * count (from redeemerVat), and whether homeSection is wired. + * + * @returns A WalletCapabilities object describing current state. + */ + async getCapabilities(): Promise { + const hasLocalKeys = keyringVat ? await E(keyringVat).hasKeys() : false; + const localAccounts: Address[] = keyringVat + ? await E(keyringVat).getAccounts() + : []; + const grants = redeemerVat ? await E(redeemerVat).listGrants() : []; + + let signingMode = 'none'; + if (externalSigner) { + signingMode = 'external:metamask'; + } else if (hasLocalKeys) { + signingMode = 'local'; + } + + let autonomy = 'no signing authority'; + if (grants.length > 0) { + // Describe any native ETH amount limits in human-readable form. + const limitParts: string[] = grants + .filter( + (grant) => + grant.method === 'transferNative' && + grant.maxAmount !== undefined, + ) + .map( + (grant) => + `max ${weiToEth(`0x${(grant.maxAmount ?? 0n).toString(16)}`)} per tx`, + ); + const limitSuffix = + limitParts.length > 0 ? ` (${limitParts.join('; ')})` : ''; + autonomy = `autonomous via ${grants.length} delegation(s)${limitSuffix}`; + } else if (homeSection) { + autonomy = 'call-home (no delegations)'; + } + + let capabilityChainId: number | undefined; + try { + capabilityChainId = await resolveChainId(); + } catch { + // ignore — chain ID may not be configured yet + } + + return harden({ + hasLocalKeys, + localAccounts, + delegationCount: grants.length, + hasPeerWallet: homeSection !== undefined, + hasExternalSigner: externalSigner !== undefined, + hasBundlerConfig: bundlerConfig !== undefined, + smartAccountAddress: smartAccountConfig?.address, + chainId: capabilityChainId, + signingMode, + autonomy, + }); + }, + }); + + return awayCoordinator; +} diff --git a/packages/evm-wallet-experiment/src/vats/home-coordinator.ts b/packages/evm-wallet-experiment/src/vats/home-coordinator.ts index d70c29faa5..2f21f63209 100644 --- a/packages/evm-wallet-experiment/src/vats/home-coordinator.ts +++ b/packages/evm-wallet-experiment/src/vats/home-coordinator.ts @@ -341,6 +341,9 @@ export function buildRootObject( // OcapURL service references let issuerService: OcapURLIssuerFacet | undefined; + // Away wallet's delegate address, registered via registerDelegateAddress + let delegateAddress: string | undefined; + /** * Typed helper for restoring values from baggage (resuscitation). * @@ -376,6 +379,7 @@ export function buildRootObject( } smartAccountConfig = restoreFromBaggage('smartAccountConfig'); + delegateAddress = restoreFromBaggage('delegateAddress'); /** Chain ID from the last `configureProvider` call (avoids RPC on every send). */ let cachedProviderChainId: number | undefined = restoreFromBaggage( @@ -1748,7 +1752,7 @@ export function buildRootObject( async buildTransferNativeGrant(options: { delegate: Address; to?: Address; - maxAmount?: bigint; + maxAmount?: bigint | string; chainId: number; }): Promise { if (!delegatorVat) { @@ -1756,9 +1760,12 @@ export function buildRootObject( } const delegator = smartAccountConfig?.address ?? (await resolveOwnerAddress()); + const maxAmount = + options.maxAmount === undefined ? undefined : BigInt(options.maxAmount); const unsignedGrant = await E(delegatorVat).buildTransferNativeGrant({ delegator, ...options, + maxAmount, }); const signedGrant = await signDelegationInGrant(unsignedGrant); await E(delegatorVat).storeGrant(signedGrant); @@ -1784,7 +1791,7 @@ export function buildRootObject( delegate: Address; token: Address; to?: Address; - maxAmount?: bigint; + maxAmount?: bigint | string; chainId: number; }): Promise { if (!delegatorVat) { @@ -1792,9 +1799,12 @@ export function buildRootObject( } const delegator = smartAccountConfig?.address ?? (await resolveOwnerAddress()); + const maxAmount = + options.maxAmount === undefined ? undefined : BigInt(options.maxAmount); const unsignedGrant = await E(delegatorVat).buildTransferFungibleGrant({ delegator, ...options, + maxAmount, }); const signedGrant = await signDelegationInGrant(unsignedGrant); await E(delegatorVat).storeGrant(signedGrant); @@ -2302,6 +2312,31 @@ export function buildRootObject( return pollTransactionReceipt(options); }, + // ------------------------------------------------------------------ + // Peer delegate address registration + // ------------------------------------------------------------------ + + /** + * Register the away wallet's on-chain delegate address. + * Called by the away coordinator after connecting to report which address + * will appear as `msg.sender` when redeeming delegations. + * + * @param address - The away wallet's delegate address (0x-prefixed). + */ + async registerDelegateAddress(address: string): Promise { + delegateAddress = address; + persistBaggage('delegateAddress', delegateAddress); + }, + + /** + * Return the registered away wallet delegate address, if any. + * + * @returns The delegate address, or undefined if not yet registered. + */ + async getDelegateAddress(): Promise { + return delegateAddress; + }, + // ------------------------------------------------------------------ // OcapURL and homeSection // ------------------------------------------------------------------ From 1c636b138ada524c413a672b692fef1d7794f456 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Wed, 15 Apr 2026 19:52:43 -0400 Subject: [PATCH 07/23] refactor(evm-wallet): remove coordinator-vat, delegation-vat, delegation-grant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Delete the monolithic coordinator-vat (3275 lines mixing home and away concerns) and the old delegation-vat, now superseded by the home/away split and the new delegator-vat/redeemer-vat pair. Remove delegation-grant.ts, which built grants from raw CaveatSpec lists; that role now belongs to delegator-vat, which encodes caveats and returns pre-decoded TransferNativeGrant / TransferFungibleGrant values. Update package.json build script: coordinator-vat → home-coordinator + away-coordinator. Remove delegation-grant exports from index.ts. Co-Authored-By: Claude Sonnet 4.6 --- packages/evm-wallet-experiment/package.json | 2 +- .../src/cluster-config.test.ts | 65 +- .../src/cluster-config.ts | 37 +- packages/evm-wallet-experiment/src/index.ts | 11 +- .../src/lib/delegation-grant.test.ts | 220 - .../src/lib/delegation-grant.ts | 363 -- .../src/vats/coordinator-vat.test.ts | 5482 ----------------- .../src/vats/coordinator-vat.ts | 3274 ---------- .../src/vats/delegation-vat.test.ts | 416 -- .../src/vats/delegation-vat.ts | 211 - 10 files changed, 69 insertions(+), 10012 deletions(-) delete mode 100644 packages/evm-wallet-experiment/src/lib/delegation-grant.test.ts delete mode 100644 packages/evm-wallet-experiment/src/lib/delegation-grant.ts delete mode 100644 packages/evm-wallet-experiment/src/vats/coordinator-vat.test.ts delete mode 100644 packages/evm-wallet-experiment/src/vats/coordinator-vat.ts delete mode 100644 packages/evm-wallet-experiment/src/vats/delegation-vat.test.ts delete mode 100644 packages/evm-wallet-experiment/src/vats/delegation-vat.ts diff --git a/packages/evm-wallet-experiment/package.json b/packages/evm-wallet-experiment/package.json index 8da7b67a63..5b2f5f693c 100644 --- a/packages/evm-wallet-experiment/package.json +++ b/packages/evm-wallet-experiment/package.json @@ -29,7 +29,7 @@ "dist/" ], "scripts": { - "build": "ocap bundle src/vats/coordinator-vat.ts src/vats/keyring-vat.ts src/vats/provider-vat.ts src/vats/delegation-vat.ts", + "build": "ocap bundle src/vats/home-coordinator.ts src/vats/away-coordinator.ts src/vats/keyring-vat.ts src/vats/provider-vat.ts", "build:docs": "typedoc", "changelog:validate": "../../scripts/validate-changelog.sh @ocap/evm-wallet-experiment", "clean": "rimraf --glob './*.tsbuildinfo' ./.eslintcache ./coverage ./dist ./.turbo ./logs", diff --git a/packages/evm-wallet-experiment/src/cluster-config.test.ts b/packages/evm-wallet-experiment/src/cluster-config.test.ts index 5460750d39..5f01389750 100644 --- a/packages/evm-wallet-experiment/src/cluster-config.test.ts +++ b/packages/evm-wallet-experiment/src/cluster-config.test.ts @@ -1,13 +1,12 @@ import { describe, it, expect } from 'vitest'; import { makeWalletClusterConfig } from './cluster-config.ts'; -import type { Address } from './types.ts'; const BUNDLE_BASE_URL = 'http://localhost:3000'; describe('cluster-config', () => { describe('makeWalletClusterConfig', () => { - it('creates a valid ClusterConfig', () => { + it('defaults to home role', () => { const config = makeWalletClusterConfig({ bundleBaseUrl: BUNDLE_BASE_URL, }); @@ -17,7 +16,34 @@ describe('cluster-config', () => { expect(config.vats).toHaveProperty('coordinator'); expect(config.vats).toHaveProperty('keyring'); expect(config.vats).toHaveProperty('provider'); - expect(config.vats).toHaveProperty('delegation'); + expect(config.vats).toHaveProperty('delegator'); + expect(config.vats).not.toHaveProperty('redeemer'); + }); + + it('uses home-coordinator.bundle for home role', () => { + const config = makeWalletClusterConfig({ + bundleBaseUrl: BUNDLE_BASE_URL, + role: 'home', + }); + + const coordConfig = config.vats.coordinator as { bundleSpec: string }; + expect(coordConfig.bundleSpec).toBe( + `${BUNDLE_BASE_URL}/home-coordinator.bundle`, + ); + }); + + it('uses away-coordinator.bundle for away role', () => { + const config = makeWalletClusterConfig({ + bundleBaseUrl: BUNDLE_BASE_URL, + role: 'away', + }); + + const coordConfig = config.vats.coordinator as { bundleSpec: string }; + expect(coordConfig.bundleSpec).toBe( + `${BUNDLE_BASE_URL}/away-coordinator.bundle`, + ); + expect(config.vats).toHaveProperty('redeemer'); + expect(config.vats).not.toHaveProperty('delegator'); }); it('includes OCAP URL services by default', () => { @@ -40,23 +66,25 @@ describe('cluster-config', () => { expect(config.services).toStrictEqual(['customService']); }); - it('sets delegation manager address as parameter', () => { - const address = '0xcccccccccccccccccccccccccccccccccccccccc' as Address; + it('has four vats for home role', () => { const config = makeWalletClusterConfig({ bundleBaseUrl: BUNDLE_BASE_URL, - delegationManagerAddress: address, + role: 'home', }); + const vatNames = Object.keys(config.vats); - expect(config.vats.delegation).toHaveProperty('parameters'); - expect( - (config.vats.delegation as { parameters: Record }) - .parameters.delegationManagerAddress, - ).toBe(address); + expect(vatNames).toStrictEqual([ + 'coordinator', + 'keyring', + 'provider', + 'delegator', + ]); }); - it('has four vats with bundleSpec', () => { + it('has four vats for away role', () => { const config = makeWalletClusterConfig({ bundleBaseUrl: BUNDLE_BASE_URL, + role: 'away', }); const vatNames = Object.keys(config.vats); @@ -64,16 +92,8 @@ describe('cluster-config', () => { 'coordinator', 'keyring', 'provider', - 'delegation', + 'redeemer', ]); - - for (const vatName of vatNames) { - const vatConfig = config.vats[vatName] as { bundleSpec: string }; - expect(vatConfig).toHaveProperty('bundleSpec'); - expect(vatConfig.bundleSpec).toBe( - `${BUNDLE_BASE_URL}/${vatName}-vat.bundle`, - ); - } }); it('requests required globals for all vats', () => { @@ -82,12 +102,11 @@ describe('cluster-config', () => { }); const baseGlobals = ['TextEncoder', 'TextDecoder']; - for (const vatName of ['keyring', 'provider', 'delegation']) { + for (const vatName of ['keyring', 'provider', 'delegator']) { const vatConfig = config.vats[vatName] as { globals?: string[] }; expect(vatConfig.globals).toStrictEqual(baseGlobals); } - // Coordinator additionally needs Date and setTimeout const coordConfig = config.vats.coordinator as { globals?: string[] }; expect(coordConfig.globals).toStrictEqual([ 'TextEncoder', diff --git a/packages/evm-wallet-experiment/src/cluster-config.ts b/packages/evm-wallet-experiment/src/cluster-config.ts index a86b97f39b..44f011631b 100644 --- a/packages/evm-wallet-experiment/src/cluster-config.ts +++ b/packages/evm-wallet-experiment/src/cluster-config.ts @@ -1,14 +1,11 @@ import type { ClusterConfig } from '@metamask/ocap-kernel'; -import type { Address } from './types.ts'; - /** * Options for creating a wallet cluster configuration. */ export type WalletClusterConfigOptions = { bundleBaseUrl: string; - delegationManagerAddress?: Address; - chainId?: number; + role?: 'home' | 'away'; forceReset?: boolean; services?: string[]; allowedHosts?: string[]; @@ -25,18 +22,38 @@ export function makeWalletClusterConfig( ): ClusterConfig { const { bundleBaseUrl, - delegationManagerAddress, + role = 'home', services = ['ocapURLIssuerService', 'ocapURLRedemptionService'], allowedHosts, } = options; + const coordinatorBundle = + role === 'home' + ? `${bundleBaseUrl}/home-coordinator.bundle` + : `${bundleBaseUrl}/away-coordinator.bundle`; + + const auxiliaryVat = + role === 'home' + ? { + delegator: { + bundleSpec: `${bundleBaseUrl}/delegator-vat.bundle`, + globals: ['TextEncoder', 'TextDecoder'], + }, + } + : { + redeemer: { + bundleSpec: `${bundleBaseUrl}/redeemer-vat.bundle`, + globals: ['TextEncoder', 'TextDecoder'], + }, + }; + return { bootstrap: 'coordinator', forceReset: options.forceReset ?? false, services, vats: { coordinator: { - bundleSpec: `${bundleBaseUrl}/coordinator-vat.bundle`, + bundleSpec: coordinatorBundle, globals: ['TextEncoder', 'TextDecoder', 'Date', 'setTimeout'], }, keyring: { @@ -50,13 +67,7 @@ export function makeWalletClusterConfig( fetch: allowedHosts ? { allowedHosts } : {}, }, }, - delegation: { - bundleSpec: `${bundleBaseUrl}/delegation-vat.bundle`, - globals: ['TextEncoder', 'TextDecoder'], - ...(delegationManagerAddress - ? { parameters: { delegationManagerAddress } } - : {}), - }, + ...auxiliaryVat, }, }; } diff --git a/packages/evm-wallet-experiment/src/index.ts b/packages/evm-wallet-experiment/src/index.ts index b0d7ca17fa..b18ddf1283 100644 --- a/packages/evm-wallet-experiment/src/index.ts +++ b/packages/evm-wallet-experiment/src/index.ts @@ -27,7 +27,6 @@ export type { Address, Action, Caveat, - CaveatSpec, CaveatType, ChainConfig, CreateDelegationOptions, @@ -44,18 +43,18 @@ export type { SwapQuote, SwapResult, TransactionRequest, + TransferFungibleGrant, + TransferNativeGrant, UserOperation, WalletCapabilities, } from './types.ts'; export { ActionStruct, - CaveatSpecStruct, CaveatStruct, CaveatTypeValues, ChainConfigStruct, CreateDelegationOptionsStruct, - DelegationGrantStruct, DelegationStatusValues, DelegationStruct, Eip712DomainStruct, @@ -182,11 +181,5 @@ export type { export { METHOD_CATALOG } from './lib/method-catalog.ts'; export type { CatalogMethodName } from './lib/method-catalog.ts'; -// Grant builder -export { - buildDelegationGrant, - makeDelegationGrantBuilder, -} from './lib/delegation-grant.ts'; - // Twin factory export { makeDelegationTwin } from './lib/delegation-twin.ts'; diff --git a/packages/evm-wallet-experiment/src/lib/delegation-grant.test.ts b/packages/evm-wallet-experiment/src/lib/delegation-grant.test.ts deleted file mode 100644 index bfe19748f5..0000000000 --- a/packages/evm-wallet-experiment/src/lib/delegation-grant.test.ts +++ /dev/null @@ -1,220 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import type { Address } from '../types.ts'; -import { - buildDelegationGrant, - makeDelegationGrantBuilder, -} from './delegation-grant.ts'; -import { makeSaltGenerator } from './delegation.ts'; - -const ALICE = '0x1111111111111111111111111111111111111111' as Address; -const BOB = '0x2222222222222222222222222222222222222222' as Address; -const TOKEN = '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as Address; -const CHAIN_ID = 11155111; - -describe('buildDelegationGrant', () => { - describe('transfer', () => { - it('produces correct caveats', () => { - const grant = buildDelegationGrant('transfer', { - delegator: ALICE, - delegate: BOB, - token: TOKEN, - max: 1000n, - chainId: CHAIN_ID, - }); - - expect(grant.methodName).toBe('transfer'); - expect(grant.token).toBe(TOKEN); - expect(grant.delegation.delegator).toBe(ALICE); - expect(grant.delegation.delegate).toBe(BOB); - expect(grant.delegation.chainId).toBe(CHAIN_ID); - expect(grant.delegation.status).toBe('pending'); - - const caveatTypes = grant.delegation.caveats.map((cv) => cv.type); - expect(caveatTypes).toStrictEqual([ - 'allowedTargets', - 'allowedMethods', - 'erc20TransferAmount', - ]); - }); - - it('includes timestamp caveat only when validUntil provided', () => { - const withoutExpiry = buildDelegationGrant('transfer', { - delegator: ALICE, - delegate: BOB, - token: TOKEN, - max: 1000n, - chainId: CHAIN_ID, - }); - expect( - withoutExpiry.delegation.caveats.map((cv) => cv.type), - ).not.toContain('timestamp'); - - const withExpiry = buildDelegationGrant('transfer', { - delegator: ALICE, - delegate: BOB, - token: TOKEN, - max: 1000n, - chainId: CHAIN_ID, - validUntil: 1700000000, - }); - expect(withExpiry.delegation.caveats.map((cv) => cv.type)).toContain( - 'timestamp', - ); - }); - - it('caveatSpecs contain cumulativeSpend entry', () => { - const grant = buildDelegationGrant('transfer', { - delegator: ALICE, - delegate: BOB, - token: TOKEN, - max: 500n, - chainId: CHAIN_ID, - }); - - expect(grant.caveatSpecs).toStrictEqual([ - { type: 'cumulativeSpend', token: TOKEN, max: 500n }, - ]); - }); - - it('includes blockWindow caveatSpec when validUntil provided', () => { - const grant = buildDelegationGrant('transfer', { - delegator: ALICE, - delegate: BOB, - token: TOKEN, - max: 500n, - chainId: CHAIN_ID, - validUntil: 1700000000, - }); - - expect(grant.caveatSpecs).toStrictEqual([ - { type: 'cumulativeSpend', token: TOKEN, max: 500n }, - { type: 'blockWindow', after: 0n, before: 1700000000n }, - ]); - }); - }); - - describe('approve', () => { - it('produces correct caveats', () => { - const grant = buildDelegationGrant('approve', { - delegator: ALICE, - delegate: BOB, - token: TOKEN, - max: 2000n, - chainId: CHAIN_ID, - }); - - expect(grant.methodName).toBe('approve'); - expect(grant.token).toBe(TOKEN); - const caveatTypes = grant.delegation.caveats.map((cv) => cv.type); - expect(caveatTypes).toStrictEqual([ - 'allowedTargets', - 'allowedMethods', - 'erc20TransferAmount', - ]); - }); - }); - - describe('call', () => { - it('produces allowedTargets caveat for provided targets', () => { - const target1 = '0x3333333333333333333333333333333333333333' as Address; - const target2 = '0x4444444444444444444444444444444444444444' as Address; - const grant = buildDelegationGrant('call', { - delegator: ALICE, - delegate: BOB, - targets: [target1, target2], - chainId: CHAIN_ID, - }); - - expect(grant.methodName).toBe('call'); - expect(grant.caveatSpecs).toStrictEqual([]); - expect(grant.delegation.caveats[0]?.type).toBe('allowedTargets'); - }); - - it('includes valueLte caveat when maxValue provided', () => { - const grant = buildDelegationGrant('call', { - delegator: ALICE, - delegate: BOB, - targets: [TOKEN], - chainId: CHAIN_ID, - maxValue: 10000n, - }); - - const caveatTypes = grant.delegation.caveats.map((cv) => cv.type); - expect(caveatTypes).toContain('valueLte'); - }); - - it('does not include valueLte caveat when maxValue omitted', () => { - const grant = buildDelegationGrant('call', { - delegator: ALICE, - delegate: BOB, - targets: [TOKEN], - chainId: CHAIN_ID, - }); - - const caveatTypes = grant.delegation.caveats.map((cv) => cv.type); - expect(caveatTypes).not.toContain('valueLte'); - }); - - it('includes blockWindow caveatSpec when validUntil provided', () => { - const grant = buildDelegationGrant('call', { - delegator: ALICE, - delegate: BOB, - targets: [TOKEN], - chainId: CHAIN_ID, - validUntil: 1700000000, - }); - - expect(grant.delegation.caveats.map((cv) => cv.type)).toContain( - 'timestamp', - ); - expect(grant.caveatSpecs).toStrictEqual([ - { type: 'blockWindow', after: 0n, before: 1700000000n }, - ]); - }); - }); -}); - -describe('makeDelegationGrantBuilder', () => { - it('produces grants with the injected salt generator', () => { - let counter = 0; - const fixedSalt = `0x${'ab'.repeat(32)}`; - const saltGenerator = () => { - counter += 1; - return fixedSalt; - }; - const builder = makeDelegationGrantBuilder({ saltGenerator }); - - const grant = builder.buildDelegationGrant('transfer', { - delegator: ALICE, - delegate: BOB, - token: TOKEN, - max: 100n, - chainId: CHAIN_ID, - }); - - expect(grant.delegation.salt).toBe(fixedSalt); - expect(counter).toBe(1); - }); - - it('two builders with independent generators produce different salts', () => { - const gen1 = makeSaltGenerator('0x01' as `0x${string}`); - const gen2 = makeSaltGenerator('0x02' as `0x${string}`); - const builder1 = makeDelegationGrantBuilder({ saltGenerator: gen1 }); - const builder2 = makeDelegationGrantBuilder({ saltGenerator: gen2 }); - - const baseOpts = { - delegator: ALICE, - delegate: BOB, - token: TOKEN, - max: 100n, - chainId: CHAIN_ID, - }; - - const grant1 = builder1.buildDelegationGrant('transfer', baseOpts); - const grant2 = builder2.buildDelegationGrant('transfer', baseOpts); - - expect(grant1.delegation.salt).not.toBe(grant2.delegation.salt); - expect(grant1.delegation.id).not.toBe(grant2.delegation.id); - }); -}); diff --git a/packages/evm-wallet-experiment/src/lib/delegation-grant.ts b/packages/evm-wallet-experiment/src/lib/delegation-grant.ts deleted file mode 100644 index 834015dc22..0000000000 --- a/packages/evm-wallet-experiment/src/lib/delegation-grant.ts +++ /dev/null @@ -1,363 +0,0 @@ -import { - encodeAllowedCalldata, - encodeAllowedMethods, - encodeAllowedTargets, - encodeErc20TransferAmount, - encodeTimestamp, - encodeValueLte, - makeCaveat, -} from './caveats.ts'; -import { generateSalt, makeDelegation } from './delegation.ts'; -import type { SaltGenerator } from './delegation.ts'; -import { - ERC20_APPROVE_SELECTOR, - ERC20_TRANSFER_SELECTOR, - FIRST_ARG_OFFSET, -} from './erc20.ts'; -import type { - Address, - Caveat, - CaveatSpec, - DelegationGrant, - Hex, -} from '../types.ts'; - -const harden = globalThis.harden ?? ((value: T): T => value); - -/** - * Encode an address as a 32-byte ABI-encoded word (left-padded with zeros). - * - * @param address - The Ethereum address to encode. - * @returns The 0x-prefixed 32-byte hex string. - */ -function abiEncodeAddress(address: Address): Hex { - return `0x${address.slice(2).toLowerCase().padStart(64, '0')}`; -} - -type TransferOptions = { - delegator: Address; - delegate: Address; - token: Address; - max: bigint; - chainId: number; - validUntil?: number; - recipient?: Address; -}; - -type ApproveOptions = { - delegator: Address; - delegate: Address; - token: Address; - max: bigint; - chainId: number; - validUntil?: number; - spender?: Address; -}; - -type CallOptions = { - delegator: Address; - delegate: Address; - targets: Address[]; - chainId: number; - maxValue?: bigint; - validUntil?: number; -}; - -export function buildDelegationGrant( - method: 'transfer', - options: TransferOptions, -): DelegationGrant; -export function buildDelegationGrant( - method: 'approve', - options: ApproveOptions, -): DelegationGrant; -export function buildDelegationGrant( - method: 'call', - options: CallOptions, -): DelegationGrant; -/** - * Build an unsigned delegation grant for the given method. - * - * Uses {@link generateSalt} (module-level fallback) for salt generation. - * For vat usage where per-instance salt isolation matters, prefer - * {@link makeDelegationGrantBuilder} with a {@link makeSaltGenerator} instance. - * - * @param method - The catalog method name. - * @param options - Method-specific options. - * @returns An unsigned DelegationGrant. - */ -export function buildDelegationGrant( - method: 'transfer' | 'approve' | 'call', - options: TransferOptions | ApproveOptions | CallOptions, -): DelegationGrant { - return dispatchGrant(method, options, generateSalt); -} - -/** - * Create a delegation grant builder with an injected salt generator. - * - * The returned builder exposes the same {@link buildDelegationGrant} overloads - * but uses the provided {@link SaltGenerator} for every grant it builds. - * Instantiate once per vat (or per logical context) so that the generator's - * internal counter is isolated from other instances. - * - * @param options - Builder options. - * @param options.saltGenerator - The salt generator to use for all grants. - * @returns An object with a {@link buildDelegationGrant} method. - */ -export function makeDelegationGrantBuilder(options: { - saltGenerator: SaltGenerator; -}): { - buildDelegationGrant( - method: 'transfer', - opts: TransferOptions, - ): DelegationGrant; - buildDelegationGrant( - method: 'approve', - opts: ApproveOptions, - ): DelegationGrant; - buildDelegationGrant(method: 'call', opts: CallOptions): DelegationGrant; -} { - const { saltGenerator } = options; - function build(method: 'transfer', opts: TransferOptions): DelegationGrant; - function build(method: 'approve', opts: ApproveOptions): DelegationGrant; - function build(method: 'call', opts: CallOptions): DelegationGrant; - /** - * @param method - The catalog method name. - * @param opts - Method-specific grant options. - * @returns An unsigned DelegationGrant. - */ - function build( - method: 'transfer' | 'approve' | 'call', - opts: TransferOptions | ApproveOptions | CallOptions, - ): DelegationGrant { - return dispatchGrant(method, opts, saltGenerator); - } - return harden({ buildDelegationGrant: build }); -} - -/** - * @param method - The catalog method name. - * @param options - Method-specific grant options. - * @param saltGenerator - Salt generator for delegation uniqueness. - * @returns An unsigned DelegationGrant. - */ -function dispatchGrant( - method: 'transfer' | 'approve' | 'call', - options: TransferOptions | ApproveOptions | CallOptions, - saltGenerator: SaltGenerator, -): DelegationGrant { - switch (method) { - case 'transfer': - return buildTransferGrant(options as TransferOptions, saltGenerator); - case 'approve': - return buildApproveGrant(options as ApproveOptions, saltGenerator); - case 'call': - return buildCallGrant(options as CallOptions, saltGenerator); - default: - throw new Error(`Unknown method: ${String(method)}`); - } -} - -type Erc20GrantOptions = { - methodName: 'transfer' | 'approve'; - selector: Hex; - delegator: Address; - delegate: Address; - token: Address; - max: bigint; - chainId: number; - validUntil?: number; - restrictedAddress?: Address; - saltGenerator: SaltGenerator; -}; - -/** - * Build a delegation grant for an ERC-20 method (transfer or approve). - * - * @param options - ERC-20 grant options. - * @param options.methodName - The catalog method name ('transfer' or 'approve'). - * @param options.selector - The ERC-20 function selector. - * @param options.delegator - The delegating account address. - * @param options.delegate - The delegate account address. - * @param options.token - The ERC-20 token contract address. - * @param options.max - The maximum token amount allowed. - * @param options.chainId - The chain ID. - * @param options.validUntil - Optional Unix timestamp after which the delegation expires. - * @param options.restrictedAddress - Optional address to lock the first argument to. - * @param options.saltGenerator - Salt generator for delegation uniqueness. - * @returns An unsigned DelegationGrant for the given ERC-20 method. - */ -function buildErc20Grant({ - methodName, - selector, - delegator, - delegate, - token, - max, - chainId, - validUntil, - restrictedAddress, - saltGenerator, -}: Erc20GrantOptions): DelegationGrant { - const caveats: Caveat[] = [ - makeCaveat({ - type: 'allowedTargets', - terms: encodeAllowedTargets([token]), - chainId, - }), - makeCaveat({ - type: 'allowedMethods', - terms: encodeAllowedMethods([selector]), - chainId, - }), - makeCaveat({ - type: 'erc20TransferAmount', - terms: encodeErc20TransferAmount({ token, amount: max }), - chainId, - }), - ]; - - const caveatSpecs: CaveatSpec[] = [{ type: 'cumulativeSpend', token, max }]; - - if (restrictedAddress !== undefined) { - const value = abiEncodeAddress(restrictedAddress); - caveats.push( - makeCaveat({ - type: 'allowedCalldata', - terms: encodeAllowedCalldata({ dataStart: FIRST_ARG_OFFSET, value }), - chainId, - }), - ); - caveatSpecs.push({ - type: 'allowedCalldata', - dataStart: FIRST_ARG_OFFSET, - value, - }); - } - - if (validUntil !== undefined) { - caveats.push( - makeCaveat({ - type: 'timestamp', - terms: encodeTimestamp({ after: 0, before: validUntil }), - chainId, - }), - ); - caveatSpecs.push({ - type: 'blockWindow', - after: 0n, - before: BigInt(validUntil), - }); - } - - const delegation = makeDelegation({ - delegator, - delegate, - caveats, - chainId, - saltGenerator, - }); - - return harden({ delegation, methodName, caveatSpecs, token }); -} - -/** - * Build a transfer delegation grant. - * - * @param options - Transfer grant options. - * @param saltGenerator - Salt generator for delegation uniqueness. - * @returns An unsigned DelegationGrant for ERC-20 transfers. - */ -function buildTransferGrant( - options: TransferOptions, - saltGenerator: SaltGenerator, -): DelegationGrant { - return buildErc20Grant({ - ...options, - methodName: 'transfer', - selector: ERC20_TRANSFER_SELECTOR, - restrictedAddress: options.recipient, - saltGenerator, - }); -} - -/** - * Build an approve delegation grant. - * - * @param options - Approve grant options. - * @param saltGenerator - Salt generator for delegation uniqueness. - * @returns An unsigned DelegationGrant for ERC-20 approvals. - */ -function buildApproveGrant( - options: ApproveOptions, - saltGenerator: SaltGenerator, -): DelegationGrant { - return buildErc20Grant({ - ...options, - methodName: 'approve', - selector: ERC20_APPROVE_SELECTOR, - restrictedAddress: options.spender, - saltGenerator, - }); -} - -/** - * Build a raw call delegation grant. - * - * @param options - Call grant options. - * @param saltGenerator - Salt generator for delegation uniqueness. - * @returns An unsigned DelegationGrant for raw calls. - */ -function buildCallGrant( - options: CallOptions, - saltGenerator: SaltGenerator, -): DelegationGrant { - const { delegator, delegate, targets, chainId, maxValue, validUntil } = - options; - const caveats: Caveat[] = [ - makeCaveat({ - type: 'allowedTargets', - terms: encodeAllowedTargets(targets), - chainId, - }), - ]; - - const caveatSpecs: CaveatSpec[] = []; - - if (maxValue !== undefined) { - caveats.push( - makeCaveat({ - type: 'valueLte', - terms: encodeValueLte(maxValue), - chainId, - }), - ); - caveatSpecs.push({ type: 'valueLte', max: maxValue }); - } - - if (validUntil !== undefined) { - caveats.push( - makeCaveat({ - type: 'timestamp', - terms: encodeTimestamp({ after: 0, before: validUntil }), - chainId, - }), - ); - caveatSpecs.push({ - type: 'blockWindow', - after: 0n, - before: BigInt(validUntil), - }); - } - - const delegation = makeDelegation({ - delegator, - delegate, - caveats, - chainId, - saltGenerator, - }); - - return harden({ delegation, methodName: 'call', caveatSpecs }); -} diff --git a/packages/evm-wallet-experiment/src/vats/coordinator-vat.test.ts b/packages/evm-wallet-experiment/src/vats/coordinator-vat.test.ts deleted file mode 100644 index 6218e51da9..0000000000 --- a/packages/evm-wallet-experiment/src/vats/coordinator-vat.test.ts +++ /dev/null @@ -1,5482 +0,0 @@ -import { afterEach, describe, it, expect, beforeEach, vi } from 'vitest'; - -import { buildRootObject as buildDelegationRoot } from './delegation-vat.ts'; -import { buildRootObject as buildKeyringRoot } from './keyring-vat.ts'; -import { makeMockBaggage } from '../../test/helpers.ts'; -import { encodeAllowedTargets, makeCaveat } from '../lib/caveats.ts'; -import { encodeTransfer } from '../lib/erc20.ts'; -import { ENTRY_POINT_V07 } from '../lib/userop.ts'; -import type { - Address, - Delegation, - Eip712TypedData, - Hex, - TransactionRequest, -} from '../types.ts'; - -// Mock E() to call methods directly on plain objects -vi.mock('@endo/eventual-send', () => ({ - E: (target: Record unknown>) => { - return new Proxy(target, { - get(_target, prop: string) { - return (...args: unknown[]) => { - const method = _target[prop]; - if (typeof method !== 'function') { - throw new Error(`${prop} is not a function on target`); - } - return method.call(_target, ...args); - }; - }, - }); - }, -})); - -const MOCK_FACTORY = '0xDDdDddDdDdddDDddDDddDDDDdDdDDdDDdDDDDDDd' as Address; - -vi.mock('../lib/sdk.ts', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - computeSmartAccountAddress: vi.fn().mockResolvedValue({ - address: '0xcccccccccccccccccccccccccccccccccccccccc', - factoryData: '0xfactorydata', - }), - resolveEnvironment: vi.fn().mockReturnValue({ - SimpleFactory: '0xDDdDddDdDdddDDddDDddDDDDdDdDDdDDdDDDDDDd', - DelegationManager: '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE', - EntryPoint: '0x0000000071727De22E5E9d8BAf0edAc6f37da032', - implementations: { - EIP7702StatelessDeleGatorImpl: - '0x63c0c19a282a1B52b07dD5a65b58948A07DAE32B', - }, - caveatEnforcers: {}, - }), - }; -}); - -// Dynamic import after mocking -const { buildRootObject } = await import('./coordinator-vat.ts'); - -const DERIVED_SMART_ACCOUNT = - '0xcccccccccccccccccccccccccccccccccccccccc' as Address; - -const TEST_MNEMONIC = - 'test test test test test test test test test test test junk'; -const TARGET = '0x1234567890abcdef1234567890abcdef12345678' as Address; - -function makeMockProviderVat() { - return { - bootstrap: vi.fn(), - configure: vi.fn(), - request: vi.fn().mockImplementation(async (method: string) => { - if (method === 'eth_getCode') { - return Promise.resolve('0x'); - } - if (method === 'eth_estimateGas') { - return Promise.resolve('0x5208' as Hex); - } - return Promise.resolve(undefined); - }), - broadcastTransaction: vi.fn().mockResolvedValue('0xtxhash'), - getBalance: vi.fn(), - getChainId: vi.fn().mockResolvedValue(1), - getNonce: vi.fn().mockResolvedValue(0), - getEntryPointNonce: vi.fn().mockResolvedValue('0x0' as Hex), - submitUserOp: vi.fn().mockResolvedValue('0xuserophash'), - estimateUserOpGas: vi.fn().mockResolvedValue({ - callGasLimit: '0x50000' as Hex, - verificationGasLimit: '0x60000' as Hex, - preVerificationGas: '0x10000' as Hex, - }), - getUserOpReceipt: vi.fn().mockResolvedValue(null), - getGasFees: vi.fn().mockResolvedValue({ - maxFeePerGas: '0x77359400' as Hex, - maxPriorityFeePerGas: '0x3b9aca00' as Hex, - }), - getUserOperationGasPrice: vi.fn().mockResolvedValue({ - slow: { - maxFeePerGas: '0x59682f00' as Hex, - maxPriorityFeePerGas: '0x1dcd6500' as Hex, - }, - standard: { - maxFeePerGas: '0x6fc23ac0' as Hex, - maxPriorityFeePerGas: '0x2faf0800' as Hex, - }, - fast: { - maxFeePerGas: '0x77359400' as Hex, - maxPriorityFeePerGas: '0x3b9aca00' as Hex, - }, - }), - httpGetJson: vi.fn(), - configureBundler: vi.fn(), - sponsorUserOp: vi.fn().mockResolvedValue({ - paymaster: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as Address, - paymasterData: '0xdeadbeef' as Hex, - paymasterVerificationGasLimit: '0x60000' as Hex, - paymasterPostOpGasLimit: '0x10000' as Hex, - callGasLimit: '0x50000' as Hex, - verificationGasLimit: '0x60000' as Hex, - preVerificationGas: '0x10000' as Hex, - }), - }; -} - -const EXT_SIGNER_ACCOUNT = - '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as Address; - -function makeMockExternalSigner() { - return { - getAccounts: vi.fn().mockResolvedValue([EXT_SIGNER_ACCOUNT]), - signTypedData: vi - .fn() - .mockResolvedValue( - '0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef00' as Hex, - ), - signMessage: vi - .fn() - .mockResolvedValue( - '0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef00' as Hex, - ), - signTransaction: vi - .fn() - .mockResolvedValue( - '0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef00' as Hex, - ), - }; -} - -describe('coordinator-vat', () => { - let coordinatorBaggage: ReturnType; - let keyringBaggage: ReturnType; - let delegationBaggage: ReturnType; - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let coordinator: any; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let keyringVat: any; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let delegationVat: any; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let providerVat: any; - - beforeEach(async () => { - coordinatorBaggage = makeMockBaggage(); - keyringBaggage = makeMockBaggage(); - delegationBaggage = makeMockBaggage(); - - // Build real keyring and delegation vats (unit test with real inner vats) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - keyringVat = buildKeyringRoot({}, undefined, keyringBaggage as any); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - delegationVat = buildDelegationRoot({}, {}, delegationBaggage as any); - providerVat = makeMockProviderVat(); - - coordinator = buildRootObject( - {}, - undefined, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - coordinatorBaggage as any, - ); - - await coordinator.bootstrap( - { keyring: keyringVat, provider: providerVat, delegation: delegationVat }, - {}, - ); - }); - - describe('bootstrap', () => { - it('stores vat references in baggage', () => { - expect(coordinatorBaggage.has('keyringVat')).toBe(true); - expect(coordinatorBaggage.has('providerVat')).toBe(true); - expect(coordinatorBaggage.has('delegationVat')).toBe(true); - }); - }); - - describe('configureProvider', () => { - it('rejects invalid RPC URL', async () => { - await expect( - coordinator.configureProvider({ - chainId: 1, - rpcUrl: 'not-a-url', - }), - ).rejects.toThrow('Invalid RPC URL'); - }); - - it('rejects non-HTTP(S) RPC URL', async () => { - await expect( - coordinator.configureProvider({ - chainId: 1, - rpcUrl: 'ws://eth.example.com', - }), - ).rejects.toThrow('Invalid RPC URL'); - }); - - it('rejects invalid chain ID in provider config', async () => { - await expect( - coordinator.configureProvider({ - chainId: 0, - rpcUrl: 'https://eth.example.com', - }), - ).rejects.toThrow('Invalid chain ID'); - }); - - it('accepts valid HTTP(S) provider config', async () => { - await coordinator.configureProvider({ - chainId: 1, - rpcUrl: 'https://eth.example.com', - }); - - expect(providerVat.configure).toHaveBeenCalledWith({ - chainId: 1, - rpcUrl: 'https://eth.example.com', - }); - }); - }); - - describe('initializeKeyring', () => { - it('initializes the keyring with SRP', async () => { - await coordinator.initializeKeyring({ - type: 'srp', - mnemonic: TEST_MNEMONIC, - }); - - const accounts = await coordinator.getAccounts(); - expect(accounts).toHaveLength(1); - }); - - it('initializes the keyring with throwaway key', async () => { - await coordinator.initializeKeyring({ type: 'throwaway' }); - - const accounts = await coordinator.getAccounts(); - expect(accounts).toHaveLength(1); - }); - - it('encrypts mnemonic when password and salt are provided', async () => { - await coordinator.initializeKeyring({ - type: 'srp', - mnemonic: TEST_MNEMONIC, - password: 'test-password', - salt: 'aabbccddaabbccddaabbccddaabbccdd', - }); - - const accounts = await coordinator.getAccounts(); - expect(accounts).toHaveLength(1); - - // Verify the keyring vat persisted encrypted data - const stored = keyringBaggage.get('keyringInit') as Record< - string, - unknown - >; - expect(stored.encrypted).toBe(true); - expect(stored).not.toHaveProperty('mnemonic'); - }, 900_000); - }); - - describe('unlockKeyring / isKeyringLocked', () => { - const LOCK_PASSWORD = 'test-password'; - const LOCK_SALT = 'aabbccddaabbccddaabbccddaabbccdd'; - - it('delegates unlock to keyring vat', async () => { - await coordinator.initializeKeyring({ - type: 'srp', - mnemonic: TEST_MNEMONIC, - password: LOCK_PASSWORD, - salt: LOCK_SALT, - }); - - // Resuscitate coordinator + keyring (simulates daemon restart) - - const restoredKeyring = buildKeyringRoot( - {}, - undefined, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - keyringBaggage as any, - ); - const freshBaggage = makeMockBaggage(); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const restoredCoord: any = buildRootObject( - {}, - undefined, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - freshBaggage as any, - ); - await restoredCoord.bootstrap( - { - keyring: restoredKeyring, - provider: providerVat, - delegation: delegationVat, - }, - {}, - ); - - expect(await restoredCoord.isKeyringLocked()).toBe(true); - - await restoredCoord.unlockKeyring(LOCK_PASSWORD); - - expect(await restoredCoord.isKeyringLocked()).toBe(false); - const accounts = await restoredCoord.getAccounts(); - expect(accounts).toHaveLength(1); - }, 900_000); - }); - - describe('signing strategy resolution', () => { - describe('local key signing', () => { - it('signs with local key when account is owned', async () => { - await coordinator.initializeKeyring({ - type: 'srp', - mnemonic: TEST_MNEMONIC, - }); - - const accounts = await coordinator.getAccounts(); - const tx: TransactionRequest = { - from: accounts[0], - to: '0x70997970c51812dc3a010c7d01b50e0d17dc79c8' as Address, - value: '0xde0b6b3a7640000' as Hex, - chainId: 1, - nonce: 0, - maxFeePerGas: '0x3b9aca00' as Hex, - maxPriorityFeePerGas: '0x3b9aca00' as Hex, - }; - - const signed = await coordinator.signTransaction(tx); - expect(signed).toMatch(/^0x/u); - }); - }); - - describe('delegation-based signing', () => { - it('uses delegation path when a matching delegation exists', async () => { - await coordinator.initializeKeyring({ - type: 'srp', - mnemonic: TEST_MNEMONIC, - }); - - const accounts = await coordinator.getAccounts(); - const delegator = accounts[0] as Address; - - // Create and sign a delegation covering the target - const delegation = await delegationVat.createDelegation({ - delegator, - delegate: delegator, - caveats: [ - makeCaveat({ - type: 'allowedTargets', - terms: encodeAllowedTargets([TARGET]), - }), - ], - chainId: 1, - }); - await delegationVat.storeSigned(delegation.id, '0xdeadbeef' as Hex); - - const tx: TransactionRequest = { - from: delegator, - to: TARGET, - value: '0x0' as Hex, - chainId: 1, - nonce: 0, - maxFeePerGas: '0x3b9aca00' as Hex, - maxPriorityFeePerGas: '0x3b9aca00' as Hex, - }; - - const signed = await coordinator.signTransaction(tx); - expect(signed).toMatch(/^0x/u); - }); - }); - - describe('no authority', () => { - it('rejects when no signing strategy is available', async () => { - await coordinator.initializeKeyring({ type: 'throwaway' }); - - const tx: TransactionRequest = { - from: '0x0000000000000000000000000000000000000099' as Address, - to: TARGET, - chainId: 1, - nonce: 0, - }; - - await expect(coordinator.signTransaction(tx)).rejects.toThrow( - 'No authority to sign this transaction', - ); - }); - }); - - describe('peer wallet fallback', () => { - it('does not forward transaction signing to peer wallet', async () => { - const mockPeerWallet = { - getAccounts: vi.fn().mockResolvedValue([]), - handleSigningRequest: vi - .fn() - .mockResolvedValue('0xpeersigned' as Hex), - }; - - // Build coordinator with peer wallet in baggage - const freshBaggage = makeMockBaggage(); - freshBaggage.init('peerWallet', mockPeerWallet); - - const coordinatorWithPeer = buildRootObject( - {}, - undefined, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - freshBaggage as any, - ); - - const tx: TransactionRequest = { - from: '0x0000000000000000000000000000000000000099' as Address, - to: TARGET, - chainId: 1, - nonce: 0, - }; - - await expect(coordinatorWithPeer.signTransaction(tx)).rejects.toThrow( - 'No authority to sign this transaction', - ); - expect(mockPeerWallet.handleSigningRequest).not.toHaveBeenCalled(); - }); - }); - }); - - describe('sendTransaction', () => { - it('signs and broadcasts a transaction', async () => { - await coordinator.initializeKeyring({ - type: 'srp', - mnemonic: TEST_MNEMONIC, - }); - - const accounts = await coordinator.getAccounts(); - const tx: TransactionRequest = { - from: accounts[0], - to: '0x70997970c51812dc3a010c7d01b50e0d17dc79c8' as Address, - value: '0xde0b6b3a7640000' as Hex, - chainId: 1, - nonce: 0, - maxFeePerGas: '0x3b9aca00' as Hex, - maxPriorityFeePerGas: '0x3b9aca00' as Hex, - }; - - const txHash = await coordinator.sendTransaction(tx); - expect(txHash).toBe('0xtxhash'); - expect(providerVat.broadcastTransaction).toHaveBeenCalled(); - - // Verify eth_estimateGas was called for the missing gasLimit - expect(providerVat.request).toHaveBeenCalledWith( - 'eth_estimateGas', - expect.arrayContaining([ - expect.objectContaining({ from: accounts[0] }), - ]), - ); - }); - - it('uses UserOp pipeline when delegation and bundler are configured', async () => { - await coordinator.initializeKeyring({ - type: 'srp', - mnemonic: TEST_MNEMONIC, - }); - - await coordinator.configureBundler({ - bundlerUrl: 'https://bundler.example.com', - chainId: 1, - }); - - const accounts = await coordinator.getAccounts(); - const delegator = accounts[0] as Address; - - // Create a signed delegation covering the target - await coordinator.createDelegation({ - delegate: delegator, - caveats: [ - makeCaveat({ - type: 'allowedTargets', - terms: encodeAllowedTargets([TARGET]), - }), - ], - chainId: 1, - }); - - const tx: TransactionRequest = { - from: delegator, - to: TARGET, - value: '0x0' as Hex, - data: '0xdeadbeef' as Hex, - chainId: 1, - nonce: 0, - maxFeePerGas: '0x3b9aca00' as Hex, - maxPriorityFeePerGas: '0x3b9aca00' as Hex, - }; - - const result = await coordinator.sendTransaction(tx); - expect(result).toBe('0xuserophash'); - expect(providerVat.submitUserOp).toHaveBeenCalled(); - expect(providerVat.broadcastTransaction).not.toHaveBeenCalled(); - }); - - it('redeems delegation via direct 7702 tx when bundler is absent', async () => { - await coordinator.initializeKeyring({ - type: 'srp', - mnemonic: TEST_MNEMONIC, - }); - - // Configure provider so resolveChainId returns 11155111 (matching - // the delegation's chainId) — without this, the delegation lookup - // would use chainId 1 from the getChainId mock and miss the match. - await coordinator.configureProvider({ - rpcUrl: 'https://sepolia.infura.io/v3/test', - chainId: 11155111, - }); - - // Set up 7702 smart account (already delegated) — no bundler configured - providerVat.request.mockResolvedValueOnce( - '0xef010063c0c19a282a1b52b07dd5a65b58948a07dae32b', - ); - await coordinator.createSmartAccount({ - chainId: 11155111, - implementation: 'stateless7702', - }); - - const accounts = await coordinator.getAccounts(); - const delegator = accounts[0] as Address; - - await coordinator.createDelegation({ - delegate: delegator, - caveats: [ - makeCaveat({ - type: 'allowedTargets', - terms: encodeAllowedTargets([TARGET]), - }), - ], - chainId: 11155111, - }); - - const tx: TransactionRequest = { - from: delegator, - to: TARGET, - value: '0x0' as Hex, - data: '0xdeadbeef' as Hex, - }; - - const result = await coordinator.sendTransaction(tx); - expect(result).toBe('0xtxhash'); - // Must use direct 7702 broadcast (self-call), not bundler UserOp - expect(providerVat.broadcastTransaction).toHaveBeenCalled(); - expect(providerVat.submitUserOp).not.toHaveBeenCalled(); - }); - - it('rejects tx to disallowed target when delegationVat exists without bundler', async () => { - await coordinator.initializeKeyring({ - type: 'srp', - mnemonic: TEST_MNEMONIC, - }); - - await coordinator.configureProvider({ - rpcUrl: 'https://sepolia.infura.io/v3/test', - chainId: 11155111, - }); - - // Set up 7702 smart account — no bundler configured - providerVat.request.mockResolvedValueOnce( - '0xef010063c0c19a282a1b52b07dd5a65b58948a07dae32b', - ); - await coordinator.createSmartAccount({ - chainId: 11155111, - implementation: 'stateless7702', - }); - - const accounts = await coordinator.getAccounts(); - const delegator = accounts[0] as Address; - - // Delegation only allows TARGET - await coordinator.createDelegation({ - delegate: delegator, - caveats: [ - makeCaveat({ - type: 'allowedTargets', - terms: encodeAllowedTargets([TARGET]), - }), - ], - chainId: 11155111, - }); - - const DISALLOWED_TARGET = - '0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef' as Address; - const tx: TransactionRequest = { - from: delegator, - to: DISALLOWED_TARGET, - value: '0x0' as Hex, - data: '0x' as Hex, - }; - - // Must NOT silently bypass delegation enforcement - await expect(coordinator.sendTransaction(tx)).rejects.toThrow( - 'No delegation covers this transaction', - ); - expect(providerVat.broadcastTransaction).not.toHaveBeenCalled(); - expect(providerVat.submitUserOp).not.toHaveBeenCalled(); - }); - - it('falls back to broadcast when no matching delegation', async () => { - await coordinator.initializeKeyring({ - type: 'srp', - mnemonic: TEST_MNEMONIC, - }); - - await coordinator.configureBundler({ - bundlerUrl: 'https://bundler.example.com', - chainId: 1, - }); - - const accounts = await coordinator.getAccounts(); - const tx: TransactionRequest = { - from: accounts[0], - to: '0x70997970c51812dc3a010c7d01b50e0d17dc79c8' as Address, - value: '0xde0b6b3a7640000' as Hex, - chainId: 1, - nonce: 0, - maxFeePerGas: '0x3b9aca00' as Hex, - maxPriorityFeePerGas: '0x3b9aca00' as Hex, - }; - - const txHash = await coordinator.sendTransaction(tx); - expect(txHash).toBe('0xtxhash'); - expect(providerVat.broadcastTransaction).toHaveBeenCalled(); - expect(providerVat.submitUserOp).not.toHaveBeenCalled(); - }); - - it('does not fall back to peer transaction signing', async () => { - const mockPeerWallet = { - getAccounts: vi.fn().mockResolvedValue([]), - handleSigningRequest: vi.fn(), - }; - const freshBaggage = makeMockBaggage(); - freshBaggage.init('peerWallet', mockPeerWallet); - - const coord = buildRootObject( - {}, - undefined, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - freshBaggage as any, - ); - await coord.bootstrap({ provider: providerVat }, {}); - - const tx: TransactionRequest = { - from: '0x0000000000000000000000000000000000000099' as Address, - to: TARGET, - value: '0x0' as Hex, - chainId: 1, - }; - - await expect(coord.sendTransaction(tx)).rejects.toThrow( - 'No authority to sign this transaction', - ); - expect(mockPeerWallet.handleSigningRequest).not.toHaveBeenCalled(); - }); - - describe('provisioned twin routing', () => { - const TOKEN = '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as Address; - const RECIPIENT = '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' as Address; - const AMOUNT = 5n; - - beforeEach(async () => { - await coordinator.initializeKeyring({ - type: 'srp', - mnemonic: TEST_MNEMONIC, - }); - await coordinator.configureBundler({ - bundlerUrl: 'https://bundler.example.com', - chainId: 1, - }); - }); - - it('routes through a transfer twin using the transfer method', async () => { - const accounts = await coordinator.getAccounts(); - const grant = await coordinator.makeDelegationGrant('transfer', { - delegate: accounts[0], - token: TOKEN, - max: AMOUNT * 2n, - chainId: 1, - }); - await coordinator.provisionTwin(grant); - - const result = await coordinator.sendTransaction({ - from: accounts[0], - to: TOKEN, - data: encodeTransfer(RECIPIENT, AMOUNT), - value: '0x0' as Hex, - chainId: 1, - }); - expect(result).toBe('0xuserophash'); - }); - - it('rejects with a clear error when calldata is missing for a transfer twin', async () => { - // delegationMatchesAction skips the allowedMethods check when - // action.data is falsy, and skips erc20TransferAmount when the - // delegation doesn't have that caveat. A delegation with only - // allowedTargets therefore matches a no-calldata tx. The decode path - // must validate length before slicing or BigInt('0x') throws a - // SyntaxError with no useful context. - const accounts = await coordinator.getAccounts(); - - // Create a delegation with only allowedTargets (no erc20TransferAmount) - // so it will match the no-data tx below. - const delegation = await coordinator.createDelegation({ - delegate: accounts[0], - caveats: [ - makeCaveat({ - type: 'allowedTargets', - terms: encodeAllowedTargets([TOKEN]), - }), - ], - chainId: 1, - }); - - // Build the grant manually to attach a transfer twin without the - // erc20TransferAmount caveat that would otherwise guard the decode path. - const grant = { - delegation, - methodName: 'transfer', - caveatSpecs: [], - token: TOKEN, - }; - await coordinator.provisionTwin(grant); - - await expect( - coordinator.sendTransaction({ - from: accounts[0], - to: TOKEN, - // no data — delegation matches, twin found, decode must not crash - value: '0x0' as Hex, - chainId: 1, - }), - ).rejects.toThrow('calldata too short'); - }); - - it('routes through a call twin using the call method', async () => { - const accounts = await coordinator.getAccounts(); - const grant = await coordinator.makeDelegationGrant('call', { - delegate: accounts[0], - targets: [TARGET], - chainId: 1, - }); - await coordinator.provisionTwin(grant); - - const result = await coordinator.sendTransaction({ - from: accounts[0], - to: TARGET, - data: '0xdeadbeef' as Hex, - value: '0x0' as Hex, - chainId: 1, - }); - expect(result).toBe('0xuserophash'); - }); - }); - }); - - describe('signMessage', () => { - it('signs a message with local key', async () => { - await coordinator.initializeKeyring({ - type: 'srp', - mnemonic: TEST_MNEMONIC, - }); - - const signature = await coordinator.signMessage('Hello, world!'); - expect(signature).toMatch(/^0x/u); - expect(signature).toHaveLength(132); - }); - - it('rejects when no authority', async () => { - const emptyBaggage = makeMockBaggage(); - - const emptyCoordinator = buildRootObject( - {}, - undefined, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - emptyBaggage as any, - ); - await emptyCoordinator.bootstrap({ provider: providerVat }, {}); - - await expect(emptyCoordinator.signMessage('test')).rejects.toThrow( - 'No authority to sign message', - ); - }); - - it('falls back to peer wallet when no local keys and no external signer', async () => { - const mockPeerWallet = { - getAccounts: vi.fn().mockResolvedValue([]), - handleSigningRequest: vi - .fn() - .mockResolvedValue('0xpeersigmessage' as Hex), - }; - - const freshBaggage = makeMockBaggage(); - freshBaggage.init('peerWallet', mockPeerWallet); - - const coord = buildRootObject( - {}, - undefined, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - freshBaggage as any, - ); - await coord.bootstrap({ provider: providerVat }, {}); - - const signature = await coord.signMessage('Hello, world!'); - expect(signature).toBe('0xpeersigmessage'); - expect(mockPeerWallet.handleSigningRequest).toHaveBeenCalledWith({ - type: 'message', - message: 'Hello, world!', - }); - }); - }); - - describe('signTypedData', () => { - it('falls back to peer wallet when no local keys and no external signer', async () => { - const mockPeerWallet = { - getAccounts: vi.fn().mockResolvedValue([]), - handleSigningRequest: vi - .fn() - .mockResolvedValue('0xpeersigtyped' as Hex), - }; - - const freshBaggage = makeMockBaggage(); - freshBaggage.init('peerWallet', mockPeerWallet); - - const coord = buildRootObject( - {}, - undefined, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - freshBaggage as any, - ); - await coord.bootstrap({ provider: providerVat }, {}); - - const typedData: Eip712TypedData = { - domain: { name: 'Test' }, - types: { Test: [{ name: 'v', type: 'uint256' }] }, - primaryType: 'Test', - message: { v: '1' }, - }; - - const signature = await coord.signTypedData(typedData); - expect(signature).toBe('0xpeersigtyped'); - expect(mockPeerWallet.handleSigningRequest).toHaveBeenCalledWith({ - type: 'typedData', - data: typedData, - }); - }); - - it('rejects when no authority', async () => { - const freshBaggage = makeMockBaggage(); - - const coord = buildRootObject( - {}, - undefined, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - freshBaggage as any, - ); - await coord.bootstrap({ provider: providerVat }, {}); - - const typedData: Eip712TypedData = { - domain: { name: 'Test' }, - types: { Test: [{ name: 'v', type: 'uint256' }] }, - primaryType: 'Test', - message: { v: '1' }, - }; - - await expect(coord.signTypedData(typedData)).rejects.toThrow( - 'No authority to sign typed data', - ); - }); - }); - - describe('signing guard for peer accounts', () => { - const peerAddress = '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' as Address; - - it('routes signMessage to peer wallet when from is a peer account', async () => { - const mockPeerWallet = { - getAccounts: vi.fn().mockResolvedValue([peerAddress]), - handleSigningRequest: vi.fn().mockResolvedValue('0xpeersig' as Hex), - }; - - const freshBaggage = makeMockBaggage(); - freshBaggage.init('keyringVat', keyringVat); - freshBaggage.init('providerVat', providerVat); - freshBaggage.init('delegationVat', delegationVat); - freshBaggage.init('peerWallet', mockPeerWallet); - freshBaggage.init('cachedPeerAccounts', [peerAddress]); - - const coord = buildRootObject( - {}, - undefined, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - freshBaggage as any, - ); - await coord.initializeKeyring({ type: 'throwaway' }); - - const signature = await coord.signMessage('hello', peerAddress); - expect(signature).toBe('0xpeersig'); - expect(mockPeerWallet.handleSigningRequest).toHaveBeenCalledWith({ - type: 'message', - message: 'hello', - account: peerAddress, - }); - }); - - it('throws when signing as peer account and peer is offline', async () => { - const freshBaggage = makeMockBaggage(); - freshBaggage.init('keyringVat', keyringVat); - freshBaggage.init('providerVat', providerVat); - freshBaggage.init('delegationVat', delegationVat); - freshBaggage.init('cachedPeerAccounts', [peerAddress]); - - const coord = buildRootObject( - {}, - undefined, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - freshBaggage as any, - ); - await coord.initializeKeyring({ type: 'throwaway' }); - - await expect(coord.signMessage('hello', peerAddress)).rejects.toThrow( - 'home device is offline', - ); - }); - - it('throws when signing typed data as peer account and peer is offline', async () => { - const freshBaggage = makeMockBaggage(); - freshBaggage.init('keyringVat', keyringVat); - freshBaggage.init('providerVat', providerVat); - freshBaggage.init('delegationVat', delegationVat); - freshBaggage.init('cachedPeerAccounts', [peerAddress]); - - const coord = buildRootObject( - {}, - undefined, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - freshBaggage as any, - ); - await coord.initializeKeyring({ type: 'throwaway' }); - - const typedData: Eip712TypedData = { - domain: { name: 'Test' }, - types: { Test: [{ name: 'v', type: 'uint256' }] }, - primaryType: 'Test', - message: { v: '1' }, - }; - - await expect(coord.signTypedData(typedData, peerAddress)).rejects.toThrow( - 'home device is offline', - ); - }); - - it('routes signTypedData to peer wallet when from is a peer account', async () => { - const mockPeerWallet = { - getAccounts: vi.fn().mockResolvedValue([peerAddress]), - getCapabilities: vi.fn().mockResolvedValue({ signingMode: 'local' }), - handleSigningRequest: vi - .fn() - .mockResolvedValue( - '0xfeedfacefeedfacefeedfacefeedfacefeedfacefeedfacefeedfacefeedfacefeedfacefeedfacefeedfacefeedfacefeedfacefeedfacefeedfacefeedface1b', - ), - }; - - const freshBaggage = makeMockBaggage(); - freshBaggage.init('keyringVat', keyringVat); - freshBaggage.init('providerVat', providerVat); - freshBaggage.init('delegationVat', delegationVat); - freshBaggage.init('peerWallet', mockPeerWallet); - freshBaggage.init('cachedPeerAccounts', [peerAddress]); - - const coord = buildRootObject( - {}, - undefined, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - freshBaggage as any, - ); - await coord.initializeKeyring({ type: 'throwaway' }); - - const typedData: Eip712TypedData = { - domain: { name: 'Test' }, - types: { Test: [{ name: 'v', type: 'uint256' }] }, - primaryType: 'Test', - message: { v: '1' }, - }; - - const signature = await coord.signTypedData(typedData, peerAddress); - expect(signature).toMatch(/^0x/u); - expect(mockPeerWallet.handleSigningRequest).toHaveBeenCalledWith({ - type: 'typedData', - data: typedData, - account: peerAddress, - }); - }); - - it('signs with local key when from is not a peer account', async () => { - const freshBaggage = makeMockBaggage(); - freshBaggage.init('keyringVat', keyringVat); - freshBaggage.init('providerVat', providerVat); - freshBaggage.init('delegationVat', delegationVat); - freshBaggage.init('cachedPeerAccounts', [peerAddress]); - - const coord = buildRootObject( - {}, - undefined, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - freshBaggage as any, - ); - await coord.initializeKeyring({ type: 'throwaway' }); - - // Sign without specifying from — should use local key - const signature = await coord.signMessage('hello'); - expect(signature).toMatch(/^0x/u); - expect(signature).toHaveLength(132); - }); - }); - - describe('request', () => { - it('forwards call to provider vat', async () => { - providerVat.request.mockResolvedValueOnce('0x1'); - - const result = await coordinator.request('eth_chainId'); - expect(result).toBe('0x1'); - expect(providerVat.request).toHaveBeenCalledWith( - 'eth_chainId', - undefined, - ); - }); - - it('forwards params to provider vat', async () => { - providerVat.request.mockResolvedValueOnce('0xbalance'); - - const result = await coordinator.request('eth_getBalance', [ - '0x1234567890abcdef1234567890abcdef12345678', - 'latest', - ]); - expect(result).toBe('0xbalance'); - expect(providerVat.request).toHaveBeenCalledWith('eth_getBalance', [ - '0x1234567890abcdef1234567890abcdef12345678', - 'latest', - ]); - }); - - it('throws when provider not configured', async () => { - const freshBaggage = makeMockBaggage(); - const coord = buildRootObject( - {}, - undefined, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - freshBaggage as any, - ); - - await expect(coord.request('eth_chainId')).rejects.toThrow( - 'Provider not configured', - ); - }); - }); - - describe('delegation management', () => { - it('creates a delegation (full flow)', async () => { - await coordinator.initializeKeyring({ - type: 'srp', - mnemonic: TEST_MNEMONIC, - }); - - const delegation = await coordinator.createDelegation({ - delegate: '0x70997970c51812dc3a010c7d01b50e0d17dc79c8' as Address, - caveats: [ - makeCaveat({ - type: 'allowedTargets', - terms: encodeAllowedTargets([TARGET]), - }), - ], - chainId: 1, - }); - - expect(delegation.status).toBe('signed'); - expect(delegation.signature).toBeDefined(); - }); - - it('lists delegations', async () => { - await coordinator.initializeKeyring({ - type: 'srp', - mnemonic: TEST_MNEMONIC, - }); - - await coordinator.createDelegation({ - delegate: '0x70997970c51812dc3a010c7d01b50e0d17dc79c8' as Address, - caveats: [], - chainId: 1, - }); - - const delegations = await coordinator.listDelegations(); - expect(delegations).toHaveLength(1); - }); - }); - - describe('receiveDelegation', () => { - it('forwards delegation to delegation vat', async () => { - await coordinator.initializeKeyring({ - type: 'srp', - mnemonic: TEST_MNEMONIC, - }); - - const delegation = await coordinator.createDelegation({ - delegate: '0x70997970c51812dc3a010c7d01b50e0d17dc79c8' as Address, - caveats: [], - chainId: 1, - }); - - const freshDelegationVat = buildDelegationRoot( - {}, - {}, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - makeMockBaggage() as any, - ); - const freshBaggage = makeMockBaggage(); - const receiver = buildRootObject( - {}, - undefined, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - freshBaggage as any, - ); - await receiver.bootstrap( - { provider: providerVat, delegation: freshDelegationVat }, - {}, - ); - - await receiver.receiveDelegation(delegation); - - const stored = await freshDelegationVat.listDelegations(); - expect(stored).toHaveLength(1); - expect((stored as Delegation[])[0].id).toBe(delegation.id); - }); - - it('throws when delegation vat not available', async () => { - const freshBaggage = makeMockBaggage(); - const coord = buildRootObject( - {}, - undefined, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - freshBaggage as any, - ); - await coord.bootstrap({ provider: providerVat }, {}); - - await expect(coord.receiveDelegation({} as Delegation)).rejects.toThrow( - 'Delegation vat not available', - ); - }); - }); - - describe('revokeDelegation', () => { - it('submits on-chain disable and updates local status', async () => { - await coordinator.initializeKeyring({ - type: 'srp', - mnemonic: TEST_MNEMONIC, - }); - await coordinator.configureBundler({ - bundlerUrl: 'https://bundler.example.com/rpc', - chainId: 1, - }); - - const delegation = await coordinator.createDelegation({ - delegate: '0x70997970c51812dc3a010c7d01b50e0d17dc79c8' as Address, - caveats: [], - chainId: 1, - }); - - // Mock: receipt found immediately so waitForUserOpReceipt resolves - providerVat.getUserOpReceipt.mockResolvedValueOnce({ - success: true, - receipt: { transactionHash: '0xabc' }, - }); - - const userOpHash = await coordinator.revokeDelegation(delegation.id); - expect(userOpHash).toBe('0xuserophash'); - - // Verify local status is now revoked - const delegations = await coordinator.listDelegations(); - const found = (delegations as Delegation[]).find( - (entry) => entry.id === delegation.id, - ); - expect(found?.status).toBe('revoked'); - - // Verify UserOp was submitted - expect(providerVat.submitUserOp).toHaveBeenCalled(); - }); - - it('revokes via direct 7702 tx and polls eth_getTransactionReceipt', async () => { - await coordinator.initializeKeyring({ - type: 'srp', - mnemonic: TEST_MNEMONIC, - }); - - // Set up 7702 smart account (already delegated) - providerVat.request.mockResolvedValueOnce( - '0xef010063c0c19a282a1b52b07dd5a65b58948a07dae32b', - ); - await coordinator.createSmartAccount({ - chainId: 11155111, - implementation: 'stateless7702', - }); - - const delegation = await coordinator.createDelegation({ - delegate: '0x70997970c51812dc3a010c7d01b50e0d17dc79c8' as Address, - caveats: [], - chainId: 11155111, - }); - - // Mock: direct tx receipt found immediately - providerVat.request.mockImplementation(async (method: string) => { - if (method === 'eth_getTransactionReceipt') { - return { status: '0x1', transactionHash: '0xabc' }; - } - if (method === 'eth_estimateGas') { - return '0x5208' as Hex; - } - return undefined; - }); - - const hash = await coordinator.revokeDelegation(delegation.id); - expect(hash).toBe('0xtxhash'); - expect(providerVat.submitUserOp).not.toHaveBeenCalled(); - expect(providerVat.broadcastTransaction).toHaveBeenCalled(); - - // Verify local status is revoked - const delegations = await coordinator.listDelegations(); - const found = (delegations as Delegation[]).find( - (entry) => entry.id === delegation.id, - ); - expect(found?.status).toBe('revoked'); - }); - - it('throws when 7702 direct revocation reverts on-chain', async () => { - await coordinator.initializeKeyring({ - type: 'srp', - mnemonic: TEST_MNEMONIC, - }); - - providerVat.request.mockResolvedValueOnce( - '0xef010063c0c19a282a1b52b07dd5a65b58948a07dae32b', - ); - await coordinator.createSmartAccount({ - chainId: 11155111, - implementation: 'stateless7702', - }); - - const delegation = await coordinator.createDelegation({ - delegate: '0x70997970c51812dc3a010c7d01b50e0d17dc79c8' as Address, - caveats: [], - chainId: 11155111, - }); - - providerVat.request.mockImplementation(async (method: string) => { - if (method === 'eth_getTransactionReceipt') { - return { status: '0x0' }; - } - if (method === 'eth_estimateGas') { - return '0x5208' as Hex; - } - return undefined; - }); - - await expect(coordinator.revokeDelegation(delegation.id)).rejects.toThrow( - 'On-chain revocation reverted', - ); - - // Local status must NOT be updated - const delegations = await coordinator.listDelegations(); - const found = (delegations as Delegation[]).find( - (entry) => entry.id === delegation.id, - ); - expect(found?.status).toBe('signed'); - }); - - it('throws when bundler not configured for hybrid account', async () => { - await coordinator.initializeKeyring({ - type: 'srp', - mnemonic: TEST_MNEMONIC, - }); - - const delegation = await coordinator.createDelegation({ - delegate: '0x70997970c51812dc3a010c7d01b50e0d17dc79c8' as Address, - caveats: [], - chainId: 1, - }); - - await expect(coordinator.revokeDelegation(delegation.id)).rejects.toThrow( - 'Failed to submit on-chain revocation', - ); - }); - - it('throws when delegation already revoked', async () => { - await coordinator.initializeKeyring({ - type: 'srp', - mnemonic: TEST_MNEMONIC, - }); - await coordinator.configureBundler({ - bundlerUrl: 'https://bundler.example.com/rpc', - chainId: 1, - }); - - const delegation = await coordinator.createDelegation({ - delegate: '0x70997970c51812dc3a010c7d01b50e0d17dc79c8' as Address, - caveats: [], - chainId: 1, - }); - - // Revoke it first - providerVat.getUserOpReceipt.mockResolvedValueOnce({ success: true }); - await coordinator.revokeDelegation(delegation.id); - - // Second revoke should fail - await expect(coordinator.revokeDelegation(delegation.id)).rejects.toThrow( - 'already revoked', - ); - }); - - it('throws when on-chain UserOp reverts (success: false)', async () => { - await coordinator.initializeKeyring({ - type: 'srp', - mnemonic: TEST_MNEMONIC, - }); - await coordinator.configureBundler({ - bundlerUrl: 'https://bundler.example.com/rpc', - chainId: 1, - }); - - const delegation = await coordinator.createDelegation({ - delegate: '0x70997970c51812dc3a010c7d01b50e0d17dc79c8' as Address, - caveats: [], - chainId: 1, - }); - - // Mock: receipt returns success: false (on-chain revert) - providerVat.getUserOpReceipt.mockResolvedValueOnce({ - success: false, - receipt: { transactionHash: '0xabc' }, - }); - - await expect(coordinator.revokeDelegation(delegation.id)).rejects.toThrow( - 'On-chain revocation reverted', - ); - - // Verify local status is NOT updated to revoked - const delegations = await coordinator.listDelegations(); - const found = (delegations as Delegation[]).find( - (entry) => entry.id === delegation.id, - ); - expect(found?.status).toBe('signed'); - }); - - it('throws when delegation has pending status', async () => { - await coordinator.initializeKeyring({ - type: 'srp', - mnemonic: TEST_MNEMONIC, - }); - await coordinator.configureBundler({ - bundlerUrl: 'https://bundler.example.com/rpc', - chainId: 1, - }); - - // Create a delegation but don't sign it — it starts as 'pending' - // We need to access the delegation vat directly to get a pending delegation - const delegation = await coordinator.createDelegation({ - delegate: '0x70997970c51812dc3a010c7d01b50e0d17dc79c8' as Address, - caveats: [], - chainId: 1, - }); - - // The delegation is signed after createDelegation, so test the error - // message path by checking that an already-revoked delegation can't - // be revoked with a specific status message - providerVat.getUserOpReceipt.mockResolvedValueOnce({ success: true }); - await coordinator.revokeDelegation(delegation.id); - - // Now it's revoked — verify the specific error mentions status - await expect(coordinator.revokeDelegation(delegation.id)).rejects.toThrow( - 'already revoked', - ); - }); - - it('throws when delegation vat not available', async () => { - const freshBaggage = makeMockBaggage(); - const coord = buildRootObject( - {}, - undefined, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - freshBaggage as any, - ); - await coord.bootstrap({ provider: providerVat }, {}); - - await expect(coord.revokeDelegation('some-id')).rejects.toThrow( - 'Delegation vat not available', - ); - }); - }); - - describe('revokeDelegationLocally', () => { - it('marks a signed delegation as revoked without on-chain call', async () => { - await coordinator.initializeKeyring({ - type: 'srp', - mnemonic: TEST_MNEMONIC, - }); - - const delegation = await coordinator.createDelegation({ - delegate: '0x70997970c51812dc3a010c7d01b50e0d17dc79c8' as Address, - caveats: [], - chainId: 1, - }); - - await coordinator.revokeDelegationLocally(delegation.id); - - const delegations = await coordinator.listDelegations(); - const found = (delegations as Delegation[]).find( - (entry) => entry.id === delegation.id, - ); - expect(found?.status).toBe('revoked'); - - // No UserOp was submitted - expect(providerVat.submitUserOp).not.toHaveBeenCalled(); - }); - - it('silently ignores unknown delegation IDs', async () => { - await coordinator.initializeKeyring({ - type: 'srp', - mnemonic: TEST_MNEMONIC, - }); - - // Does not throw — delegations unchanged - await coordinator.revokeDelegationLocally('nonexistent-id'); - const delegations = await coordinator.listDelegations(); - expect(delegations).toStrictEqual([]); - }); - - it('is idempotent for already-revoked delegations', async () => { - await coordinator.initializeKeyring({ - type: 'srp', - mnemonic: TEST_MNEMONIC, - }); - - const delegation = await coordinator.createDelegation({ - delegate: '0x70997970c51812dc3a010c7d01b50e0d17dc79c8' as Address, - caveats: [], - chainId: 1, - }); - - await coordinator.revokeDelegationLocally(delegation.id); - // Second call should not throw - await coordinator.revokeDelegationLocally(delegation.id); - - const delegations = await coordinator.listDelegations(); - const found = (delegations as Delegation[]).find( - (entry) => entry.id === delegation.id, - ); - expect(found?.status).toBe('revoked'); - }); - }); - - describe('getCapabilities', () => { - it('reports wallet capabilities', async () => { - await coordinator.initializeKeyring({ - type: 'srp', - mnemonic: TEST_MNEMONIC, - }); - - const caps = await coordinator.getCapabilities(); - expect(caps).toStrictEqual({ - hasLocalKeys: true, - localAccounts: expect.arrayContaining([ - expect.stringMatching(/^0x[\da-f]{40}$/iu), - ]), - delegationCount: 0, - delegations: [], - hasPeerWallet: false, - hasExternalSigner: false, - hasBundlerConfig: false, - smartAccountAddress: undefined, - chainId: 1, - signingMode: 'local', - autonomy: 'no signing authority', - peerAccountsCached: false, - cachedPeerAccounts: [], - hasAwayWallet: false, - }); - }); - - it('reports autonomy for 7702 with delegations and no bundler', async () => { - await coordinator.initializeKeyring({ - type: 'srp', - mnemonic: TEST_MNEMONIC, - }); - - // Set up 7702 smart account (already delegated) — no bundler - providerVat.request.mockResolvedValueOnce( - '0xef010063c0c19a282a1b52b07dd5a65b58948a07dae32b', - ); - await coordinator.createSmartAccount({ - chainId: 11155111, - implementation: 'stateless7702', - }); - - // Create a delegation - await coordinator.createDelegation({ - delegate: '0x70997970c51812dc3a010c7d01b50e0d17dc79c8' as Address, - caveats: [], - chainId: 11155111, - }); - - // Configure provider so cachedProviderChainId is set to 11155111 - await coordinator.configureProvider({ - rpcUrl: 'https://sepolia.infura.io/v3/test', - chainId: 11155111, - }); - - const caps = await coordinator.getCapabilities(); - expect(caps.hasBundlerConfig).toBe(false); - expect(caps.autonomy).toMatch(/^autonomous/u); - expect(caps.chainId).toBe(11155111); - }); - - it('reports autonomy via peer relay when no bundler and no 7702', async () => { - const peerAddress = - '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' as Address; - const mockPeerWallet = { - getAccounts: vi.fn().mockResolvedValue([peerAddress]), - getCapabilities: vi.fn().mockResolvedValue({ signingMode: 'local' }), - handleSigningRequest: vi.fn(), - handleRedemptionRequest: vi.fn(), - registerAwayWallet: vi.fn().mockResolvedValue(undefined), - registerDelegateAddress: vi.fn().mockResolvedValue(undefined), - }; - - const freshBaggage = makeMockBaggage(); - freshBaggage.init('keyringVat', keyringVat); - freshBaggage.init('providerVat', providerVat); - freshBaggage.init('delegationVat', delegationVat); - freshBaggage.init('peerWallet', mockPeerWallet); - freshBaggage.init('cachedPeerAccounts', [peerAddress]); - - const coord = buildRootObject( - {}, - undefined, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - freshBaggage as any, - ); - - await coord.bootstrap( - { - keyring: keyringVat, - provider: providerVat, - delegation: delegationVat, - }, - {}, - ); - - await coord.initializeKeyring({ - type: 'srp', - mnemonic: TEST_MNEMONIC, - }); - - // Create delegation — no bundler, no 7702, but peer wallet is connected - await coord.createDelegation({ - delegate: '0x70997970c51812dc3a010c7d01b50e0d17dc79c8' as Address, - caveats: [], - chainId: 1, - }); - - const caps = await coord.getCapabilities(); - expect(caps.hasBundlerConfig).toBe(false); - expect(caps.hasPeerWallet).toBe(true); - expect(caps.autonomy).toMatch(/^autonomous/u); - expect(caps.autonomy).toContain('relay, requires home online'); - }); - }); - - describe('handleSigningRequest', () => { - it('rejects transaction signing requests', async () => { - await coordinator.initializeKeyring({ - type: 'srp', - mnemonic: TEST_MNEMONIC, - }); - - const accounts = await coordinator.getAccounts(); - await expect( - coordinator.handleSigningRequest({ - type: 'transaction', - tx: { - from: accounts[0], - to: '0x70997970c51812dc3a010c7d01b50e0d17dc79c8' as Address, - value: '0xde0b6b3a7640000' as Hex, - chainId: 1, - nonce: 0, - maxFeePerGas: '0x3b9aca00' as Hex, - maxPriorityFeePerGas: '0x3b9aca00' as Hex, - }, - }), - ).rejects.toThrow( - 'Peer transaction signing is disabled; use delegation redemption', - ); - }); - - it('handles message signing requests', async () => { - await coordinator.initializeKeyring({ - type: 'srp', - mnemonic: TEST_MNEMONIC, - }); - - const signed = await coordinator.handleSigningRequest({ - type: 'message', - message: 'Hello', - }); - - expect(signed).toMatch(/^0x/u); - }); - - it('rejects unknown request types', async () => { - await expect( - coordinator.handleSigningRequest({ type: 'unknown' }), - ).rejects.toThrow('Unknown signing request type'); - }); - }); - - describe('connectExternalSigner', () => { - it('stores external signer in baggage', async () => { - const extSigner = makeMockExternalSigner(); - await coordinator.connectExternalSigner(extSigner); - - expect(coordinatorBaggage.has('externalSigner')).toBe(true); - expect(coordinatorBaggage.get('externalSigner')).toBe(extSigner); - }); - - it('merges external signer accounts with local accounts', async () => { - await coordinator.initializeKeyring({ - type: 'srp', - mnemonic: TEST_MNEMONIC, - }); - - const extSigner = makeMockExternalSigner(); - await coordinator.connectExternalSigner(extSigner); - - const accounts = await coordinator.getAccounts(); - expect(accounts).toContain(EXT_SIGNER_ACCOUNT); - expect(accounts.length).toBeGreaterThanOrEqual(2); - }); - - it.each([ - ['null', null], - ['undefined', undefined], - ['a string', 'not-an-object'], - ['a number', 42], - ])( - 'rejects %s as external signer', - async (_label: string, signer: unknown) => { - await expect(coordinator.connectExternalSigner(signer)).rejects.toThrow( - 'Invalid external signer', - ); - }, - ); - - it('deduplicates accounts from external and local signers', async () => { - await coordinator.initializeKeyring({ - type: 'srp', - mnemonic: TEST_MNEMONIC, - }); - - const localAccounts = await coordinator.getAccounts(); - const extSigner = makeMockExternalSigner(); - extSigner.getAccounts.mockResolvedValue([localAccounts[0]]); - - await coordinator.connectExternalSigner(extSigner); - - const merged = await coordinator.getAccounts(); - expect(merged).toHaveLength(1); - }); - - it('returns only peer accounts when peer wallet is connected', async () => { - const peerAddress = - '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' as Address; - const mockPeerWallet = { - getAccounts: vi.fn().mockResolvedValue([peerAddress]), - handleSigningRequest: vi.fn().mockResolvedValue('0xpeersigned' as Hex), - }; - - const freshBaggage = makeMockBaggage(); - freshBaggage.init('keyringVat', keyringVat); - freshBaggage.init('providerVat', providerVat); - freshBaggage.init('delegationVat', delegationVat); - freshBaggage.init('peerWallet', mockPeerWallet); - - const coord = buildRootObject( - {}, - undefined, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - freshBaggage as any, - ); - - await coord.initializeKeyring({ type: 'throwaway' }); - const accounts = await coord.getAccounts(); - // Only peer accounts — local throwaway is hidden - expect(accounts).toStrictEqual([peerAddress]); - }); - - it('falls back to cached peer accounts when peer is offline', async () => { - const peerAddress = - '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' as Address; - const mockPeerWallet = { - getAccounts: vi.fn().mockRejectedValue(new Error('peer offline')), - handleSigningRequest: vi.fn(), - }; - - const freshBaggage = makeMockBaggage(); - freshBaggage.init('keyringVat', keyringVat); - freshBaggage.init('providerVat', providerVat); - freshBaggage.init('delegationVat', delegationVat); - freshBaggage.init('peerWallet', mockPeerWallet); - freshBaggage.init('cachedPeerAccounts', [peerAddress]); - - const coord = buildRootObject( - {}, - undefined, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - freshBaggage as any, - ); - - await coord.initializeKeyring({ type: 'throwaway' }); - const accounts = await coord.getAccounts(); - expect(accounts).toStrictEqual([peerAddress]); - }); - - it('returns cached peer accounts when peer wallet is no longer set', async () => { - const peerAddress = - '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' as Address; - - const freshBaggage = makeMockBaggage(); - freshBaggage.init('keyringVat', keyringVat); - freshBaggage.init('providerVat', providerVat); - freshBaggage.init('delegationVat', delegationVat); - freshBaggage.init('cachedPeerAccounts', [peerAddress]); - - const coord = buildRootObject( - {}, - undefined, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - freshBaggage as any, - ); - - await coord.initializeKeyring({ type: 'throwaway' }); - const accounts = await coord.getAccounts(); - expect(accounts).toStrictEqual([peerAddress]); - }); - - it('updates cached peer accounts on successful getAccounts', async () => { - const peerAddress = - '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' as Address; - const mockPeerWallet = { - getAccounts: vi.fn().mockResolvedValue([peerAddress]), - handleSigningRequest: vi.fn(), - }; - - const freshBaggage = makeMockBaggage(); - freshBaggage.init('keyringVat', keyringVat); - freshBaggage.init('providerVat', providerVat); - freshBaggage.init('delegationVat', delegationVat); - freshBaggage.init('peerWallet', mockPeerWallet); - - const coord = buildRootObject( - {}, - undefined, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - freshBaggage as any, - ); - - await coord.getAccounts(); - expect(freshBaggage.get('cachedPeerAccounts')).toStrictEqual([ - peerAddress, - ]); - }); - }); - - describe('refreshPeerAccounts', () => { - it('fetches and caches peer accounts', async () => { - const peerAddress = - '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' as Address; - const mockPeerWallet = { - getAccounts: vi.fn().mockResolvedValue([peerAddress]), - handleSigningRequest: vi.fn(), - }; - - const freshBaggage = makeMockBaggage(); - freshBaggage.init('keyringVat', keyringVat); - freshBaggage.init('providerVat', providerVat); - freshBaggage.init('delegationVat', delegationVat); - freshBaggage.init('peerWallet', mockPeerWallet); - - const coord = buildRootObject( - {}, - undefined, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - freshBaggage as any, - ); - - const accounts = await coord.refreshPeerAccounts(); - expect(accounts).toStrictEqual([peerAddress]); - expect(freshBaggage.get('cachedPeerAccounts')).toStrictEqual([ - peerAddress, - ]); - }); - - it('throws when no peer wallet is connected', async () => { - await expect(coordinator.refreshPeerAccounts()).rejects.toThrow( - 'No peer wallet connected', - ); - }); - }); - - describe('registerAwayWallet', () => { - it('stores the away wallet reference in baggage', async () => { - const mockAwayWallet = { - receiveDelegation: vi.fn().mockResolvedValue(undefined), - }; - - await coordinator.registerAwayWallet(mockAwayWallet); - expect(coordinatorBaggage.get('awayWallet')).toBe(mockAwayWallet); - }); - - it('reports hasAwayWallet in capabilities after registration', async () => { - const mockAwayWallet = { - receiveDelegation: vi.fn().mockResolvedValue(undefined), - }; - - await coordinator.registerAwayWallet(mockAwayWallet); - const caps = await coordinator.getCapabilities(); - expect(caps.hasAwayWallet).toBe(true); - }); - - it.each([ - ['null', null], - ['undefined', undefined], - ['a string', 'not-an-object'], - ['a number', 42], - ])( - 'rejects %s as away wallet reference', - async (_label: string, ref: unknown) => { - await expect(coordinator.registerAwayWallet(ref)).rejects.toThrow( - 'Invalid away wallet reference: must be a non-null object', - ); - }, - ); - - it('overwrites a previous away wallet reference', async () => { - const walletA = { - receiveDelegation: vi.fn().mockResolvedValue(undefined), - }; - const walletB = { - receiveDelegation: vi.fn().mockResolvedValue(undefined), - }; - - await coordinator.registerAwayWallet(walletA); - await coordinator.registerAwayWallet(walletB); - - const delegation: Delegation = { - id: 'del-overwrite', - delegator: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as Address, - delegate: '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' as Address, - authority: - '0xa0000000000000000000000000000000000000000000000000000000000000000' as Hex, - caveats: [], - salt: '0x01' as Hex, - signature: '0xsig' as Hex, - chainId: 1, - status: 'signed', - }; - - await coordinator.pushDelegationToAway(delegation); - expect(walletA.receiveDelegation).not.toHaveBeenCalled(); - expect(walletB.receiveDelegation).toHaveBeenCalledWith(delegation); - }); - - it('restores away wallet from baggage on resuscitation', async () => { - const mockAwayWallet = { - receiveDelegation: vi.fn().mockResolvedValue(undefined), - }; - - const freshBaggage = makeMockBaggage(); - freshBaggage.init('keyringVat', keyringVat); - freshBaggage.init('providerVat', providerVat); - freshBaggage.init('delegationVat', delegationVat); - freshBaggage.init('awayWallet', mockAwayWallet); - - const coord = buildRootObject( - {}, - undefined, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - freshBaggage as any, - ); - - const delegation: Delegation = { - id: 'del-resuscitate', - delegator: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as Address, - delegate: '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' as Address, - authority: - '0xa0000000000000000000000000000000000000000000000000000000000000000' as Hex, - caveats: [], - salt: '0x01' as Hex, - signature: '0xsig' as Hex, - chainId: 1, - status: 'signed', - }; - - await coord.pushDelegationToAway(delegation); - expect(mockAwayWallet.receiveDelegation).toHaveBeenCalledWith(delegation); - }); - }); - - describe('pushDelegationToAway', () => { - it('pushes a delegation to the away wallet', async () => { - const mockAwayWallet = { - receiveDelegation: vi.fn().mockResolvedValue(undefined), - }; - - await coordinator.registerAwayWallet(mockAwayWallet); - - const delegation: Delegation = { - id: 'del-push-1', - delegator: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as Address, - delegate: '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' as Address, - authority: - '0xa0000000000000000000000000000000000000000000000000000000000000000' as Hex, - caveats: [], - salt: '0x01' as Hex, - signature: '0xsig' as Hex, - chainId: 1, - status: 'signed', - }; - - await coordinator.pushDelegationToAway(delegation); - expect(mockAwayWallet.receiveDelegation).toHaveBeenCalledWith(delegation); - }); - - it('throws when no away wallet is registered', async () => { - const delegation: Delegation = { - id: 'del-push-2', - delegator: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as Address, - delegate: '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' as Address, - authority: - '0xa0000000000000000000000000000000000000000000000000000000000000000' as Hex, - caveats: [], - salt: '0x01' as Hex, - signature: '0xsig' as Hex, - chainId: 1, - status: 'signed', - }; - - await expect( - coordinator.pushDelegationToAway(delegation), - ).rejects.toThrow( - 'No away wallet registered. The away device must connect first.', - ); - }); - - it('propagates errors from receiveDelegation', async () => { - const mockAwayWallet = { - receiveDelegation: vi - .fn() - .mockRejectedValue(new Error('CapTP connection lost')), - }; - - await coordinator.registerAwayWallet(mockAwayWallet); - - const delegation: Delegation = { - id: 'del-push-error', - delegator: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as Address, - delegate: '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' as Address, - authority: - '0xa0000000000000000000000000000000000000000000000000000000000000000' as Hex, - caveats: [], - salt: '0x01' as Hex, - signature: '0xsig' as Hex, - chainId: 1, - status: 'signed', - }; - - await expect( - coordinator.pushDelegationToAway(delegation), - ).rejects.toThrow('CapTP connection lost'); - }); - }); - - describe('handleRedemptionRequest', () => { - it('executes single delegation redemption on behalf of peer', async () => { - await coordinator.initializeKeyring({ - type: 'srp', - mnemonic: TEST_MNEMONIC, - }); - - await coordinator.configureBundler({ - bundlerUrl: 'https://bundler.example.com', - chainId: 1, - }); - - const accounts = await coordinator.getAccounts(); - const delegator = accounts[0] as Address; - - const delegation = await coordinator.createDelegation({ - delegate: delegator, - caveats: [], - chainId: 1, - }); - - const result = await coordinator.handleRedemptionRequest({ - type: 'single', - delegations: [delegation], - execution: { - target: TARGET, - value: '0x0' as Hex, - callData: '0x' as Hex, - }, - maxFeePerGas: '0x3b9aca00' as Hex, - maxPriorityFeePerGas: '0x3b9aca00' as Hex, - }); - - expect(result).toBe('0xuserophash'); - }); - - it('executes batch delegation redemption on behalf of peer', async () => { - await coordinator.initializeKeyring({ - type: 'srp', - mnemonic: TEST_MNEMONIC, - }); - - await coordinator.configureBundler({ - bundlerUrl: 'https://bundler.example.com', - chainId: 1, - }); - - const accounts = await coordinator.getAccounts(); - const delegator = accounts[0] as Address; - - const delegation = await coordinator.createDelegation({ - delegate: delegator, - caveats: [], - chainId: 1, - }); - - const result = await coordinator.handleRedemptionRequest({ - type: 'batch', - delegations: [delegation], - executions: [ - { target: TARGET, value: '0x0' as Hex, callData: '0x' as Hex }, - { target: TARGET, value: '0x1' as Hex, callData: '0x' as Hex }, - ], - }); - - expect(result).toBe('0xuserophash'); - }); - - it('throws for empty delegations array', async () => { - await expect( - coordinator.handleRedemptionRequest({ - type: 'single', - delegations: [], - }), - ).rejects.toThrow('Missing or empty delegations in redemption request'); - }); - - it('throws for single request without execution', async () => { - await coordinator.initializeKeyring({ - type: 'srp', - mnemonic: TEST_MNEMONIC, - }); - - await coordinator.configureBundler({ - bundlerUrl: 'https://bundler.example.com', - chainId: 1, - }); - - const accounts = await coordinator.getAccounts(); - const delegator = accounts[0] as Address; - - const delegation = await coordinator.createDelegation({ - delegate: delegator, - caveats: [], - chainId: 1, - }); - - await expect( - coordinator.handleRedemptionRequest({ - type: 'single', - delegations: [delegation], - }), - ).rejects.toThrow('Missing execution in single redemption request'); - }); - - it('throws for batch request without executions', async () => { - await coordinator.initializeKeyring({ - type: 'srp', - mnemonic: TEST_MNEMONIC, - }); - - await coordinator.configureBundler({ - bundlerUrl: 'https://bundler.example.com', - chainId: 1, - }); - - const accounts = await coordinator.getAccounts(); - const delegator = accounts[0] as Address; - - const delegation = await coordinator.createDelegation({ - delegate: delegator, - caveats: [], - chainId: 1, - }); - - await expect( - coordinator.handleRedemptionRequest({ - type: 'batch', - delegations: [delegation], - }), - ).rejects.toThrow('Missing executions in batch redemption request'); - }); - - it('throws for unknown redemption request type', async () => { - await coordinator.initializeKeyring({ - type: 'srp', - mnemonic: TEST_MNEMONIC, - }); - - await coordinator.configureBundler({ - bundlerUrl: 'https://bundler.example.com', - chainId: 1, - }); - - const accounts = await coordinator.getAccounts(); - const delegator = accounts[0] as Address; - - const delegation = await coordinator.createDelegation({ - delegate: delegator, - caveats: [], - chainId: 1, - }); - - await expect( - coordinator.handleRedemptionRequest({ - type: 'unknown', - delegations: [delegation], - }), - ).rejects.toThrow('Unknown redemption request type: unknown'); - }); - - it('rejects relayed request when no bundler or 7702 configured', async () => { - // No bundler, no 7702 — cannot fulfill relayed requests - await coordinator.initializeKeyring({ - type: 'srp', - mnemonic: TEST_MNEMONIC, - }); - - const accounts = await coordinator.getAccounts(); - const delegator = accounts[0] as Address; - - const delegation = await coordinator.createDelegation({ - delegate: delegator, - caveats: [], - chainId: 1, - }); - - await expect( - coordinator.handleRedemptionRequest({ - type: 'single', - delegations: [delegation], - execution: { - target: TARGET, - value: '0x0' as Hex, - callData: '0x' as Hex, - }, - }), - ).rejects.toThrow( - 'Cannot fulfill relayed redemption: no bundler or direct 7702 configured', - ); - }); - }); - - describe('connectToPeer', () => { - it('registers the coordinator as away wallet on the home device', async () => { - const peerAddress = - '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' as Address; - const mockPeerWallet = { - getAccounts: vi.fn().mockResolvedValue([peerAddress]), - getCapabilities: vi.fn().mockResolvedValue({ signingMode: 'local' }), - handleSigningRequest: vi.fn(), - registerAwayWallet: vi.fn().mockResolvedValue(undefined), - }; - - const mockRedemption = { - redeem: vi.fn().mockResolvedValue(mockPeerWallet), - }; - - const freshBaggage = makeMockBaggage(); - freshBaggage.init('keyringVat', keyringVat); - freshBaggage.init('providerVat', providerVat); - freshBaggage.init('delegationVat', delegationVat); - - const coord = buildRootObject( - {}, - undefined, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - freshBaggage as any, - ); - - await coord.bootstrap( - { - keyring: keyringVat, - provider: providerVat, - delegation: delegationVat, - }, - { ocapURLRedemptionService: mockRedemption }, - ); - - await coord.connectToPeer('ocap:test@peer123'); - expect(mockPeerWallet.registerAwayWallet).toHaveBeenCalled(); - }); - - it('completes when peer does not support registerAwayWallet', async () => { - const peerAddress = - '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' as Address; - const mockPeerWallet = { - getAccounts: vi.fn().mockResolvedValue([peerAddress]), - getCapabilities: vi.fn().mockResolvedValue({ signingMode: 'local' }), - handleSigningRequest: vi.fn(), - registerAwayWallet: vi - .fn() - .mockRejectedValue(new Error('method not found')), - }; - - const mockRedemption = { - redeem: vi.fn().mockResolvedValue(mockPeerWallet), - }; - - const freshBaggage = makeMockBaggage(); - freshBaggage.init('keyringVat', keyringVat); - freshBaggage.init('providerVat', providerVat); - freshBaggage.init('delegationVat', delegationVat); - - const coord = buildRootObject( - {}, - undefined, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - freshBaggage as any, - ); - - await coord.bootstrap( - { - keyring: keyringVat, - provider: providerVat, - delegation: delegationVat, - }, - { ocapURLRedemptionService: mockRedemption }, - ); - - // Does not throw — gracefully degrades - await coord.connectToPeer('ocap:test@peer123'); - expect(mockPeerWallet.registerAwayWallet).toHaveBeenCalled(); - }); - }); - - describe('registerDelegateAddress', () => { - it('stores delegate address in baggage', async () => { - const addr = '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' as Address; - await coordinator.registerDelegateAddress(addr); - expect(coordinatorBaggage.get('pendingDelegateAddress')).toBe(addr); - }); - - it('returns delegate address via getDelegateAddress', async () => { - const addr = '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' as Address; - await coordinator.registerDelegateAddress(addr); - const result = await coordinator.getDelegateAddress(); - expect(result).toBe(addr); - }); - - it('returns undefined when no delegate address is set', async () => { - const result = await coordinator.getDelegateAddress(); - expect(result).toBeUndefined(); - }); - - it.each([ - ['empty string', ''], - ['not hex', 'not-an-address'], - ['too short', '0x1234'], - ['null', null], - ])( - 'rejects %s as delegate address', - async (_label: string, addr: unknown) => { - await expect(coordinator.registerDelegateAddress(addr)).rejects.toThrow( - 'Invalid delegate address', - ); - }, - ); - }); - - describe('sendDelegateAddressToPeer', () => { - it('sends delegate address to peer wallet', async () => { - const addr = '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' as Address; - const mockPeerWallet = { - getAccounts: vi.fn().mockResolvedValue([]), - getCapabilities: vi.fn().mockResolvedValue({ signingMode: 'local' }), - handleSigningRequest: vi.fn(), - registerAwayWallet: vi.fn().mockResolvedValue(undefined), - registerDelegateAddress: vi.fn().mockResolvedValue(undefined), - }; - - const mockRedemption = { - redeem: vi.fn().mockResolvedValue(mockPeerWallet), - }; - - const freshBaggage = makeMockBaggage(); - freshBaggage.init('keyringVat', keyringVat); - freshBaggage.init('providerVat', providerVat); - freshBaggage.init('delegationVat', delegationVat); - - const coord = buildRootObject( - {}, - undefined, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - freshBaggage as any, - ); - - await coord.bootstrap( - { - keyring: keyringVat, - provider: providerVat, - delegation: delegationVat, - }, - { ocapURLRedemptionService: mockRedemption }, - ); - - await coord.connectToPeer('ocap:test@peer123'); - await coord.sendDelegateAddressToPeer(addr); - expect(mockPeerWallet.registerDelegateAddress).toHaveBeenCalledWith(addr); - }); - - it('throws when no peer wallet is connected', async () => { - await expect( - coordinator.sendDelegateAddressToPeer( - '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', - ), - ).rejects.toThrow('No peer wallet connected'); - }); - }); - - describe('configureBundler', () => { - it('stores bundler config in baggage', async () => { - await coordinator.configureBundler({ - bundlerUrl: 'https://bundler.example.com', - chainId: 1, - }); - - expect(coordinatorBaggage.has('bundlerConfig')).toBe(true); - }); - - it('defaults entryPoint to ENTRY_POINT_V07', async () => { - await coordinator.configureBundler({ - bundlerUrl: 'https://bundler.example.com', - chainId: 1, - }); - - const config = coordinatorBaggage.get('bundlerConfig') as { - entryPoint: Hex; - }; - expect(config.entryPoint).toBe(ENTRY_POINT_V07); - }); - - it('accepts custom entryPoint', async () => { - const customEntryPoint = - '0x1111111111111111111111111111111111111111' as Hex; - await coordinator.configureBundler({ - bundlerUrl: 'https://bundler.example.com', - entryPoint: customEntryPoint, - chainId: 1, - }); - - const config = coordinatorBaggage.get('bundlerConfig') as { - entryPoint: Hex; - }; - expect(config.entryPoint).toBe(customEntryPoint); - }); - - it('reports hasBundlerConfig in capabilities', async () => { - await coordinator.initializeKeyring({ - type: 'srp', - mnemonic: TEST_MNEMONIC, - }); - - await coordinator.configureBundler({ - bundlerUrl: 'https://bundler.example.com', - chainId: 1, - }); - - const caps = await coordinator.getCapabilities(); - expect(caps.hasBundlerConfig).toBe(true); - }); - - it('rejects invalid bundler URL', async () => { - await expect( - coordinator.configureBundler({ - bundlerUrl: 'not-a-url', - chainId: 1, - }), - ).rejects.toThrow('Invalid bundler URL'); - }); - - it('rejects non-HTTP(S) bundler URL', async () => { - await expect( - coordinator.configureBundler({ - bundlerUrl: 'ftp://bundler.example.com', - chainId: 1, - }), - ).rejects.toThrow('Invalid bundler URL'); - }); - - it('rejects invalid chain ID', async () => { - await expect( - coordinator.configureBundler({ - bundlerUrl: 'https://bundler.example.com', - chainId: 0, - }), - ).rejects.toThrow('Invalid chain ID'); - - await expect( - coordinator.configureBundler({ - bundlerUrl: 'https://bundler.example.com', - chainId: -1, - }), - ).rejects.toThrow('Invalid chain ID'); - - await expect( - coordinator.configureBundler({ - bundlerUrl: 'https://bundler.example.com', - chainId: 1.5, - }), - ).rejects.toThrow('Invalid chain ID'); - }); - }); - - describe('signing with external signer', () => { - it('prefers keyring over external signer for signTypedData', async () => { - await coordinator.initializeKeyring({ - type: 'srp', - mnemonic: TEST_MNEMONIC, - }); - - const extSigner = makeMockExternalSigner(); - await coordinator.connectExternalSigner(extSigner); - - const typedData: Eip712TypedData = { - domain: { name: 'Test', version: '1', chainId: 1 }, - types: { - EIP712Domain: [ - { name: 'name', type: 'string' }, - { name: 'version', type: 'string' }, - { name: 'chainId', type: 'uint256' }, - ], - Test: [{ name: 'value', type: 'uint256' }], - }, - primaryType: 'Test', - message: { value: '42' }, - }; - - const signature = await coordinator.signTypedData(typedData); - expect(signature).toMatch(/^0x/u); - expect(extSigner.signTypedData).not.toHaveBeenCalled(); - }); - - it('falls back to external signer for signTypedData when no keyring', async () => { - const freshBaggage = makeMockBaggage(); - const extSigner = makeMockExternalSigner(); - freshBaggage.init('externalSigner', extSigner); - - const coord = buildRootObject( - {}, - undefined, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - freshBaggage as any, - ); - await coord.bootstrap({ provider: providerVat }, {}); - - const typedData: Eip712TypedData = { - domain: { name: 'Test' }, - types: { Test: [{ name: 'v', type: 'uint256' }] }, - primaryType: 'Test', - message: { v: '1' }, - }; - - const signature = await coord.signTypedData(typedData); - expect(signature).toBe( - '0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef00', - ); - expect(extSigner.signTypedData).toHaveBeenCalledWith( - typedData, - EXT_SIGNER_ACCOUNT, - ); - }); - - it('uses the requested local account for signTypedData', async () => { - await coordinator.initializeKeyring({ - type: 'srp', - mnemonic: TEST_MNEMONIC, - }); - const secondAccount = await keyringVat.deriveAccount(1); - - const typedData: Eip712TypedData = { - domain: { name: 'Test' }, - types: { Test: [{ name: 'v', type: 'uint256' }] }, - primaryType: 'Test', - message: { v: '1' }, - }; - - const signature = await coordinator.signTypedData( - typedData, - secondAccount, - ); - const expected = await keyringVat.signTypedData(typedData, secondAccount); - const firstAccountSignature = await keyringVat.signTypedData(typedData); - - expect(signature).toBe(expected); - expect(signature).not.toBe(firstAccountSignature); - }); - - it('throws when signTypedData requests an unknown local account', async () => { - await coordinator.initializeKeyring({ - type: 'srp', - mnemonic: TEST_MNEMONIC, - }); - - const typedData: Eip712TypedData = { - domain: { name: 'Test' }, - types: { Test: [{ name: 'v', type: 'uint256' }] }, - primaryType: 'Test', - message: { v: '1' }, - }; - - await expect( - coordinator.signTypedData( - typedData, - '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', - ), - ).rejects.toThrow( - 'No key for account 0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', - ); - }); - - it('falls back to external signer for signMessage when no keyring', async () => { - const freshBaggage = makeMockBaggage(); - const extSigner = makeMockExternalSigner(); - freshBaggage.init('externalSigner', extSigner); - - const coord = buildRootObject( - {}, - undefined, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - freshBaggage as any, - ); - await coord.bootstrap({ provider: providerVat }, {}); - - const signature = await coord.signMessage('Hello'); - expect(signature).toBe( - '0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef00', - ); - expect(extSigner.signMessage).toHaveBeenCalledWith( - 'Hello', - EXT_SIGNER_ACCOUNT, - ); - }); - - it('falls back to external signer for signTransaction when no local key', async () => { - const freshBaggage = makeMockBaggage(); - const extSigner = makeMockExternalSigner(); - freshBaggage.init('externalSigner', extSigner); - - const coord = buildRootObject( - {}, - undefined, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - freshBaggage as any, - ); - await coord.bootstrap({ provider: providerVat }, {}); - - const tx: TransactionRequest = { - from: '0x0000000000000000000000000000000000000099' as Address, - to: TARGET, - chainId: 1, - nonce: 0, - }; - - const signature = await coord.signTransaction(tx); - expect(signature).toBe( - '0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef00', - ); - expect(extSigner.signTransaction).toHaveBeenCalledWith(tx); - }); - - it('reports hasExternalSigner in capabilities', async () => { - const extSigner = makeMockExternalSigner(); - await coordinator.connectExternalSigner(extSigner); - - const caps = await coordinator.getCapabilities(); - expect(caps.hasExternalSigner).toBe(true); - }); - }); - - describe('handleSigningRequest with external signer', () => { - it('rejects transaction requests', async () => { - const freshBaggage = makeMockBaggage(); - const extSigner = makeMockExternalSigner(); - freshBaggage.init('externalSigner', extSigner); - - const coord = buildRootObject( - {}, - undefined, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - freshBaggage as any, - ); - - const tx: TransactionRequest = { - from: EXT_SIGNER_ACCOUNT, - to: TARGET, - chainId: 1, - nonce: 0, - }; - - await expect( - coord.handleSigningRequest({ - type: 'transaction', - tx, - }), - ).rejects.toThrow( - 'Peer transaction signing is disabled; use delegation redemption', - ); - expect(extSigner.signTransaction).not.toHaveBeenCalled(); - }); - - it('falls back to external signer for typedData requests', async () => { - const freshBaggage = makeMockBaggage(); - const extSigner = makeMockExternalSigner(); - freshBaggage.init('externalSigner', extSigner); - - const coord = buildRootObject( - {}, - undefined, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - freshBaggage as any, - ); - - const data: Eip712TypedData = { - domain: { name: 'Test' }, - types: { Test: [{ name: 'v', type: 'uint256' }] }, - primaryType: 'Test', - message: { v: '1' }, - }; - - const signed = await coord.handleSigningRequest({ - type: 'typedData', - data, - }); - expect(signed).toBe( - '0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef00', - ); - }); - - it('falls back to external signer for message requests', async () => { - const freshBaggage = makeMockBaggage(); - const extSigner = makeMockExternalSigner(); - freshBaggage.init('externalSigner', extSigner); - - const coord = buildRootObject( - {}, - undefined, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - freshBaggage as any, - ); - - const signed = await coord.handleSigningRequest({ - type: 'message', - message: 'test', - }); - expect(signed).toBe( - '0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef00', - ); - }); - - it('throws when no signer is available', async () => { - const freshBaggage = makeMockBaggage(); - - const coord = buildRootObject( - {}, - undefined, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - freshBaggage as any, - ); - - await expect( - coord.handleSigningRequest({ - type: 'typedData', - data: { - domain: { name: 'Test' }, - types: { Test: [{ name: 'v', type: 'uint256' }] }, - primaryType: 'Test', - message: { v: '1' }, - }, - }), - ).rejects.toThrow('No signer available to handle signing request'); - }); - }); - - describe('createDelegation with external signer', () => { - it('creates a delegation using external signer when no keyring', async () => { - const extSigner = makeMockExternalSigner(); - - const freshBaggage = makeMockBaggage(); - freshBaggage.init('externalSigner', extSigner); - - const freshDelegationVat = buildDelegationRoot( - {}, - {}, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - makeMockBaggage() as any, - ); - - const coord = buildRootObject( - {}, - undefined, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - freshBaggage as any, - ); - await coord.bootstrap( - { provider: providerVat, delegation: freshDelegationVat }, - {}, - ); - - const delegation = await coord.createDelegation({ - delegate: '0x70997970c51812dc3a010c7d01b50e0d17dc79c8' as Address, - caveats: [], - chainId: 1, - }); - - expect(delegation.status).toBe('signed'); - expect(delegation.signature).toBe( - '0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef00', - ); - expect(delegation.delegator).toBe(EXT_SIGNER_ACCOUNT); - expect(extSigner.signTypedData).toHaveBeenCalled(); - }); - - it('uses external owner account for smart-account delegation signing', async () => { - const extSigner = makeMockExternalSigner(); - const freshBaggage = makeMockBaggage(); - freshBaggage.init('externalSigner', extSigner); - - const freshDelegationVat = buildDelegationRoot( - {}, - {}, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - makeMockBaggage() as any, - ); - - const coord = buildRootObject( - {}, - undefined, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - freshBaggage as any, - ); - await coord.bootstrap( - { provider: providerVat, delegation: freshDelegationVat }, - {}, - ); - - const smartAddress = - '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' as Address; - await coord.createSmartAccount({ - chainId: 11155111, - address: smartAddress, - }); - - const delegation = await coord.createDelegation({ - delegate: '0x70997970c51812dc3a010c7d01b50e0d17dc79c8' as Address, - caveats: [], - chainId: 11155111, - }); - - expect(delegation.delegator).toBe(smartAddress); - expect(extSigner.signTypedData).toHaveBeenCalled(); - const [, from] = extSigner.signTypedData.mock.calls.at(-1) as [ - Eip712TypedData, - Address, - ]; - expect(from).toBe(EXT_SIGNER_ACCOUNT); - }); - - it('throws when neither keyring nor external signer is available', async () => { - const freshBaggage = makeMockBaggage(); - - const freshDelegationVat = buildDelegationRoot( - {}, - {}, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - makeMockBaggage() as any, - ); - - const coord = buildRootObject( - {}, - undefined, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - freshBaggage as any, - ); - await coord.bootstrap( - { provider: providerVat, delegation: freshDelegationVat }, - {}, - ); - - await expect( - coord.createDelegation({ - delegate: '0x70997970c51812dc3a010c7d01b50e0d17dc79c8' as Address, - caveats: [], - chainId: 1, - }), - ).rejects.toThrow('No accounts available to create delegation'); - }); - }); - - describe('redeemDelegation', () => { - it('redeems a delegation by ID via the UserOp pipeline', async () => { - await coordinator.initializeKeyring({ - type: 'srp', - mnemonic: TEST_MNEMONIC, - }); - - await coordinator.configureBundler({ - bundlerUrl: 'https://bundler.example.com', - chainId: 1, - }); - - const accounts = await coordinator.getAccounts(); - const delegator = accounts[0] as Address; - - // Create a signed delegation where delegator == delegate (self-delegation) - const delegation = await coordinator.createDelegation({ - delegate: delegator, - caveats: [ - makeCaveat({ - type: 'allowedTargets', - terms: encodeAllowedTargets([TARGET]), - }), - ], - chainId: 1, - }); - - const result = await coordinator.redeemDelegation({ - execution: { - target: TARGET, - value: '0x0' as Hex, - callData: '0x' as Hex, - }, - delegationId: delegation.id, - maxFeePerGas: '0x3b9aca00' as Hex, - maxPriorityFeePerGas: '0x3b9aca00' as Hex, - }); - - expect(result).toBe('0xuserophash'); - expect(providerVat.getEntryPointNonce).toHaveBeenCalledWith({ - entryPoint: ENTRY_POINT_V07, - sender: delegator, - }); - expect(providerVat.estimateUserOpGas).toHaveBeenCalledWith( - expect.objectContaining({ - bundlerUrl: 'https://bundler.example.com', - entryPoint: ENTRY_POINT_V07, - }), - ); - expect(providerVat.submitUserOp).toHaveBeenCalledWith( - expect.objectContaining({ - bundlerUrl: 'https://bundler.example.com', - entryPoint: ENTRY_POINT_V07, - userOp: expect.objectContaining({ - sender: delegator, - signature: expect.stringMatching(/^0x/u), - // Self-pay path: callGasLimit and verificationGasLimit get 10% buffer - callGasLimit: '0x58000', // 0x50000 + 10% - verificationGasLimit: '0x69999', // 0x60000 + 10% - // preVerificationGas must NOT be buffered - preVerificationGas: '0x10000', - }), - }), - ); - }); - - it('redeems a delegation by action match', async () => { - await coordinator.initializeKeyring({ - type: 'srp', - mnemonic: TEST_MNEMONIC, - }); - - await coordinator.configureBundler({ - bundlerUrl: 'https://bundler.example.com', - chainId: 1, - }); - - const accounts = await coordinator.getAccounts(); - const delegator = accounts[0] as Address; - - await coordinator.createDelegation({ - delegate: delegator, - caveats: [ - makeCaveat({ - type: 'allowedTargets', - terms: encodeAllowedTargets([TARGET]), - }), - ], - chainId: 1, - }); - - const result = await coordinator.redeemDelegation({ - execution: { - target: TARGET, - value: '0x0' as Hex, - callData: '0x' as Hex, - }, - action: { - to: TARGET, - value: '0x0' as Hex, - }, - maxFeePerGas: '0x3b9aca00' as Hex, - maxPriorityFeePerGas: '0x3b9aca00' as Hex, - }); - - expect(result).toBe('0xuserophash'); - }); - - it('throws when no matching delegation exists', async () => { - await coordinator.initializeKeyring({ - type: 'srp', - mnemonic: TEST_MNEMONIC, - }); - - await coordinator.configureBundler({ - bundlerUrl: 'https://bundler.example.com', - chainId: 1, - }); - - await expect( - coordinator.redeemDelegation({ - execution: { - target: TARGET, - value: '0x0' as Hex, - callData: '0x' as Hex, - }, - action: { - to: '0x0000000000000000000000000000000000000099' as Address, - }, - maxFeePerGas: '0x3b9aca00' as Hex, - maxPriorityFeePerGas: '0x3b9aca00' as Hex, - }), - ).rejects.toThrow('No matching delegation found'); - }); - - it('throws when bundler is not configured and no peer wallet', async () => { - await coordinator.initializeKeyring({ - type: 'srp', - mnemonic: TEST_MNEMONIC, - }); - - const accounts = await coordinator.getAccounts(); - const delegator = accounts[0] as Address; - - // Create a real signed delegation so we get past the lookup - const delegation = await coordinator.createDelegation({ - delegate: delegator, - caveats: [], - chainId: 1, - }); - - // No configureBundler call — should throw - await expect( - coordinator.redeemDelegation({ - execution: { - target: TARGET, - value: '0x0' as Hex, - callData: '0x' as Hex, - }, - delegations: [delegation], - maxFeePerGas: '0x3b9aca00' as Hex, - maxPriorityFeePerGas: '0x3b9aca00' as Hex, - }), - ).rejects.toThrow( - 'Bundler not configured and no peer wallet available for relay', - ); - }); - - it('relays single delegation redemption to peer when no bundler', async () => { - const peerAddress = - '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' as Address; - const mockPeerWallet = { - getAccounts: vi.fn().mockResolvedValue([peerAddress]), - getCapabilities: vi.fn().mockResolvedValue({ signingMode: 'local' }), - handleSigningRequest: vi.fn(), - handleRedemptionRequest: vi - .fn() - .mockResolvedValue('0xrelayedhash' as Hex), - registerAwayWallet: vi.fn().mockResolvedValue(undefined), - registerDelegateAddress: vi.fn().mockResolvedValue(undefined), - }; - - const freshBaggage = makeMockBaggage(); - freshBaggage.init('keyringVat', keyringVat); - freshBaggage.init('providerVat', providerVat); - freshBaggage.init('delegationVat', delegationVat); - freshBaggage.init('peerWallet', mockPeerWallet); - - const coord = buildRootObject( - {}, - undefined, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - freshBaggage as any, - ); - - await coord.bootstrap( - { - keyring: keyringVat, - provider: providerVat, - delegation: delegationVat, - }, - {}, - ); - - await coord.initializeKeyring({ - type: 'srp', - mnemonic: TEST_MNEMONIC, - }); - - const accounts = await coord.getAccounts(); - const delegator = accounts[0] as Address; - - const delegation = await coord.createDelegation({ - delegate: delegator, - caveats: [ - makeCaveat({ - type: 'allowedTargets', - terms: encodeAllowedTargets([TARGET]), - }), - ], - chainId: 1, - }); - - // No configureBundler — forces relay path - const result = await coord.redeemDelegation({ - execution: { - target: TARGET, - value: '0x0' as Hex, - callData: '0x' as Hex, - }, - delegationId: delegation.id, - maxFeePerGas: '0x3b9aca00' as Hex, - maxPriorityFeePerGas: '0x3b9aca00' as Hex, - }); - - expect(result).toBe('0xrelayedhash'); - expect(mockPeerWallet.handleRedemptionRequest).toHaveBeenCalledWith({ - type: 'single', - delegations: [expect.objectContaining({ id: delegation.id })], - execution: { - target: TARGET, - value: '0x0', - callData: '0x', - }, - maxFeePerGas: '0x3b9aca00', - maxPriorityFeePerGas: '0x3b9aca00', - }); - }); - - it('relays batch delegation redemption to peer when no bundler', async () => { - const peerAddress = - '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' as Address; - const mockPeerWallet = { - getAccounts: vi.fn().mockResolvedValue([peerAddress]), - getCapabilities: vi.fn().mockResolvedValue({ signingMode: 'local' }), - handleSigningRequest: vi.fn(), - handleRedemptionRequest: vi - .fn() - .mockResolvedValue('0xrelayedbatch' as Hex), - registerAwayWallet: vi.fn().mockResolvedValue(undefined), - registerDelegateAddress: vi.fn().mockResolvedValue(undefined), - }; - - const freshBaggage = makeMockBaggage(); - freshBaggage.init('keyringVat', keyringVat); - freshBaggage.init('providerVat', providerVat); - freshBaggage.init('delegationVat', delegationVat); - freshBaggage.init('peerWallet', mockPeerWallet); - - const coord = buildRootObject( - {}, - undefined, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - freshBaggage as any, - ); - - await coord.bootstrap( - { - keyring: keyringVat, - provider: providerVat, - delegation: delegationVat, - }, - {}, - ); - - await coord.initializeKeyring({ - type: 'srp', - mnemonic: TEST_MNEMONIC, - }); - - const accounts = await coord.getAccounts(); - const delegator = accounts[0] as Address; - - // Create delegation with allowedTargets for TARGET - const delegation = await coord.createDelegation({ - delegate: delegator, - caveats: [ - makeCaveat({ - type: 'allowedTargets', - terms: encodeAllowedTargets([TARGET]), - }), - ], - chainId: 1, - }); - - // sendBatchTransaction routes through delegation batch path - const result = await coord.sendBatchTransaction([ - { from: delegator, to: TARGET, value: '0x1' as Hex }, - { from: delegator, to: TARGET, value: '0x2' as Hex }, - ]); - - expect(result).toBe('0xrelayedbatch'); - expect(mockPeerWallet.handleRedemptionRequest).toHaveBeenCalledWith({ - type: 'batch', - delegations: [expect.objectContaining({ id: delegation.id })], - executions: [ - { target: TARGET, value: '0x1', callData: '0x' }, - { target: TARGET, value: '0x2', callData: '0x' }, - ], - }); - }); - - it('relays sendTransaction via peer when delegation matches and no bundler', async () => { - const peerAddress = - '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' as Address; - const mockPeerWallet = { - getAccounts: vi.fn().mockResolvedValue([peerAddress]), - getCapabilities: vi.fn().mockResolvedValue({ signingMode: 'local' }), - handleSigningRequest: vi.fn(), - handleRedemptionRequest: vi - .fn() - .mockResolvedValue('0xrelayedtx' as Hex), - registerAwayWallet: vi.fn().mockResolvedValue(undefined), - registerDelegateAddress: vi.fn().mockResolvedValue(undefined), - }; - - const freshBaggage = makeMockBaggage(); - freshBaggage.init('keyringVat', keyringVat); - freshBaggage.init('providerVat', providerVat); - freshBaggage.init('delegationVat', delegationVat); - freshBaggage.init('peerWallet', mockPeerWallet); - - const coord = buildRootObject( - {}, - undefined, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - freshBaggage as any, - ); - - await coord.bootstrap( - { - keyring: keyringVat, - provider: providerVat, - delegation: delegationVat, - }, - {}, - ); - - await coord.initializeKeyring({ - type: 'srp', - mnemonic: TEST_MNEMONIC, - }); - - const accounts = await coord.getAccounts(); - const delegator = accounts[0] as Address; - - await coord.createDelegation({ - delegate: delegator, - caveats: [ - makeCaveat({ - type: 'allowedTargets', - terms: encodeAllowedTargets([TARGET]), - }), - ], - chainId: 1, - }); - - // sendTransaction matches delegation and relays via peer - const result = await coord.sendTransaction({ - from: delegator, - to: TARGET, - value: '0x1' as Hex, - }); - - expect(result).toBe('0xrelayedtx'); - expect(mockPeerWallet.handleRedemptionRequest).toHaveBeenCalledWith( - expect.objectContaining({ type: 'single' }), - ); - }); - - it('propagates peer relay errors to the caller', async () => { - const peerAddress = - '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' as Address; - const mockPeerWallet = { - getAccounts: vi.fn().mockResolvedValue([peerAddress]), - getCapabilities: vi.fn().mockResolvedValue({ signingMode: 'local' }), - handleSigningRequest: vi.fn(), - handleRedemptionRequest: vi - .fn() - .mockRejectedValue(new Error('CapTP connection lost')), - registerAwayWallet: vi.fn().mockResolvedValue(undefined), - registerDelegateAddress: vi.fn().mockResolvedValue(undefined), - }; - - const freshBaggage = makeMockBaggage(); - freshBaggage.init('keyringVat', keyringVat); - freshBaggage.init('providerVat', providerVat); - freshBaggage.init('delegationVat', delegationVat); - freshBaggage.init('peerWallet', mockPeerWallet); - - const coord = buildRootObject( - {}, - undefined, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - freshBaggage as any, - ); - - await coord.bootstrap( - { - keyring: keyringVat, - provider: providerVat, - delegation: delegationVat, - }, - {}, - ); - - await coord.initializeKeyring({ - type: 'srp', - mnemonic: TEST_MNEMONIC, - }); - - const accounts = await coord.getAccounts(); - const delegator = accounts[0] as Address; - - const delegation = await coord.createDelegation({ - delegate: delegator, - caveats: [ - makeCaveat({ - type: 'allowedTargets', - terms: encodeAllowedTargets([TARGET]), - }), - ], - chainId: 1, - }); - - await expect( - coord.redeemDelegation({ - execution: { - target: TARGET, - value: '0x0' as Hex, - callData: '0x' as Hex, - }, - delegationId: delegation.id, - }), - ).rejects.toThrow( - 'Failed to relay delegation redemption to home wallet: CapTP connection lost', - ); - }); - - it('propagates batch peer relay errors to the caller', async () => { - const peerAddress = - '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' as Address; - const mockPeerWallet = { - getAccounts: vi.fn().mockResolvedValue([peerAddress]), - getCapabilities: vi.fn().mockResolvedValue({ signingMode: 'local' }), - handleSigningRequest: vi.fn(), - handleRedemptionRequest: vi - .fn() - .mockRejectedValue(new Error('peer disconnected')), - registerAwayWallet: vi.fn().mockResolvedValue(undefined), - registerDelegateAddress: vi.fn().mockResolvedValue(undefined), - }; - - const freshBaggage = makeMockBaggage(); - freshBaggage.init('keyringVat', keyringVat); - freshBaggage.init('providerVat', providerVat); - freshBaggage.init('delegationVat', delegationVat); - freshBaggage.init('peerWallet', mockPeerWallet); - - const coord = buildRootObject( - {}, - undefined, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - freshBaggage as any, - ); - - await coord.bootstrap( - { - keyring: keyringVat, - provider: providerVat, - delegation: delegationVat, - }, - {}, - ); - - await coord.initializeKeyring({ - type: 'srp', - mnemonic: TEST_MNEMONIC, - }); - - const accounts = await coord.getAccounts(); - const delegator = accounts[0] as Address; - - await coord.createDelegation({ - delegate: delegator, - caveats: [ - makeCaveat({ - type: 'allowedTargets', - terms: encodeAllowedTargets([TARGET]), - }), - ], - chainId: 1, - }); - - await expect( - coord.sendBatchTransaction([ - { from: delegator, to: TARGET, value: '0x1' as Hex }, - { from: delegator, to: TARGET, value: '0x2' as Hex }, - ]), - ).rejects.toThrow( - 'Failed to relay batch delegation redemption to home wallet: peer disconnected', - ); - }); - - it('throws for non-delegation batch when peer is set but no bundler', async () => { - const peerAddress = - '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' as Address; - const mockPeerWallet = { - getAccounts: vi.fn().mockResolvedValue([peerAddress]), - getCapabilities: vi.fn().mockResolvedValue({ signingMode: 'local' }), - handleSigningRequest: vi.fn(), - handleRedemptionRequest: vi.fn(), - registerAwayWallet: vi.fn().mockResolvedValue(undefined), - registerDelegateAddress: vi.fn().mockResolvedValue(undefined), - }; - - const freshBaggage = makeMockBaggage(); - freshBaggage.init('keyringVat', keyringVat); - freshBaggage.init('providerVat', providerVat); - freshBaggage.init('delegationVat', delegationVat); - freshBaggage.init('peerWallet', mockPeerWallet); - - const coord = buildRootObject( - {}, - undefined, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - freshBaggage as any, - ); - - await coord.bootstrap( - { - keyring: keyringVat, - provider: providerVat, - delegation: delegationVat, - }, - {}, - ); - - await coord.initializeKeyring({ - type: 'srp', - mnemonic: TEST_MNEMONIC, - }); - - const accounts = await coord.getAccounts(); - const sender = accounts[0] as Address; - - // Non-delegation target — no delegation covers this address - const otherTarget = - '0x9999999999999999999999999999999999999999' as Address; - - await expect( - coord.sendBatchTransaction([ - { from: sender, to: otherTarget, value: '0x1' as Hex }, - { from: sender, to: otherTarget, value: '0x2' as Hex }, - ]), - ).rejects.toThrow( - 'Non-delegation batch execution requires a bundler or direct 7702', - ); - }); - - it('rejects delegations with non-signed status', async () => { - await coordinator.initializeKeyring({ - type: 'srp', - mnemonic: TEST_MNEMONIC, - }); - - await coordinator.configureBundler({ - bundlerUrl: 'https://bundler.example.com', - chainId: 1, - }); - - const accounts = await coordinator.getAccounts(); - const delegator = accounts[0] as Address; - - // Create a delegation but don't sign it (stays pending) — use delegation vat directly - const pendingDelegation = await delegationVat.createDelegation({ - delegator, - delegate: delegator, - caveats: [], - chainId: 1, - }); - - await expect( - coordinator.redeemDelegation({ - execution: { - target: TARGET, - value: '0x0' as Hex, - callData: '0x' as Hex, - }, - delegationId: pendingDelegation.id, - maxFeePerGas: '0x3b9aca00' as Hex, - maxPriorityFeePerGas: '0x3b9aca00' as Hex, - }), - ).rejects.toThrow("has status 'pending', expected 'signed'"); - }); - - it('throws when no delegations, delegationId, or action provided', async () => { - await coordinator.initializeKeyring({ - type: 'srp', - mnemonic: TEST_MNEMONIC, - }); - - await coordinator.configureBundler({ - bundlerUrl: 'https://bundler.example.com', - chainId: 1, - }); - - await expect( - coordinator.redeemDelegation({ - execution: { - target: TARGET, - value: '0x0' as Hex, - callData: '0x' as Hex, - }, - maxFeePerGas: '0x3b9aca00' as Hex, - maxPriorityFeePerGas: '0x3b9aca00' as Hex, - }), - ).rejects.toThrow('Must provide delegations, delegationId, or action'); - }); - - it('accepts explicit delegation chains', async () => { - await coordinator.initializeKeyring({ - type: 'srp', - mnemonic: TEST_MNEMONIC, - }); - - await coordinator.configureBundler({ - bundlerUrl: 'https://bundler.example.com', - chainId: 1, - }); - - const accounts = await coordinator.getAccounts(); - const delegator = accounts[0] as Address; - - const delegation = await coordinator.createDelegation({ - delegate: delegator, - caveats: [], - chainId: 1, - }); - - const result = await coordinator.redeemDelegation({ - execution: { - target: TARGET, - value: '0x0' as Hex, - callData: '0x' as Hex, - }, - delegations: [delegation], - maxFeePerGas: '0x3b9aca00' as Hex, - maxPriorityFeePerGas: '0x3b9aca00' as Hex, - }); - - expect(result).toBe('0xuserophash'); - }); - - it('redeems via external signer when no keyring', async () => { - const extSigner = makeMockExternalSigner(); - const freshBaggage = makeMockBaggage(); - freshBaggage.init('externalSigner', extSigner); - - const freshDelegationVat = buildDelegationRoot( - {}, - {}, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - makeMockBaggage() as any, - ); - - const coord = buildRootObject( - {}, - undefined, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - freshBaggage as any, - ); - await coord.bootstrap( - { provider: providerVat, delegation: freshDelegationVat }, - {}, - ); - - await coord.configureBundler({ - bundlerUrl: 'https://bundler.example.com', - chainId: 1, - }); - - // Create delegation with external signer as delegator - const delegation = await coord.createDelegation({ - delegate: EXT_SIGNER_ACCOUNT, - caveats: [], - chainId: 1, - }); - - const result = await coord.redeemDelegation({ - execution: { - target: TARGET, - value: '0x0' as Hex, - callData: '0x' as Hex, - }, - delegationId: delegation.id, - maxFeePerGas: '0x3b9aca00' as Hex, - maxPriorityFeePerGas: '0x3b9aca00' as Hex, - }); - - expect(result).toBe('0xuserophash'); - // UserOp is now signed via signTypedData (EIP-712 for HybridDeleGator) - expect(extSigner.signTypedData).toHaveBeenCalled(); - }); - }); - - describe('getTokenBalance', () => { - it('returns the decoded balance', async () => { - // ABI-encoded uint256 for 1000000 - providerVat.request.mockResolvedValueOnce( - '0x00000000000000000000000000000000000000000000000000000000000f4240' as Hex, - ); - const balance = await coordinator.getTokenBalance({ - token: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as Address, - owner: TARGET, - }); - expect(balance).toBe('1000000'); - expect(providerVat.request).toHaveBeenCalledWith('eth_call', [ - expect.objectContaining({ - to: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', - data: expect.stringMatching(/^0x70a08231/u), - }), - 'latest', - ]); - }); - - it('throws when provider is not configured', async () => { - const bare = buildRootObject( - {}, - undefined, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - makeMockBaggage() as any, - ); - await expect( - bare.getTokenBalance({ - token: TARGET, - owner: TARGET, - }), - ).rejects.toThrow('Provider not configured'); - }); - }); - - describe('getTokenMetadata', () => { - it('returns name, symbol, and decimals', async () => { - // "USD Coin" as ABI-encoded string - const nameEncoded = [ - '0x', - '0000000000000000000000000000000000000000000000000000000000000020', - '0000000000000000000000000000000000000000000000000000000000000008', - '55534420436f696e000000000000000000000000000000000000000000000000', - ].join('') as Hex; - // "USDC" as ABI-encoded string - const symbolEncoded = [ - '0x', - '0000000000000000000000000000000000000000000000000000000000000020', - '0000000000000000000000000000000000000000000000000000000000000004', - '5553444300000000000000000000000000000000000000000000000000000000', - ].join('') as Hex; - const decimalsEncoded = - '0x0000000000000000000000000000000000000000000000000000000000000006' as Hex; - - providerVat.request - .mockResolvedValueOnce(nameEncoded) // name - .mockResolvedValueOnce(symbolEncoded) // symbol - .mockResolvedValueOnce(decimalsEncoded); // decimals - - const meta = await coordinator.getTokenMetadata({ - token: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as Address, - }); - expect(meta).toStrictEqual({ - name: 'USD Coin', - symbol: 'USDC', - decimals: 6, - }); - }); - - it('throws when RPC returns empty response', async () => { - providerVat.request - .mockResolvedValueOnce('0x') // name returns empty - .mockResolvedValueOnce('0x') - .mockResolvedValueOnce('0x'); - - await expect( - coordinator.getTokenMetadata({ - token: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as Address, - }), - ).rejects.toThrow(/returned unexpected value/u); - }); - - it('throws when provider is not configured', async () => { - const bare = buildRootObject( - {}, - undefined, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - makeMockBaggage() as any, - ); - await expect( - bare.getTokenMetadata({ - token: TARGET, - }), - ).rejects.toThrow('Provider not configured'); - }); - }); - - describe('sendErc20Transfer', () => { - it('routes through sendTransaction with encoded transfer calldata', async () => { - await coordinator.initializeKeyring({ - type: 'srp', - mnemonic: TEST_MNEMONIC, - }); - await coordinator.configureProvider({ chainId: 1, rpcUrl: 'http://rpc' }); - - const token = '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as Address; - const result = await coordinator.sendErc20Transfer({ - token, - to: TARGET, - amount: 1000n, - }); - expect(result).toBe('0xtxhash'); - expect(providerVat.broadcastTransaction).toHaveBeenCalled(); - - // Verify eth_estimateGas was called with correct ERC-20 tx shape - const estimateCall = providerVat.request.mock.calls.find( - (call: unknown[]) => call[0] === 'eth_estimateGas', - ); - expect(estimateCall).toBeDefined(); - const txParam = estimateCall[1][0]; - // to = token contract, not recipient - expect(txParam.to).toBe(token); - // value = 0 for ERC-20 - expect(txParam.value).toBe('0x0'); - // data starts with transfer selector - expect(txParam.data.slice(0, 10).toLowerCase()).toBe('0xa9059cbb'); - }); - - it('uses explicit from address when provided', async () => { - await coordinator.initializeKeyring({ - type: 'srp', - mnemonic: TEST_MNEMONIC, - }); - await coordinator.configureProvider({ chainId: 1, rpcUrl: 'http://rpc' }); - - const accounts = await coordinator.getAccounts(); - const result = await coordinator.sendErc20Transfer({ - token: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as Address, - to: TARGET, - amount: 500n, - from: accounts[0] as Address, - }); - expect(result).toBe('0xtxhash'); - }); - - it('throws when no accounts available', async () => { - await expect( - coordinator.sendErc20Transfer({ - token: TARGET, - to: TARGET, - amount: 100n, - }), - ).rejects.toThrow('No accounts available'); - }); - }); - - describe('waitForUserOpReceipt', () => { - it('returns receipt when found immediately', async () => { - await coordinator.configureBundler({ - bundlerUrl: 'https://bundler.example.com', - chainId: 1, - }); - - const receipt = { success: true, receipt: { transactionHash: '0xabc' } }; - providerVat.getUserOpReceipt.mockResolvedValueOnce(receipt); - - const result = await coordinator.waitForUserOpReceipt({ - userOpHash: '0xdeadbeef' as Hex, - }); - expect(result).toStrictEqual(receipt); - }); - - it('polls and returns receipt after delay', async () => { - vi.useFakeTimers(); - - await coordinator.configureBundler({ - bundlerUrl: 'https://bundler.example.com', - chainId: 1, - }); - - const receipt = { success: true }; - providerVat.getUserOpReceipt - .mockResolvedValueOnce(null) - .mockResolvedValueOnce(null) - .mockResolvedValueOnce(receipt); - - const resultPromise = coordinator.waitForUserOpReceipt({ - userOpHash: '0xdeadbeef' as Hex, - pollIntervalMs: 100, - }); - - // Advance through two polls - await vi.advanceTimersByTimeAsync(100); - await vi.advanceTimersByTimeAsync(100); - - const result = await resultPromise; - expect(result).toStrictEqual(receipt); - expect(providerVat.getUserOpReceipt).toHaveBeenCalledTimes(3); - - vi.useRealTimers(); - }); - - it('throws on timeout', async () => { - vi.useFakeTimers(); - - await coordinator.configureBundler({ - bundlerUrl: 'https://bundler.example.com', - chainId: 1, - }); - - providerVat.getUserOpReceipt.mockResolvedValue(null); - - const resultPromise = coordinator.waitForUserOpReceipt({ - userOpHash: '0xdeadbeef' as Hex, - pollIntervalMs: 100, - timeoutMs: 500, - }); - - // Advance past timeout - for (let i = 0; i < 10; i++) { - await vi.advanceTimersByTimeAsync(100); - } - - await expect(resultPromise).rejects.toThrow('not found after 500ms'); - - vi.useRealTimers(); - }); - - it('throws when provider and bundler are not configured', async () => { - const freshBaggage = makeMockBaggage(); - const coord = buildRootObject( - {}, - undefined, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - freshBaggage as any, - ); - - await expect( - coord.waitForUserOpReceipt({ - userOpHash: '0xdeadbeef' as Hex, - }), - ).rejects.toThrow('Provider and bundler must be configured'); - }); - }); - - describe('waitForTransactionReceipt', () => { - afterEach(() => { - vi.useRealTimers(); - }); - - it('returns success when receipt is found', async () => { - providerVat.request.mockResolvedValueOnce({ - status: '0x1', - transactionHash: '0xabc', - }); - - const result = await coordinator.waitForTransactionReceipt({ - txHash: '0xdeadbeef' as Hex, - }); - expect(result).toStrictEqual({ success: true }); - expect(providerVat.request).toHaveBeenCalledWith( - 'eth_getTransactionReceipt', - ['0xdeadbeef'], - ); - }); - - it('polls until receipt appears', async () => { - vi.useFakeTimers(); - - providerVat.request - .mockResolvedValueOnce(null) - .mockResolvedValueOnce({ status: '0x1' }); - - const resultPromise = coordinator.waitForTransactionReceipt({ - txHash: '0xabc' as Hex, - pollIntervalMs: 100, - }); - - await vi.advanceTimersByTimeAsync(100); - await vi.advanceTimersByTimeAsync(100); - - const result = await resultPromise; - expect(result.success).toBe(true); - expect(providerVat.request).toHaveBeenCalledTimes(2); - }); - - it('returns success: false for reverted transaction', async () => { - providerVat.request.mockResolvedValueOnce({ - status: '0x0', - transactionHash: '0xabc', - }); - - const result = await coordinator.waitForTransactionReceipt({ - txHash: '0xdeadbeef' as Hex, - }); - expect(result).toStrictEqual({ success: false }); - }); - - it('normalizes numeric status from provider', async () => { - providerVat.request.mockResolvedValueOnce({ - status: 1, - transactionHash: '0xabc', - }); - - const result = await coordinator.waitForTransactionReceipt({ - txHash: '0xdeadbeef' as Hex, - }); - expect(result).toStrictEqual({ success: true }); - }); - - it('treats missing status as success', async () => { - providerVat.request.mockResolvedValueOnce({ - transactionHash: '0xabc', - }); - - const result = await coordinator.waitForTransactionReceipt({ - txHash: '0xdeadbeef' as Hex, - }); - expect(result).toStrictEqual({ success: true }); - }); - - it('retries on transient RPC errors during polling', async () => { - providerVat.request - .mockRejectedValueOnce(new Error('network error')) - .mockResolvedValueOnce({ status: '0x1' }); - - const result = await coordinator.waitForTransactionReceipt({ - txHash: '0xabc' as Hex, - pollIntervalMs: 10, - }); - expect(result.success).toBe(true); - expect(providerVat.request).toHaveBeenCalledTimes(2); - }); - - it('throws on timeout when receipt never appears', async () => { - vi.useFakeTimers(); - - providerVat.request.mockResolvedValue(null); - - const resultPromise = coordinator.waitForTransactionReceipt({ - txHash: '0xabc' as Hex, - pollIntervalMs: 50, - timeoutMs: 200, - }); - - // Advance past the timeout - await vi.advanceTimersByTimeAsync(300); - - await expect(resultPromise).rejects.toThrow('not mined after'); - }); - - it('throws when provider is not configured', async () => { - const freshBaggage = makeMockBaggage(); - const coord = buildRootObject( - {}, - undefined, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - freshBaggage as any, - ); - - await expect( - coord.waitForTransactionReceipt({ txHash: '0xdeadbeef' as Hex }), - ).rejects.toThrow('Provider not configured'); - }); - }); - - describe('getTransactionReceipt', () => { - beforeEach(async () => { - await coordinator.initializeKeyring({ - type: 'srp', - mnemonic: TEST_MNEMONIC, - }); - }); - - it('throws when provider is not configured', async () => { - const coord = buildRootObject( - {}, - undefined, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - makeMockBaggage() as any, - ); - await coord.bootstrap({ keyring: keyringVat }, {}); - - await expect( - coord.getTransactionReceipt('0xdeadbeef' as Hex), - ).rejects.toThrow('Provider not configured'); - }); - - it('returns receipt from bundler when UserOp hash matches', async () => { - await coordinator.configureBundler({ - bundlerUrl: 'https://bundler.example.com', - chainId: 11155111, - }); - - providerVat.getUserOpReceipt.mockResolvedValueOnce({ - success: true, - receipt: { transactionHash: '0xabc123' }, - }); - - const result = await coordinator.getTransactionReceipt( - '0xdeadbeef' as Hex, - ); - - expect(result).toStrictEqual({ - txHash: '0xabc123', - userOpHash: '0xdeadbeef', - success: true, - }); - }); - - it('falls back to regular RPC when bundler returns null', async () => { - await coordinator.configureBundler({ - bundlerUrl: 'https://bundler.example.com', - chainId: 11155111, - }); - - providerVat.getUserOpReceipt.mockResolvedValueOnce(null); - providerVat.request.mockResolvedValueOnce({ - status: '0x1', - transactionHash: '0xregulartx', - }); - - const result = await coordinator.getTransactionReceipt( - '0xregulartx' as Hex, - ); - - expect(result).toStrictEqual({ - txHash: '0xregulartx', - success: true, - }); - }); - - it('falls back to regular RPC when bundler throws', async () => { - await coordinator.configureBundler({ - bundlerUrl: 'https://bundler.example.com', - chainId: 11155111, - }); - - providerVat.getUserOpReceipt.mockRejectedValueOnce( - new Error('not found'), - ); - providerVat.request.mockResolvedValueOnce({ - status: '0x1', - transactionHash: '0xregulartx', - }); - - const result = await coordinator.getTransactionReceipt( - '0xregulartx' as Hex, - ); - - expect(result).toStrictEqual({ - txHash: '0xregulartx', - success: true, - }); - }); - - it('returns null when receipt not found anywhere', async () => { - providerVat.request.mockResolvedValueOnce(null); - - const result = await coordinator.getTransactionReceipt( - '0xnonexistent' as Hex, - ); - - expect(result).toBeNull(); - }); - - it('maps status 0x0 as success: false', async () => { - providerVat.request.mockResolvedValueOnce({ - status: '0x0', - transactionHash: '0xreverted', - }); - - const result = await coordinator.getTransactionReceipt( - '0xreverted' as Hex, - ); - - expect(result).toStrictEqual({ - txHash: '0xreverted', - success: false, - }); - }); - }); - - describe('createSmartAccount', () => { - it('derives counterfactual address when not provided', async () => { - await coordinator.initializeKeyring({ - type: 'srp', - mnemonic: TEST_MNEMONIC, - }); - - const config = await coordinator.createSmartAccount({ - deploySalt: - '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex, - chainId: 11155111, - }); - - expect(config).toStrictEqual({ - implementation: 'hybrid', - deploySalt: - '0x0000000000000000000000000000000000000000000000000000000000000001', - address: DERIVED_SMART_ACCOUNT, - factory: MOCK_FACTORY, - factoryData: '0xfactorydata', - deployed: false, - }); - - expect(coordinatorBaggage.has('smartAccountConfig')).toBe(true); - }); - - it('stores explicit address when provided', async () => { - await coordinator.initializeKeyring({ - type: 'srp', - mnemonic: TEST_MNEMONIC, - }); - - const smartAddress = - '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' as Address; - - const config = await coordinator.createSmartAccount({ - deploySalt: - '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex, - chainId: 11155111, - address: smartAddress, - }); - - expect(config.address).toBe(smartAddress); - }); - - it('throws when no owner account is available', async () => { - const freshBaggage = makeMockBaggage(); - const coord = buildRootObject( - {}, - undefined, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - freshBaggage as any, - ); - await coord.bootstrap({ provider: providerVat }, {}); - - await expect( - coord.createSmartAccount({ - deploySalt: - '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex, - chainId: 11155111, - }), - ).rejects.toThrow('No owner account available'); - }); - - it('reports smartAccountAddress in capabilities', async () => { - await coordinator.initializeKeyring({ - type: 'srp', - mnemonic: TEST_MNEMONIC, - }); - - const smartAddress = - '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' as Address; - - await coordinator.createSmartAccount({ - deploySalt: - '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex, - chainId: 11155111, - address: smartAddress, - }); - - const caps = await coordinator.getCapabilities(); - expect(caps.smartAccountAddress).toBe(smartAddress); - }); - }); - - describe('getSmartAccountAddress', () => { - it('returns undefined when no smart account configured', async () => { - const address = await coordinator.getSmartAccountAddress(); - expect(address).toBeUndefined(); - }); - - it('returns derived address after smart account creation', async () => { - await coordinator.initializeKeyring({ - type: 'srp', - mnemonic: TEST_MNEMONIC, - }); - - await coordinator.createSmartAccount({ - deploySalt: - '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex, - chainId: 11155111, - }); - - const address = await coordinator.getSmartAccountAddress(); - expect(address).toBe(DERIVED_SMART_ACCOUNT); - }); - - it('returns explicit address after smart account creation', async () => { - await coordinator.initializeKeyring({ - type: 'srp', - mnemonic: TEST_MNEMONIC, - }); - - const smartAddress = - '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' as Address; - - await coordinator.createSmartAccount({ - deploySalt: - '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex, - chainId: 11155111, - address: smartAddress, - }); - - const address = await coordinator.getSmartAccountAddress(); - expect(address).toBe(smartAddress); - }); - }); - - describe('smart account delegation', () => { - it('uses smart account address as delegator when configured', async () => { - await coordinator.initializeKeyring({ - type: 'srp', - mnemonic: TEST_MNEMONIC, - }); - - const smartAddress = - '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' as Address; - await coordinator.createSmartAccount({ - deploySalt: - '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex, - chainId: 11155111, - address: smartAddress, - }); - - const delegation = await coordinator.createDelegation({ - delegate: '0x70997970c51812dc3a010c7d01b50e0d17dc79c8' as Address, - caveats: [], - chainId: 1, - }); - - expect(delegation.delegator).toBe(smartAddress); - expect(delegation.status).toBe('signed'); - }); - - it('signs with EOA owner when smart account is sender', async () => { - await coordinator.initializeKeyring({ - type: 'srp', - mnemonic: TEST_MNEMONIC, - }); - - const accounts = await coordinator.getAccounts(); - const eoaOwner = accounts[0] as Address; - - await coordinator.createSmartAccount({ - deploySalt: - '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex, - chainId: 11155111, - }); - - await coordinator.configureBundler({ - bundlerUrl: 'https://bundler.example.com', - chainId: 11155111, - }); - - // Create a self-delegation where delegator is the smart account - const delegation = await coordinator.createDelegation({ - delegate: DERIVED_SMART_ACCOUNT, - caveats: [ - makeCaveat({ - type: 'allowedTargets', - terms: encodeAllowedTargets([TARGET]), - }), - ], - chainId: 11155111, - }); - - const result = await coordinator.redeemDelegation({ - execution: { - target: TARGET, - value: '0x0' as Hex, - callData: '0x' as Hex, - }, - delegationId: delegation.id, - maxFeePerGas: '0x3b9aca00' as Hex, - maxPriorityFeePerGas: '0x3b9aca00' as Hex, - }); - - expect(result).toBe('0xuserophash'); - // The sender is the smart account but signing address should be the EOA - const submitCall = providerVat.submitUserOp.mock.calls[0][0]; - expect(submitCall.userOp.sender).toBe(DERIVED_SMART_ACCOUNT); - // Verify signature is present (signed by EOA) - expect(submitCall.userOp.signature).toMatch(/^0x/u); - expect(submitCall.userOp.signature).not.toBe('0x'); - // The EOA owner is used for derivation and signing - expect(eoaOwner).toMatch(/^0x[\da-f]{40}$/iu); - }); - }); - - describe('paymaster sponsorship', () => { - it('uses paymaster when usePaymaster is configured', async () => { - await coordinator.initializeKeyring({ - type: 'srp', - mnemonic: TEST_MNEMONIC, - }); - - await coordinator.configureBundler({ - bundlerUrl: 'https://bundler.example.com', - chainId: 1, - usePaymaster: true, - }); - - const accounts = await coordinator.getAccounts(); - const delegator = accounts[0] as Address; - - const delegation = await coordinator.createDelegation({ - delegate: delegator, - caveats: [ - makeCaveat({ - type: 'allowedTargets', - terms: encodeAllowedTargets([TARGET]), - }), - ], - chainId: 1, - }); - - const result = await coordinator.redeemDelegation({ - execution: { - target: TARGET, - value: '0x0' as Hex, - callData: '0x' as Hex, - }, - delegationId: delegation.id, - maxFeePerGas: '0x3b9aca00' as Hex, - maxPriorityFeePerGas: '0x3b9aca00' as Hex, - }); - - expect(result).toBe('0xuserophash'); - expect(providerVat.sponsorUserOp).toHaveBeenCalled(); - expect(providerVat.estimateUserOpGas).not.toHaveBeenCalled(); - - // Verify the submitted UserOp includes paymaster fields - const submitCall = providerVat.submitUserOp.mock.calls[0][0]; - expect(submitCall.userOp.paymaster).toBe( - '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', - ); - expect(submitCall.userOp.paymasterData).toBe('0xdeadbeef'); - - // Paymaster path: gas values must pass through unbuffered (they are - // part of the paymaster's signed commitment) - expect(submitCall.userOp.callGasLimit).toBe('0x50000'); - expect(submitCall.userOp.verificationGasLimit).toBe('0x60000'); - expect(submitCall.userOp.preVerificationGas).toBe('0x10000'); - }); - - it('passes sponsorshipPolicyId in context', async () => { - await coordinator.initializeKeyring({ - type: 'srp', - mnemonic: TEST_MNEMONIC, - }); - - await coordinator.configureBundler({ - bundlerUrl: 'https://bundler.example.com', - chainId: 1, - usePaymaster: true, - sponsorshipPolicyId: 'sp_my_policy', - }); - - const accounts = await coordinator.getAccounts(); - const delegator = accounts[0] as Address; - - const delegation = await coordinator.createDelegation({ - delegate: delegator, - caveats: [], - chainId: 1, - }); - - await coordinator.redeemDelegation({ - execution: { - target: TARGET, - value: '0x0' as Hex, - callData: '0x' as Hex, - }, - delegationId: delegation.id, - maxFeePerGas: '0x3b9aca00' as Hex, - maxPriorityFeePerGas: '0x3b9aca00' as Hex, - }); - - expect(providerVat.sponsorUserOp).toHaveBeenCalledWith( - expect.objectContaining({ - context: { sponsorshipPolicyId: 'sp_my_policy' }, - }), - ); - }); - - it('calls provider configureBundler during coordinator configureBundler', async () => { - await coordinator.configureBundler({ - bundlerUrl: 'https://bundler.example.com', - chainId: 1, - }); - - expect(providerVat.configureBundler).toHaveBeenCalledWith({ - bundlerUrl: 'https://bundler.example.com', - chainId: 1, - }); - }); - }); - - describe('createSmartAccount (stateless7702)', () => { - it('creates a 7702 smart account when EOA has no code', async () => { - vi.useFakeTimers(); - - await coordinator.initializeKeyring({ - type: 'srp', - mnemonic: TEST_MNEMONIC, - }); - - // eth_getCode returns empty (not yet delegated), eth_estimateGas for - // the authorization tx, then receipt confirms the tx - providerVat.request - .mockResolvedValueOnce('0x') // initial eth_getCode check - .mockResolvedValueOnce('0x19000') // eth_estimateGas for EIP-7702 auth - .mockResolvedValueOnce({ status: '0x1' }); // eth_getTransactionReceipt poll - - const configPromise = coordinator.createSmartAccount({ - chainId: 11155111, - implementation: 'stateless7702', - }); - - // Advance past the confirmation poll timeout - await vi.advanceTimersByTimeAsync(2000); - - const config = await configPromise; - const accounts = await coordinator.getAccounts(); - - expect(config.implementation).toBe('stateless7702'); - expect(config.address).toBe(accounts[0]); - expect(config.deployed).toBe(true); - expect(config.factory).toBeUndefined(); - expect(config.factoryData).toBeUndefined(); - expect(config.deploySalt).toBeUndefined(); - expect(coordinatorBaggage.has('smartAccountConfig')).toBe(true); - - // Should have broadcast the authorization tx (as type-4 EIP-7702) - expect(providerVat.broadcastTransaction).toHaveBeenCalled(); - const broadcastArg = providerVat.broadcastTransaction.mock - .calls[0][0] as string; - // EIP-7702 serialized tx starts with 0x04 - expect(broadcastArg.startsWith('0x04')).toBe(true); - - vi.useRealTimers(); - }); - - it('falls back to hardcoded gas when eth_estimateGas fails for EIP-7702', async () => { - vi.useFakeTimers(); - - await coordinator.initializeKeyring({ - type: 'srp', - mnemonic: TEST_MNEMONIC, - }); - - // eth_getCode returns empty, eth_estimateGas rejects, then receipt confirms - providerVat.request - .mockResolvedValueOnce('0x') // initial eth_getCode check - .mockRejectedValueOnce(new Error('method not supported')) // eth_estimateGas - .mockResolvedValueOnce({ status: '0x1' }); // eth_getTransactionReceipt poll - - const configPromise = coordinator.createSmartAccount({ - chainId: 11155111, - implementation: 'stateless7702', - }); - - await vi.advanceTimersByTimeAsync(2000); - - const config = await configPromise; - expect(config.implementation).toBe('stateless7702'); - expect(config.deployed).toBe(true); - - // Should have broadcast despite gas estimation failure (using fallback gas) - expect(providerVat.broadcastTransaction).toHaveBeenCalled(); - const broadcastArg = providerVat.broadcastTransaction.mock - .calls[0][0] as string; - expect(broadcastArg.startsWith('0x04')).toBe(true); - - vi.useRealTimers(); - }); - - it('skips tx when EOA is already 7702-delegated', async () => { - await coordinator.initializeKeyring({ - type: 'srp', - mnemonic: TEST_MNEMONIC, - }); - - // eth_getCode returns valid EIP-7702 designator - providerVat.request.mockResolvedValueOnce( - '0xef010063c0c19a282a1b52b07dd5a65b58948a07dae32b', - ); - - const config = await coordinator.createSmartAccount({ - chainId: 11155111, - implementation: 'stateless7702', - }); - - expect(config.implementation).toBe('stateless7702'); - expect(config.deployed).toBe(true); - // No broadcast needed - expect(providerVat.broadcastTransaction).not.toHaveBeenCalled(); - }); - - it('throws when keyring is not available', async () => { - const freshBaggage = makeMockBaggage(); - const coord = buildRootObject( - {}, - undefined, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - freshBaggage as any, - ); - await coord.bootstrap({ provider: providerVat }, {}); - - await expect( - coord.createSmartAccount({ - chainId: 11155111, - implementation: 'stateless7702', - }), - ).rejects.toThrow('No accounts available for EIP-7702 smart account'); - }); - - it('throws when provider is not available', async () => { - const freshBaggage = makeMockBaggage(); - const coord = buildRootObject( - {}, - undefined, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - freshBaggage as any, - ); - await coord.bootstrap({ keyring: keyringVat }, {}); - await coord.initializeKeyring({ type: 'throwaway' }); - - await expect( - coord.createSmartAccount({ - chainId: 11155111, - implementation: 'stateless7702', - }), - ).rejects.toThrow('Provider vat required'); - }); - - it('throws when keyring has no accounts', async () => { - const freshBaggage = makeMockBaggage(); - const emptyKeyring = { - ...keyringVat, - hasKeys: vi.fn().mockResolvedValue(false), - getAccounts: vi.fn().mockResolvedValue([]), - }; - const coord = buildRootObject( - {}, - undefined, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - freshBaggage as any, - ); - await coord.bootstrap( - { keyring: emptyKeyring, provider: providerVat }, - {}, - ); - - await expect( - coord.createSmartAccount({ - chainId: 11155111, - implementation: 'stateless7702', - }), - ).rejects.toThrow('No accounts available'); - }); - - it('throws on confirmation timeout', async () => { - vi.useFakeTimers(); - - await coordinator.initializeKeyring({ - type: 'srp', - mnemonic: TEST_MNEMONIC, - }); - - // eth_getCode returns empty (no EIP-7702 designator — e.g. Infura), - // and eth_getTransactionReceipt returns null (tx not mined yet). - providerVat.request.mockResolvedValue(null); - - const configPromise = coordinator.createSmartAccount({ - chainId: 11155111, - implementation: 'stateless7702', - }); - - // Advance through all 45 poll attempts (2s each) - for (let i = 0; i < 45; i++) { - await vi.advanceTimersByTimeAsync(2000); - } - - await expect(configPromise).rejects.toThrow('not confirmed after 90s'); - - vi.useRealTimers(); - }); - }); - - describe('redeemDelegation (stateless7702 direct tx)', () => { - /** - * Set up a 7702 smart account (already-delegated path). Optionally - * configure a bundler (unused for redemption on the direct 7702 path). - * - * @param options - Setup options. - * @param options.usePaymaster - Whether to enable paymaster on bundler config. - * @returns The EOA address. - */ - async function setup7702WithBundler(options?: { - usePaymaster?: boolean; - }): Promise
{ - await coordinator.initializeKeyring({ - type: 'srp', - mnemonic: TEST_MNEMONIC, - }); - - const accounts = await coordinator.getAccounts(); - const eoaAddress = accounts[0] as Address; - - // Set up 7702 smart account (already delegated) - providerVat.request.mockResolvedValueOnce( - '0xef010063c0c19a282a1b52b07dd5a65b58948a07dae32b', - ); - await coordinator.createSmartAccount({ - chainId: 11155111, - implementation: 'stateless7702', - }); - - await coordinator.configureBundler({ - bundlerUrl: 'https://bundler.example.com', - chainId: 11155111, - usePaymaster: options?.usePaymaster, - }); - - return eoaAddress; - } - - it('broadcasts a self-call EIP-1559 tx instead of a UserOp', async () => { - const eoaAddress = await setup7702WithBundler(); - - // Create a delegation - const delegation = await coordinator.createDelegation({ - delegate: eoaAddress, - caveats: [ - makeCaveat({ - type: 'allowedTargets', - terms: encodeAllowedTargets([TARGET]), - }), - ], - chainId: 11155111, - }); - - const result = await coordinator.redeemDelegation({ - execution: { - target: TARGET, - value: '0x0' as Hex, - callData: '0x' as Hex, - }, - delegationId: delegation.id, - maxFeePerGas: '0x3b9aca00' as Hex, - maxPriorityFeePerGas: '0x3b9aca00' as Hex, - }); - - expect(result).toBe('0xtxhash'); - expect(providerVat.submitUserOp).not.toHaveBeenCalled(); - expect(providerVat.broadcastTransaction).toHaveBeenCalledOnce(); - const signedRaw = providerVat.broadcastTransaction.mock - .calls[0][0] as Hex; - expect(typeof signedRaw).toBe('string'); - expect(signedRaw.startsWith('0x')).toBe(true); - }); - - it('uses direct broadcast for 7702 and UserOp submission for hybrid', async () => { - // --- 7702 path --- - const eoa7702 = await setup7702WithBundler(); - - const del7702 = await coordinator.createDelegation({ - delegate: eoa7702, - caveats: [], - chainId: 11155111, - }); - - await coordinator.redeemDelegation({ - execution: { - target: TARGET, - value: '0x0' as Hex, - callData: '0x' as Hex, - }, - delegationId: del7702.id, - maxFeePerGas: '0x3b9aca00' as Hex, - maxPriorityFeePerGas: '0x3b9aca00' as Hex, - }); - - expect(providerVat.broadcastTransaction).toHaveBeenCalled(); - expect(providerVat.submitUserOp).not.toHaveBeenCalled(); - - // --- Hybrid path (fresh coordinator) --- - const hybridBaggage = makeMockBaggage(); - const hybridKeyringBaggage = makeMockBaggage(); - const hybridDelegationBaggage = makeMockBaggage(); - - const hybridKeyring = buildKeyringRoot( - {}, - undefined, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - hybridKeyringBaggage as any, - ); - - const hybridDelegation = buildDelegationRoot( - {}, - {}, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - hybridDelegationBaggage as any, - ); - const hybridProvider = makeMockProviderVat(); - - const hybridCoord = buildRootObject( - {}, - undefined, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - hybridBaggage as any, - ); - await hybridCoord.bootstrap( - { - keyring: hybridKeyring, - provider: hybridProvider, - delegation: hybridDelegation, - }, - {}, - ); - await hybridCoord.initializeKeyring({ - type: 'srp', - mnemonic: TEST_MNEMONIC, - }); - await hybridCoord.createSmartAccount({ - deploySalt: - '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex, - chainId: 11155111, - }); - await hybridCoord.configureBundler({ - bundlerUrl: 'https://bundler.example.com', - chainId: 11155111, - }); - - const hybridAccounts = await hybridCoord.getAccounts(); - const delHybrid = await hybridCoord.createDelegation({ - delegate: hybridAccounts[0] as Address, - caveats: [], - chainId: 11155111, - }); - - await hybridCoord.redeemDelegation({ - execution: { - target: TARGET, - value: '0x0' as Hex, - callData: '0x' as Hex, - }, - delegationId: delHybrid.id, - maxFeePerGas: '0x3b9aca00' as Hex, - maxPriorityFeePerGas: '0x3b9aca00' as Hex, - }); - - expect(hybridProvider.submitUserOp).toHaveBeenCalled(); - const sigHybrid = - hybridProvider.submitUserOp.mock.calls[0][0].userOp.signature; - expect(sigHybrid).toMatch(/^0x/u); - }); - - it('does not use paymaster or UserOps for stateless7702 redemption', async () => { - const eoaAddress = await setup7702WithBundler({ usePaymaster: true }); - - const delegation = await coordinator.createDelegation({ - delegate: eoaAddress, - caveats: [], - chainId: 11155111, - }); - - const result = await coordinator.redeemDelegation({ - execution: { - target: TARGET, - value: '0x0' as Hex, - callData: '0x' as Hex, - }, - delegationId: delegation.id, - maxFeePerGas: '0x3b9aca00' as Hex, - maxPriorityFeePerGas: '0x3b9aca00' as Hex, - }); - - expect(result).toBe('0xtxhash'); - expect(providerVat.sponsorUserOp).not.toHaveBeenCalled(); - expect(providerVat.submitUserOp).not.toHaveBeenCalled(); - expect(providerVat.broadcastTransaction).toHaveBeenCalled(); - }); - }); - - // ------------------------------------------------------------------ - // Token swaps - // ------------------------------------------------------------------ - - describe('sendBatchTransaction', () => { - beforeEach(async () => { - await coordinator.initializeKeyring({ - type: 'srp', - mnemonic: TEST_MNEMONIC, - }); - }); - - it('throws when no transactions provided', async () => { - await expect(coordinator.sendBatchTransaction([])).rejects.toThrow( - 'No transactions to send', - ); - }); - - it('delegates to sendTransaction for a single tx', async () => { - providerVat.broadcastTransaction.mockResolvedValueOnce('0xsingletx'); - - const accounts = await coordinator.getAccounts(); - const result = await coordinator.sendBatchTransaction([ - { - from: accounts[0] as Address, - to: '0x1111111111111111111111111111111111111111' as Address, - value: '0x1' as Hex, - }, - ]); - - expect(result).toBe('0xsingletx'); - }); - - it('executes sequentially for EOA (no bundler)', async () => { - providerVat.broadcastTransaction - .mockResolvedValueOnce('0xtx1') - .mockResolvedValueOnce('0xtx2'); - - const accounts = await coordinator.getAccounts(); - const result = await coordinator.sendBatchTransaction([ - { - from: accounts[0] as Address, - to: '0x1111111111111111111111111111111111111111' as Address, - value: '0x1' as Hex, - }, - { - from: accounts[0] as Address, - to: '0x2222222222222222222222222222222222222222' as Address, - value: '0x2' as Hex, - }, - ]); - - expect(result).toStrictEqual(['0xtx1', '0xtx2']); - }); - - it('batches into single UserOp when bundler is configured', async () => { - await coordinator.configureBundler({ - bundlerUrl: 'https://bundler.example.com', - chainId: 11155111, - }); - - providerVat.request.mockImplementation(async (method: string) => { - if (method === 'eth_getCode') { - return '0x'; - } - if (method === 'eth_estimateGas') { - return '0x5208' as Hex; - } - return undefined; - }); - providerVat.submitUserOp.mockResolvedValueOnce('0xbatchhash'); - - const accounts = await coordinator.getAccounts(); - const result = await coordinator.sendBatchTransaction([ - { - from: accounts[0] as Address, - to: '0x1111111111111111111111111111111111111111' as Address, - value: '0x1' as Hex, - }, - { - from: accounts[0] as Address, - to: '0x2222222222222222222222222222222222222222' as Address, - value: '0x2' as Hex, - }, - ]); - - expect(result).toBe('0xbatchhash'); - expect(providerVat.submitUserOp).toHaveBeenCalledOnce(); - }); - - it('uses direct batch when delegation vat exists but no matching delegation', async () => { - await coordinator.configureBundler({ - bundlerUrl: 'https://bundler.example.com', - chainId: 11155111, - }); - await coordinator.createSmartAccount({ - implementation: 'hybrid', - deploySalt: '0x01' as Hex, - factory: MOCK_FACTORY, - }); - - // delegation vat is connected but has no delegations, so - // findDelegationForAction returns nothing → falls through to direct batch - providerVat.request.mockImplementation(async (method: string) => { - if (method === 'eth_getCode') { - return '0x'; - } - if (method === 'eth_estimateGas') { - return '0x5208' as Hex; - } - return undefined; - }); - providerVat.submitUserOp.mockResolvedValueOnce('0xdirectbatch'); - - const result = await coordinator.sendBatchTransaction([ - { - from: '0xcccccccccccccccccccccccccccccccccccccccc' as Address, - to: '0x1111111111111111111111111111111111111111' as Address, - value: '0x1' as Hex, - }, - { - from: '0xcccccccccccccccccccccccccccccccccccccccc' as Address, - to: '0x2222222222222222222222222222222222222222' as Address, - value: '0x2' as Hex, - }, - ]); - - expect(result).toBe('0xdirectbatch'); - expect(providerVat.submitUserOp).toHaveBeenCalledOnce(); - }); - - it('batches via delegation redemption using direct 7702 when no bundler', async () => { - // Set up 7702 smart account — no bundler - providerVat.request.mockResolvedValueOnce( - '0xef010063c0c19a282a1b52b07dd5a65b58948a07dae32b', - ); - await coordinator.createSmartAccount({ - chainId: 11155111, - implementation: 'stateless7702', - }); - - await coordinator.configureProvider({ - rpcUrl: 'https://sepolia.infura.io/v3/test', - chainId: 11155111, - }); - - const accounts = await coordinator.getAccounts(); - const delegator = accounts[0] as Address; - const target1 = '0x1111111111111111111111111111111111111111' as Address; - const target2 = '0x2222222222222222222222222222222222222222' as Address; - - await coordinator.createDelegation({ - delegate: delegator, - caveats: [ - makeCaveat({ - type: 'allowedTargets', - terms: encodeAllowedTargets([target1, target2]), - }), - ], - chainId: 11155111, - }); - - providerVat.request.mockImplementation(async (method: string) => { - if (method === 'eth_estimateGas') { - return '0x5208' as Hex; - } - return undefined; - }); - - const result = await coordinator.sendBatchTransaction([ - { from: delegator, to: target1, value: '0x1' as Hex }, - { from: delegator, to: target2, value: '0x2' as Hex }, - ]); - - expect(result).toBe('0xtxhash'); - expect(providerVat.submitUserOp).not.toHaveBeenCalled(); - expect(providerVat.broadcastTransaction).toHaveBeenCalledOnce(); - }); - - it('rejects batch to disallowed targets when delegations exist', async () => { - // Set up 7702 smart account — no bundler - providerVat.request.mockResolvedValueOnce( - '0xef010063c0c19a282a1b52b07dd5a65b58948a07dae32b', - ); - await coordinator.createSmartAccount({ - chainId: 11155111, - implementation: 'stateless7702', - }); - - await coordinator.configureProvider({ - rpcUrl: 'https://sepolia.infura.io/v3/test', - chainId: 11155111, - }); - - const accounts = await coordinator.getAccounts(); - const delegator = accounts[0] as Address; - - // Delegation only allows TARGET - await coordinator.createDelegation({ - delegate: delegator, - caveats: [ - makeCaveat({ - type: 'allowedTargets', - terms: encodeAllowedTargets([TARGET]), - }), - ], - chainId: 11155111, - }); - - // Batch to disallowed targets must NOT bypass caveat enforcement - await expect( - coordinator.sendBatchTransaction([ - { - from: delegator, - to: '0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef' as Address, - value: '0x1' as Hex, - }, - { - from: delegator, - to: '0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef' as Address, - value: '0x2' as Hex, - }, - ]), - ).rejects.toThrow('No single delegation covers all'); - expect(providerVat.broadcastTransaction).not.toHaveBeenCalled(); - expect(providerVat.submitUserOp).not.toHaveBeenCalled(); - }); - - it('batches via direct EIP-1559 when stateless7702 has no bundler', async () => { - providerVat.request.mockResolvedValueOnce( - '0xef010063c0c19a282a1b52b07dd5a65b58948a07dae32b', - ); - await coordinator.createSmartAccount({ - chainId: 11155111, - implementation: 'stateless7702', - }); - - providerVat.request.mockImplementation(async (method: string) => { - if (method === 'eth_getCode') { - return '0x'; - } - if (method === 'eth_estimateGas') { - return '0x5208' as Hex; - } - return undefined; - }); - - const accounts = await coordinator.getAccounts(); - const result = await coordinator.sendBatchTransaction([ - { - from: accounts[0] as Address, - to: '0x1111111111111111111111111111111111111111' as Address, - value: '0x1' as Hex, - }, - { - from: accounts[0] as Address, - to: '0x2222222222222222222222222222222222222222' as Address, - value: '0x2' as Hex, - }, - ]); - - expect(result).toBe('0xtxhash'); - expect(providerVat.submitUserOp).not.toHaveBeenCalled(); - expect(providerVat.broadcastTransaction).toHaveBeenCalledOnce(); - }); - }); - - describe('getSwapQuote', () => { - const SRC_TOKEN = '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' as Address; - const DEST_TOKEN = '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2' as Address; - const SRC_AMOUNT = '0xf4240' as Hex; // 1000000 - - beforeEach(async () => { - await coordinator.initializeKeyring({ - type: 'srp', - mnemonic: TEST_MNEMONIC, - }); - }); - - it('throws when provider is not configured', async () => { - // Build a coordinator without a provider vat - const coord = buildRootObject( - {}, - undefined, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - makeMockBaggage() as any, - ); - await coord.bootstrap( - { keyring: keyringVat, delegation: delegationVat }, - {}, - ); - await expect( - coord.getSwapQuote({ - srcToken: SRC_TOKEN, - destToken: DEST_TOKEN, - srcAmount: SRC_AMOUNT, - slippage: 1, - }), - ).rejects.toThrow('Provider not configured'); - }); - - it('throws for invalid slippage', async () => { - await expect( - coordinator.getSwapQuote({ - srcToken: SRC_TOKEN, - destToken: DEST_TOKEN, - srcAmount: SRC_AMOUNT, - slippage: 0, - }), - ).rejects.toThrow('Slippage must be between 0.1 and 50'); - }); - - it('throws when no quotes are returned', async () => { - providerVat.httpGetJson.mockResolvedValueOnce([]); - - await expect( - coordinator.getSwapQuote({ - srcToken: SRC_TOKEN, - destToken: DEST_TOKEN, - srcAmount: SRC_AMOUNT, - slippage: 1, - }), - ).rejects.toThrow('No swap quotes available'); - }); - - it('throws when all aggregators error', async () => { - providerVat.httpGetJson.mockResolvedValueOnce([ - { error: 'insufficient liquidity' }, - { error: 'pair not supported' }, - ]); - - await expect( - coordinator.getSwapQuote({ - srcToken: SRC_TOKEN, - destToken: DEST_TOKEN, - srcAmount: SRC_AMOUNT, - slippage: 1, - }), - ).rejects.toThrow('All swap aggregators returned errors'); - }); - - it('selects the best quote by destinationAmount', async () => { - const quotes = [ - { - trade: { - to: '0xrouter1', - from: '0xwallet', - data: '0xcalldata1', - value: '0x0', - gas: '0x30000', - }, - approvalNeeded: null, - sourceAmount: '1000000', - destinationAmount: '500000000000000', - aggregator: 'agg1', - fee: 0, - gasEstimate: '200000', - priceSlippage: 0.5, - quoteRefreshSeconds: 30, - }, - { - trade: { - to: '0xrouter2', - from: '0xwallet', - data: '0xcalldata2', - value: '0x0', - gas: '0x30000', - }, - approvalNeeded: null, - sourceAmount: '1000000', - destinationAmount: '600000000000000', - aggregator: 'agg2', - fee: 0, - gasEstimate: '250000', - priceSlippage: 0.3, - quoteRefreshSeconds: 30, - }, - ]; - providerVat.httpGetJson.mockResolvedValueOnce(quotes); - - const result = await coordinator.getSwapQuote({ - srcToken: SRC_TOKEN, - destToken: DEST_TOKEN, - srcAmount: SRC_AMOUNT, - slippage: 1, - }); - - expect(result.aggregator).toBe('agg2'); - expect(result.destinationAmount).toBe('600000000000000'); - }); - - it('skips errored aggregator entries', async () => { - const quotes = [ - { error: 'no liquidity' }, - { - trade: { - to: '0xrouter', - from: '0xwallet', - data: '0xcalldata', - value: '0x0', - gas: '0x30000', - }, - approvalNeeded: null, - sourceAmount: '1000000', - destinationAmount: '500000000000000', - aggregator: 'goodAgg', - fee: 0, - gasEstimate: '200000', - priceSlippage: 0.5, - quoteRefreshSeconds: 30, - }, - ]; - providerVat.httpGetJson.mockResolvedValueOnce(quotes); - - const result = await coordinator.getSwapQuote({ - srcToken: SRC_TOKEN, - destToken: DEST_TOKEN, - srcAmount: SRC_AMOUNT, - slippage: 1, - }); - - expect(result.aggregator).toBe('goodAgg'); - }); - - it('builds the correct MetaSwap URL', async () => { - providerVat.httpGetJson.mockResolvedValueOnce([ - { - trade: { - to: '0xrouter', - from: '0xwallet', - data: '0x', - value: '0x0', - gas: '0x30000', - }, - approvalNeeded: null, - sourceAmount: '1000000', - destinationAmount: '500000', - aggregator: 'test', - fee: 0, - gasEstimate: '200000', - priceSlippage: 0.5, - quoteRefreshSeconds: 30, - }, - ]); - - await coordinator.getSwapQuote({ - srcToken: SRC_TOKEN, - destToken: DEST_TOKEN, - srcAmount: SRC_AMOUNT, - slippage: 2.5, - }); - - const url = providerVat.httpGetJson.mock.calls[0][0] as string; - expect(url).toContain('swap.api.cx.metamask.io'); - expect(url).toContain(`sourceToken=${SRC_TOKEN.toLowerCase()}`); - expect(url).toContain(`destinationToken=${DEST_TOKEN.toLowerCase()}`); - expect(url).toContain('sourceAmount=1000000'); - expect(url).toContain('slippage=2.5'); - }); - }); - - describe('swapTokens', () => { - const SRC_TOKEN = '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' as Address; - const DEST_TOKEN = '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2' as Address; - const SRC_AMOUNT = '0xf4240' as Hex; - const ZERO_ADDRESS = - '0x0000000000000000000000000000000000000000' as Address; - - const SWAP_CALLDATA = '0xabcdef01' as Hex; - const APPROVAL_CALLDATA = '0x095ea7b3' as Hex; - - const makeQuoteResponse = (options?: { - approvalNeeded?: { to: string; data: string; value: string } | null; - }) => [ - { - trade: { - to: '0x3333333333333333333333333333333333333333', - from: '0xwallet', - data: SWAP_CALLDATA, - value: '0x0', - gas: '0x30000', - }, - approvalNeeded: options?.approvalNeeded ?? null, - sourceAmount: '1000000', - destinationAmount: '500000000000000', - aggregator: 'testAgg', - fee: 0, - gasEstimate: '200000', - priceSlippage: 0.5, - quoteRefreshSeconds: 30, - }, - ]; - - beforeEach(async () => { - await coordinator.initializeKeyring({ - type: 'srp', - mnemonic: TEST_MNEMONIC, - }); - }); - - it('executes a swap without approval (native ETH or sufficient allowance)', async () => { - providerVat.httpGetJson.mockResolvedValueOnce(makeQuoteResponse()); - providerVat.broadcastTransaction.mockResolvedValueOnce('0xswaptxhash'); - - const result = await coordinator.swapTokens({ - srcToken: SRC_TOKEN, - destToken: DEST_TOKEN, - srcAmount: SRC_AMOUNT, - slippage: 1, - }); - - expect(result.swapTxHash).toBe('0xswaptxhash'); - expect(result.approvalTxHash).toBeUndefined(); - expect(result.aggregator).toBe('testAgg'); - expect(result.sourceAmount).toBe('1000000'); - expect(result.destinationAmount).toBe('500000000000000'); - }); - - it('sends approval when approvalNeeded and insufficient allowance', async () => { - const approval = { - to: '0x3333333333333333333333333333333333333333', - data: APPROVAL_CALLDATA, - value: '0x0', - }; - providerVat.httpGetJson.mockResolvedValueOnce( - makeQuoteResponse({ approvalNeeded: approval }), - ); - - // Mock allowance check returning 0 - providerVat.request.mockImplementation( - async (method: string, params?: unknown[]) => { - if (method === 'eth_call') { - const callParams = params?.[0] as - | { to: string; data: string } - | undefined; - // Allowance call to src token - if ( - callParams?.to?.toLowerCase() === SRC_TOKEN.toLowerCase() && - callParams?.data?.startsWith('0xdd62ed3e') - ) { - return '0x0000000000000000000000000000000000000000000000000000000000000000'; - } - } - if (method === 'eth_estimateGas') { - return '0x5208' as Hex; - } - return undefined; - }, - ); - providerVat.broadcastTransaction - .mockResolvedValueOnce('0xapprovaltxhash') - .mockResolvedValueOnce('0xswaptxhash'); - - const result = await coordinator.swapTokens({ - srcToken: SRC_TOKEN, - destToken: DEST_TOKEN, - srcAmount: SRC_AMOUNT, - slippage: 1, - }); - - expect(result.approvalTxHash).toBe('0xapprovaltxhash'); - expect(result.swapTxHash).toBe('0xswaptxhash'); - }); - - it('skips approval when srcToken is native ETH (zero address)', async () => { - const approval = { - to: '0x3333333333333333333333333333333333333333', - data: APPROVAL_CALLDATA, - value: '0x0', - }; - providerVat.httpGetJson.mockResolvedValueOnce( - makeQuoteResponse({ approvalNeeded: approval }), - ); - providerVat.broadcastTransaction.mockResolvedValueOnce('0xswaptxhash'); - - const result = await coordinator.swapTokens({ - srcToken: ZERO_ADDRESS, - destToken: DEST_TOKEN, - srcAmount: SRC_AMOUNT, - slippage: 1, - }); - - expect(result.approvalTxHash).toBeUndefined(); - expect(result.swapTxHash).toBe('0xswaptxhash'); - }); - - it('skips approval when current allowance is sufficient', async () => { - const approval = { - to: '0x3333333333333333333333333333333333333333', - data: APPROVAL_CALLDATA, - value: '0x0', - }; - providerVat.httpGetJson.mockResolvedValueOnce( - makeQuoteResponse({ approvalNeeded: approval }), - ); - - // Mock allowance check returning more than srcAmount (0xf4240 = 1000000) - providerVat.request.mockImplementation( - async (method: string, params?: unknown[]) => { - if (method === 'eth_call') { - const callParams = params?.[0] as - | { to: string; data: string } - | undefined; - if ( - callParams?.to?.toLowerCase() === SRC_TOKEN.toLowerCase() && - callParams?.data?.startsWith('0xdd62ed3e') - ) { - // Return 2000000 allowance (more than 1000000 required) - return '0x00000000000000000000000000000000000000000000000000000000001e8480'; - } - } - if (method === 'eth_estimateGas') { - return '0x5208' as Hex; - } - return undefined; - }, - ); - providerVat.broadcastTransaction.mockResolvedValueOnce('0xswaptxhash'); - - const result = await coordinator.swapTokens({ - srcToken: SRC_TOKEN, - destToken: DEST_TOKEN, - srcAmount: SRC_AMOUNT, - slippage: 1, - }); - - expect(result.approvalTxHash).toBeUndefined(); - expect(result.swapTxHash).toBe('0xswaptxhash'); - }); - - it('batches approve + swap in a single UserOp when bundler is configured', async () => { - // Configure bundler to enable smart account (batch) path - await coordinator.configureBundler({ - bundlerUrl: 'https://bundler.example.com', - chainId: 11155111, - }); - - const approval = { - to: '0x3333333333333333333333333333333333333333', - data: APPROVAL_CALLDATA, - value: '0x0', - }; - providerVat.httpGetJson.mockResolvedValueOnce( - makeQuoteResponse({ approvalNeeded: approval }), - ); - - // Mock allowance check returning 0 (approval needed) - providerVat.request.mockImplementation( - async (method: string, params?: unknown[]) => { - if (method === 'eth_call') { - const callParams = params?.[0] as - | { to: string; data: string } - | undefined; - if ( - callParams?.to?.toLowerCase() === SRC_TOKEN.toLowerCase() && - callParams?.data?.startsWith('0xdd62ed3e') - ) { - return '0x0000000000000000000000000000000000000000000000000000000000000000'; - } - } - if (method === 'eth_getCode') { - return '0x'; - } - if (method === 'eth_estimateGas') { - return '0x5208' as Hex; - } - return undefined; - }, - ); - - // submitUserOp returns a single hash for the batched UserOp - providerVat.submitUserOp.mockResolvedValueOnce('0xbatchuserophash'); - - const result = await coordinator.swapTokens({ - srcToken: SRC_TOKEN, - destToken: DEST_TOKEN, - srcAmount: SRC_AMOUNT, - slippage: 1, - }); - - // Should return a single batched hash, no separate approval hash - expect(result.swapTxHash).toBe('0xbatchuserophash'); - expect(result.approvalTxHash).toBeUndefined(); - expect(result.batched).toBe(true); - }); - - it('includes approval tx hash in error when swap fails after approval', async () => { - const approval = { - to: '0x3333333333333333333333333333333333333333', - data: APPROVAL_CALLDATA, - value: '0x0', - }; - providerVat.httpGetJson.mockResolvedValueOnce( - makeQuoteResponse({ approvalNeeded: approval }), - ); - providerVat.request.mockImplementation( - async (method: string, params?: unknown[]) => { - if (method === 'eth_call') { - const callParams = params?.[0] as - | { to: string; data: string } - | undefined; - if (callParams?.data?.startsWith('0xdd62ed3e')) { - return '0x0000000000000000000000000000000000000000000000000000000000000000'; - } - } - if (method === 'eth_estimateGas') { - return '0x5208' as Hex; - } - return undefined; - }, - ); - providerVat.broadcastTransaction - .mockResolvedValueOnce('0xapproval123') - .mockRejectedValueOnce(new Error('execution reverted')); - - await expect( - coordinator.swapTokens({ - srcToken: SRC_TOKEN, - destToken: DEST_TOKEN, - srcAmount: SRC_AMOUNT, - slippage: 1, - }), - ).rejects.toThrow(/approval tx: 0xapproval123/u); - }); - }); -}); diff --git a/packages/evm-wallet-experiment/src/vats/coordinator-vat.ts b/packages/evm-wallet-experiment/src/vats/coordinator-vat.ts deleted file mode 100644 index 870b344e5e..0000000000 --- a/packages/evm-wallet-experiment/src/vats/coordinator-vat.ts +++ /dev/null @@ -1,3274 +0,0 @@ -import { E } from '@endo/eventual-send'; -import { makeDefaultExo } from '@metamask/kernel-utils/exo'; -import { Logger } from '@metamask/logger'; -import type { Baggage } from '@metamask/ocap-kernel'; - -import { - ENFORCER_CONTRACT_KEY_MAP, - PLACEHOLDER_CONTRACTS, - registerChainContracts, -} from '../constants.ts'; -import type { ChainContracts } from '../constants.ts'; -import { - buildDelegationGrant, - makeDelegationGrantBuilder, -} from '../lib/delegation-grant.ts'; -import { makeDelegationTwin } from '../lib/delegation-twin.ts'; -import { makeSaltGenerator } from '../lib/delegation.ts'; -import { - decodeAllowanceResult, - decodeBalanceOfResult, - decodeDecimalsResult, - decodeNameResult, - decodeSymbolResult, - encodeAllowance, - encodeBalanceOf, - encodeDecimals, - encodeName, - encodeSymbol, - encodeTransfer, -} from '../lib/erc20.ts'; -import type { CatalogMethodName } from '../lib/method-catalog.ts'; -import { - buildBatchExecuteCallData, - buildSdkBatchRedeemCallData, - buildSdkDisableCallData, - buildSdkRedeemCallData, - computeSmartAccountAddress, - isEip7702Delegated, - prepareUserOpTypedData, - registerEnvironment, - resolveEnvironment, - setSdkLogger, -} from '../lib/sdk.ts'; -import { ENTRY_POINT_V07 } from '../lib/userop.ts'; -import type { - Action, - Address, - Caveat, - ChainConfig, - CreateDelegationOptions, - Delegation, - DelegationGrant, - DelegationMatchResult, - Eip712TypedData, - Execution, - Hex, - SmartAccountConfig, - SwapQuote, - SwapResult, - TransactionRequest, - UserOperation, - WalletCapabilities, -} from '../types.ts'; - -const harden = globalThis.harden ?? ((value: T): T => value); - -/** - * Apply a percentage buffer to a hex gas value. - * - * @param gasHex - The gas value as a hex string. - * @param bufferPercent - The buffer percentage to add (e.g. 10 for 10%). - * @returns The buffered gas value as a hex string. - */ -function applyGasBuffer(gasHex: Hex, bufferPercent: number): Hex { - const gas = BigInt(gasHex); - const buffered = gas + (gas * BigInt(bufferPercent)) / 100n; - return `0x${buffered.toString(16)}`; -} - -/** - * Validate that an `eth_estimateGas` response is a valid hex string. - * - * @param result - The raw RPC response. - * @returns The validated hex string. - * @throws If the result is not a hex string. - */ -function validateGasEstimate(result: unknown): Hex { - if (typeof result !== 'string' || !result.startsWith('0x')) { - throw new Error( - `eth_estimateGas returned unexpected value: ${String(result)}`, - ); - } - return result as Hex; -} - -/** - * Validate that a token `eth_call` response is a usable hex string. - * - * @param result - The raw RPC response. - * @param method - The ERC-20 method name (for error context). - * @param token - The token address (for error context). - * @returns The validated hex string. - * @throws If the result is not a non-empty hex string. - */ -function validateTokenCallResult( - result: unknown, - method: string, - token: Address, -): Hex { - if ( - typeof result !== 'string' || - !result.startsWith('0x') || - result === '0x' - ) { - throw new Error( - `${method}() call to token ${token} returned unexpected value: ${String(result)}`, - ); - } - return result as Hex; -} - -/** - * Convert a wei amount in hex to a human-readable ETH string. - * - * @param weiHex - The wei amount as a hex string. - * @returns A formatted string like "1.5 ETH". - */ -function weiToEth(weiHex: string): string { - const wei = BigInt(weiHex); - const whole = wei / 10n ** 18n; - const frac = wei % 10n ** 18n; - if (frac === 0n) { - return `${String(whole)} ETH`; - } - const fracStr = frac.toString().padStart(18, '0').replace(/0+$/u, ''); - return `${String(whole)}.${fracStr} ETH`; -} - -/** - * Convert a caveat to a human-readable description. - * - * @param caveat - The caveat to describe. - * @returns A human-readable string describing the caveat's constraint. - */ -function describeCaveat(caveat: Caveat): string { - switch (caveat.type) { - case 'nativeTokenTransferAmount': - return `total spend limit: ${weiToEth(caveat.terms)}`; - case 'valueLte': - return `max per tx: ${weiToEth(caveat.terms)}`; - case 'allowedTargets': - return 'restricted target addresses'; - case 'allowedMethods': - return 'restricted methods'; - case 'limitedCalls': - return 'limited number of calls'; - case 'timestamp': - return 'time-limited'; - case 'erc20TransferAmount': { - // Packed encoding: 20-byte address + 32-byte uint256 = 52 bytes - // In hex string: '0x' + 40 address chars + 64 amount chars = 106 chars - if (caveat.terms.length >= 106) { - try { - const token = `0x${caveat.terms.slice(2, 42)}`; - const amount = BigInt(`0x${caveat.terms.slice(42)}`); - return `ERC-20 transfer limit: ${amount.toString()} units on ${token}`; - } catch { - // Fall through to generic description - } - } - return 'ERC-20 transfer limit'; - } - default: - return `${String(caveat.type)} enforced`; - } -} - -/** - * Vat powers for the coordinator vat. - */ -type VatPowers = { - logger?: Logger; -}; - -/** - * Vat references available in the wallet subcluster. - */ -type WalletVats = { - keyring?: unknown; - provider?: unknown; - delegation?: unknown; -}; - -/** - * Services available to the wallet subcluster. - */ -type WalletServices = { - ocapURLIssuerService?: unknown; - ocapURLRedemptionService?: unknown; -}; - -// Typed facets for E() calls (avoid `any` by using explicit method signatures) -type KeyringFacet = { - initialize: ( - options: { type: string; mnemonic?: string }, - password?: string, - salt?: string, - ) => Promise; - unlock: (password: string) => Promise; - isLocked: () => Promise; - hasKeys: () => Promise; - getAccounts: () => Promise; - deriveAccount: (index: number) => Promise
; - signTransaction: (tx: TransactionRequest) => Promise; - signTypedData: (data: Eip712TypedData, from?: Address) => Promise; - signMessage: (message: string, from?: Address) => Promise; - signHash: (hash: Hex, from?: Address) => Promise; - signAuthorization: (options: { - contractAddress: Address; - chainId: number; - nonce?: number; - from?: Address; - }) => Promise; -}; - -type ProviderFacet = { - configure: (config: ChainConfig) => Promise; - request: (method: string, params?: unknown[]) => Promise; - broadcastTransaction: (signedTx: Hex) => Promise; - getChainId: () => Promise; - getNonce: (address: Address) => Promise; - getEntryPointNonce: (options: { - entryPoint: Address; - sender: Address; - key?: Hex; - }) => Promise; - submitUserOp: (options: { - bundlerUrl: string; - entryPoint: Hex; - userOp: UserOperation; - }) => Promise; - estimateUserOpGas: (options: { - bundlerUrl: string; - entryPoint: Hex; - userOp: UserOperation; - }) => Promise<{ - callGasLimit: Hex; - verificationGasLimit: Hex; - preVerificationGas: Hex; - }>; - getUserOpReceipt: (options: { - bundlerUrl: string; - userOpHash: Hex; - }) => Promise; - getGasFees: () => Promise<{ - maxFeePerGas: Hex; - maxPriorityFeePerGas: Hex; - }>; - configureBundler: (config: { - bundlerUrl: string; - chainId: number; - }) => Promise; - httpGetJson: (url: string) => Promise; - getUserOperationGasPrice: () => Promise<{ - fast: { maxFeePerGas: Hex; maxPriorityFeePerGas: Hex }; - }>; - sponsorUserOp: (options: { - bundlerUrl: string; - entryPoint: Hex; - userOp: UserOperation; - context?: Record; - }) => Promise<{ - paymaster: Address; - paymasterData: Hex; - paymasterVerificationGasLimit: Hex; - paymasterPostOpGasLimit: Hex; - callGasLimit: Hex; - verificationGasLimit: Hex; - preVerificationGas: Hex; - }>; -}; - -type DelegationFacet = { - createDelegation: ( - options: CreateDelegationOptions & { delegator: Address }, - ) => Promise; - prepareDelegationForSigning: (id: string) => Promise; - storeSigned: (id: string, signature: Hex) => Promise; - receiveDelegation: (delegation: Delegation) => Promise; - findDelegationForAction: ( - action: Action, - chainId?: number, - currentTime?: number, - ) => Promise; - explainActionMatch: ( - action: Action, - chainId?: number, - currentTime?: number, - ) => Promise<{ delegationId: string; result: DelegationMatchResult }[]>; - getDelegation: (id: string) => Promise; - listDelegations: () => Promise; - revokeDelegation: (id: string) => Promise; -}; - -type PeerWalletFacet = { - getAccounts: () => Promise; - getCapabilities: () => Promise; - handleSigningRequest: (request: { - type: string; - tx?: TransactionRequest; - data?: Eip712TypedData; - message?: string; - account?: Address; - }) => Promise; - registerAwayWallet: (awayRef: unknown) => Promise; - registerDelegateAddress: (address: string) => Promise; - handleRedemptionRequest: (request: { - type: 'single' | 'batch'; - delegations: Delegation[]; - execution?: Execution; - executions?: Execution[]; - maxFeePerGas?: Hex; - maxPriorityFeePerGas?: Hex; - }) => Promise; -}; - -type ExternalSignerFacet = { - getAccounts: () => Promise; - signTypedData: (data: Eip712TypedData, from: Address) => Promise; - signMessage: (message: string, from: Address) => Promise; - signTransaction: (tx: TransactionRequest) => Promise; -}; - -type AwayWalletFacet = { - receiveDelegation: (delegation: Delegation) => Promise; - revokeDelegationLocally: (id: string) => Promise; -}; - -type OcapURLIssuerFacet = { - issue: (target: unknown) => Promise; -}; - -type OcapURLRedemptionFacet = { - redeem: (url: string) => Promise; -}; - -/** - * Build the root object for the coordinator vat (bootstrap vat). - * - * The coordinator orchestrates signing strategy resolution, delegation - * management, and peer wallet communication. It is the public API of - * the wallet subcluster. - * - * @param vatPowers - Special powers granted to this vat. - * @param _parameters - Initialization parameters. - * @param baggage - Root of vat's persistent state. - * @returns The root object for the coordinator vat. - */ -export function buildRootObject( - vatPowers: VatPowers, - _parameters: unknown, - baggage: Baggage, -): object { - const logger = (vatPowers.logger ?? new Logger()).subLogger({ - tags: ['coordinator-vat'], - }); - - // Wire SDK logger so resolveEnvironment/registerEnvironment are visible - setSdkLogger((level, message, data) => { - if (level === 'info') { - logger.info(message, data); - } else { - logger.debug(message, data); - } - }); - - // Per-vat salt generator so each vat instance has an independent counter - // rather than sharing the module-level one. When crypto.getRandomValues is - // available (Node.js, browsers) salts are random; the counter fallback is - // only used in strict SES compartments that do not endow crypto. - const grantBuilder = makeDelegationGrantBuilder({ - saltGenerator: makeSaltGenerator(), - }); - - // References to other vats (set during bootstrap) - let keyringVat: KeyringFacet | undefined; - let providerVat: ProviderFacet | undefined; - let delegationVat: DelegationFacet | undefined; - - // Twins provisioned via provisionTwin(), keyed by delegation ID. - // redeemFn/readFn closures cannot cross the CapTP vat boundary, so twins - // live here rather than in the delegation vat. - const coordinatorTwins = new Map< - string, - ReturnType - >(); - // Method name for each provisioned twin, used by sendTransaction to dispatch - // to the correct method (transfer/approve twins don't expose .call()). - const coordinatorTwinMethods = new Map(); - let issuerService: OcapURLIssuerFacet | undefined; - let redemptionService: OcapURLRedemptionFacet | undefined; - - // Peer wallet reference (set via connectToPeer) - let peerWallet: PeerWalletFacet | undefined; - - // External signer reference (e.g. MetaMask). - // Note: external signers are transient — they must be reconnected after - // kernel restart via connectExternalSigner(). The baggage entry tracks - // the reference but it may be stale after resuscitation. - let externalSigner: ExternalSignerFacet | undefined; - - // Bundler configuration for ERC-4337 UserOps - let bundlerConfig: - | { - bundlerUrl: string; - entryPoint: Hex; - chainId: number; - usePaymaster?: boolean; - sponsorshipPolicyId?: string; - environment?: { - EntryPoint: Hex; - DelegationManager: Hex; - SimpleFactory: Hex; - implementations: Record; - caveatEnforcers: Record; - }; - } - | undefined; - - // Smart account configuration (persisted in baggage) - let smartAccountConfig: SmartAccountConfig | undefined; - - // Away wallet reference (set via registerAwayWallet from the away device). - // Note: like externalSigner, this is a transient CapTP reference — it will - // be stale after kernel restart. The baggage entry is restored but the - // remote endpoint may be gone. pushDelegationToAway() will fail at call - // time if the reference is dead. - let awayWallet: AwayWalletFacet | undefined; - - // Delegate address sent by the away device for delegation creation - let pendingDelegateAddress: Address | undefined; - - // Cached peer (home) accounts for offline autonomy - let cachedPeerAccounts: Address[] = []; - // Cached peer signing mode for offline autonomy - let cachedPeerSigningMode: string | undefined; - - /** - * Typed helper for restoring values from baggage (resuscitation). - * - * @param key - The baggage key to look up. - * @returns The stored value cast to T, or undefined if not present. - */ - function restoreFromBaggage(key: string): T | undefined { - return baggage.has(key) ? (baggage.get(key) as T) : undefined; - } - - // Restore vat references from baggage if available (resuscitation) - keyringVat = restoreFromBaggage('keyringVat'); - providerVat = restoreFromBaggage('providerVat'); - delegationVat = restoreFromBaggage('delegationVat'); - peerWallet = restoreFromBaggage('peerWallet'); - externalSigner = restoreFromBaggage('externalSigner'); - bundlerConfig = restoreFromBaggage('bundlerConfig'); - if (bundlerConfig?.environment) { - registerEnvironment(bundlerConfig.chainId, bundlerConfig.environment); - } - smartAccountConfig = - restoreFromBaggage('smartAccountConfig'); - awayWallet = restoreFromBaggage('awayWallet'); - pendingDelegateAddress = restoreFromBaggage
( - 'pendingDelegateAddress', - ); - cachedPeerAccounts = - restoreFromBaggage('cachedPeerAccounts') ?? []; - cachedPeerSigningMode = restoreFromBaggage('cachedPeerSigningMode'); - - /** Chain ID from the last `configureProvider` call (avoids RPC on every send). */ - let cachedProviderChainId: number | undefined = restoreFromBaggage( - 'cachedProviderChainId', - ); - - /** - * Resolve the wallet chain ID for delegation matching, SDK addresses, and txs. - * - * Order: bundler config → cached provider config → `eth_chainId` RPC. - * - * @returns The resolved chain ID. - */ - async function resolveChainId(): Promise { - if (bundlerConfig?.chainId !== undefined) { - return bundlerConfig.chainId; - } - if (cachedProviderChainId !== undefined) { - return cachedProviderChainId; - } - if (!providerVat) { - throw new Error( - 'Provider not configured — call configureProvider() first', - ); - } - return E(providerVat).getChainId(); - } - - /** - * Whether smart-account operations for this sender should use Infura-style - * raw transactions (stateless 7702) instead of ERC-4337 UserOps. - * - * @param sender - Smart account address (same as EOA for stateless 7702). - * @returns True when direct EIP-1559 submission should be used. - */ - async function useDirect7702Tx(sender: Address): Promise { - if (smartAccountConfig?.implementation === 'stateless7702') { - if ( - smartAccountConfig.address !== undefined && - smartAccountConfig.address.toLowerCase() !== sender.toLowerCase() - ) { - // Config points at a different account — fall through to lazy check. - } else { - return true; - } - } - if (smartAccountConfig?.implementation === 'hybrid') { - return false; - } - if (!providerVat) { - throw new Error( - 'Cannot determine account type: provider not configured and ' + - 'smartAccountConfig is absent. Call configureProvider() first.', - ); - } - const code = (await E(providerVat).request('eth_getCode', [ - sender, - 'latest', - ])) as string; - const chainId = await resolveChainId(); - return isEip7702Delegated(code, chainId); - } - - /** - * Sign and broadcast a self-call tx with SDK-encoded DeleGator calldata - * (7702 EOA). Returns the transaction hash immediately after broadcast. - * - * @param options - Direct submission options. - * @param options.sender - Upgraded EOA / smart account address. - * @param options.callData - SDK-wrapped `execute` calldata. - * @param options.maxFeePerGas - Optional max fee per gas override. - * @param options.maxPriorityFeePerGas - Optional priority fee override. - * @returns The transaction hash from `eth_sendRawTransaction`. - */ - async function buildAndSubmitDirect7702Tx(options: { - sender: Address; - callData: Hex; - maxFeePerGas?: Hex; - maxPriorityFeePerGas?: Hex; - }): Promise { - if (!providerVat) { - throw new Error('Provider vat not available'); - } - const chainId = await resolveChainId(); - let { maxFeePerGas, maxPriorityFeePerGas } = options; - if (!maxFeePerGas || !maxPriorityFeePerGas) { - const fees = await E(providerVat).getGasFees(); - maxFeePerGas = maxFeePerGas ?? fees.maxFeePerGas; - maxPriorityFeePerGas = maxPriorityFeePerGas ?? fees.maxPriorityFeePerGas; - } - const nonce = await E(providerVat).getNonce(options.sender); - const estimatedGas = validateGasEstimate( - await E(providerVat).request('eth_estimateGas', [ - { - from: options.sender, - to: options.sender, - data: options.callData, - }, - ]), - ); - const gasLimit = applyGasBuffer(estimatedGas, 10); - const filledTx: TransactionRequest = { - from: options.sender, - to: options.sender, - chainId, - nonce, - maxFeePerGas, - maxPriorityFeePerGas, - gasLimit, - data: options.callData, - value: '0x0' as Hex, - }; - const signedTx = await resolveTransactionSigning(filledTx); - return E(providerVat).broadcastTransaction(signedTx); - } - - /** - * Poll until an EIP-1559 transaction is mined or timeout. - * - * @param options - Polling options. - * @param options.txHash - Transaction hash to wait for. - * @param options.pollIntervalMs - Delay between RPC polls in milliseconds. - * @param options.timeoutMs - Maximum time to wait in milliseconds. - * @returns Whether the mined transaction succeeded (`status` 0x1). - */ - async function pollTransactionReceipt(options: { - txHash: Hex; - pollIntervalMs?: number; - timeoutMs?: number; - }): Promise<{ success: boolean }> { - if (!providerVat) { - throw new Error('Provider not configured'); - } - if ( - typeof globalThis.Date?.now !== 'function' || - typeof globalThis.setTimeout !== 'function' - ) { - throw new Error( - 'Transaction receipt polling requires Date.now and setTimeout', - ); - } - const interval = options.pollIntervalMs ?? 2000; - const timeout = options.timeoutMs ?? 120_000; - const start = Date.now(); - while (Date.now() - start < timeout) { - let receipt: { status?: string | number } | null = null; - try { - receipt = (await E(providerVat).request('eth_getTransactionReceipt', [ - options.txHash, - ])) as { status?: string | number } | null; - } catch (error) { - // Transient RPC errors (network hiccups, rate limits) should not - // abort polling — the tx was already broadcast and may still mine. - logger.warn( - `RPC error polling receipt for ${options.txHash}, will retry`, - error, - ); - await new Promise((resolve) => setTimeout(resolve, interval)); - continue; - } - if (receipt) { - // Normalize: some providers return status as a number (1) rather - // than the standard hex string ('0x1'). EIP-1559 receipts must have - // a status field; a missing one likely indicates a malformed response. - const { status } = receipt; - if (status === undefined || status === null) { - logger.warn( - `Receipt for ${options.txHash} has no status field — assuming success`, - ); - return harden({ success: true }); - } - const normalizedStatus = - typeof status === 'number' ? `0x${status.toString(16)}` : status; - return harden({ success: normalizedStatus === '0x1' }); - } - await new Promise((resolve) => setTimeout(resolve, interval)); - } - throw new Error( - `Transaction ${options.txHash} not mined after ${String(timeout)}ms`, - ); - } - - /** - * Check if an address belongs to the cached peer (home) accounts. - * - * @param address - The Ethereum address to check. - * @returns True if the address is a cached peer account. - */ - function isPeerAccount(address: Address): boolean { - return cachedPeerAccounts.some( - (a) => a.toLowerCase() === address.toLowerCase(), - ); - } - - /** - * Build a human-readable error message from delegation match results. - * - * @param matchResults - The match results from explainActionMatch. - * @param context - A message prefix describing the context. - * @returns A formatted error message string. - */ - function buildDelegationMismatchError( - matchResults: { delegationId: string; result: DelegationMatchResult }[], - context: string, - ): string { - const reasons = matchResults - .filter((entry) => !entry.result.matches) - .map( - (entry) => - `delegation ${entry.delegationId.slice(0, 10)}…: ${entry.result.reason ?? 'unknown'} (caveat: ${entry.result.failedCaveat ?? 'n/a'})`, - ); - return `${context}. ${reasons.length} delegation(s) checked: ${reasons.join('; ')}`; - } - - /** - * Resolve the EOA owner address from the keyring or external signer. - * - * @returns The first available EOA address. - * @throws If no accounts are available. - */ - async function resolveOwnerAddress(): Promise
{ - if (keyringVat) { - const accounts = await E(keyringVat).getAccounts(); - if (accounts.length > 0) { - return accounts[0] as Address; - } - } - if (externalSigner) { - const accounts = await E(externalSigner).getAccounts(); - if (accounts.length > 0) { - return accounts[0] as Address; - } - } - throw new Error('No accounts available'); - } - - const PEER_TIMEOUT_MS = 5000; - - /** - * Race a promise against a timeout. - * - * @param promise - The promise to race. - * @param ms - Timeout in milliseconds. - * @returns The resolved value of the promise. - */ - async function raceWithTimeout( - promise: Promise, - ms: number, - ): Promise { - if (typeof globalThis.setTimeout !== 'function') { - return promise; - } - return new Promise((resolve, reject) => { - const timer = globalThis.setTimeout(() => { - reject(new Error(`Peer call timed out after ${String(ms)}ms`)); - }, ms); - // eslint-disable-next-line promise/catch-or-return - promise.then( - (value) => { - globalThis.clearTimeout(timer); - resolve(value); - return undefined; - }, - (error: unknown) => { - globalThis.clearTimeout(timer); - reject(error instanceof Error ? error : new Error(String(error))); - return undefined; - }, - ); - }); - } - - /** - * Persist a baggage key-value pair, handling both init and update. - * - * @param key - The baggage key. - * @param value - The value to persist. - */ - function persistBaggage(key: string, value: unknown): void { - if (baggage.has(key)) { - baggage.set(key, value); - } else { - baggage.init(key, value); - } - } - - /** - * Build a peer signing request for typed data. - * - * @param data - The typed data to sign. - * @param account - Optional account to sign with. - * @returns The peer signing request payload. - */ - function makeTypedDataSigningRequest( - data: Eip712TypedData, - account?: Address, - ): { type: 'typedData'; data: Eip712TypedData; account?: Address } { - return account - ? { type: 'typedData', data, account } - : { type: 'typedData', data }; - } - - /** - * Resolve the signing strategy for typed data. - * Priority: keyring → external signer → peer wallet → error - * - * @param data - The EIP-712 typed data to sign. - * @param from - Optional sender address. - * @returns The signature as a hex string. - */ - async function resolveTypedDataSigning( - data: Eip712TypedData, - from?: Address, - ): Promise { - // If the requested address belongs to the home device, route to peer - if (from && isPeerAccount(from)) { - if (peerWallet) { - return E(peerWallet).handleSigningRequest( - makeTypedDataSigningRequest(data, from), - ); - } - throw new Error( - `Cannot sign typed data as ${from}: home device is offline and this address requires home signing authority`, - ); - } - - if (keyringVat) { - const hasKeys = await E(keyringVat).hasKeys(); - if (hasKeys) { - return E(keyringVat).signTypedData(data, from); - } - } - - if (externalSigner) { - const accounts = await E(externalSigner).getAccounts(); - if (accounts.length > 0) { - return E(externalSigner).signTypedData(data, from ?? accounts[0]); - } - } - - if (peerWallet) { - return E(peerWallet).handleSigningRequest( - makeTypedDataSigningRequest(data, from), - ); - } - - throw new Error('No authority to sign typed data'); - } - - /** - * Resolve the signing strategy for a personal message. - * Priority: keyring → external signer → peer wallet → error - * - * @param message - The message to sign. - * @param from - Optional sender address. - * @returns The signature as a hex string. - */ - async function resolveMessageSigning( - message: string, - from?: Address, - ): Promise { - // If the requested address belongs to the home device, route to peer - if (from && isPeerAccount(from)) { - if (peerWallet) { - return E(peerWallet).handleSigningRequest({ - type: 'message', - message, - account: from, - }); - } - throw new Error( - `Cannot sign message as ${from}: home device is offline and this address requires home signing authority`, - ); - } - - if (keyringVat) { - const hasKeys = await E(keyringVat).hasKeys(); - if (hasKeys) { - return E(keyringVat).signMessage(message, from); - } - } - - if (externalSigner) { - const accounts = await E(externalSigner).getAccounts(); - if (accounts.length > 0) { - return E(externalSigner).signMessage(message, from ?? accounts[0]); - } - } - - if (peerWallet) { - return E(peerWallet).handleSigningRequest({ - type: 'message', - message, - ...(from ? { account: from } : {}), - }); - } - - throw new Error('No authority to sign message'); - } - - /** - * Resolve the signing strategy for a transaction. - * Priority: local key → external signer → reject - * - * @param tx - The transaction request to sign. - * @returns The signed transaction as a hex string. - */ - async function resolveTransactionSigning( - tx: TransactionRequest, - ): Promise { - // Strategy 1: Check if local keyring owns this account - if (keyringVat) { - const accounts = await E(keyringVat).getAccounts(); - if (accounts.includes(tx.from.toLowerCase() as Address)) { - return E(keyringVat).signTransaction(tx); - } - } - - // Strategy 2: Check if external signer can handle it - if (externalSigner) { - return E(externalSigner).signTransaction({ - ...tx, - from: tx.from.toLowerCase() as Address, - }); - } - - throw new Error('No authority to sign this transaction'); - } - - /** - * Build, sign, and submit a UserOp. Shared pipeline for both delegation - * redemption and on-chain delegation revocation. - * - * @param options - Pipeline options. - * @param options.sender - The smart account address that sends the UserOp. - * @param options.callData - The encoded callData for the UserOp. - * @param options.maxFeePerGas - Optional max fee per gas override. - * @param options.maxPriorityFeePerGas - Optional max priority fee per gas override. - * @returns The UserOp hash from the bundler. - */ - async function buildAndSubmitUserOp(options: { - sender: Address; - callData: Hex; - maxFeePerGas?: Hex; - maxPriorityFeePerGas?: Hex; - }): Promise { - if (!providerVat) { - throw new Error('Provider vat not available'); - } - if (!bundlerConfig) { - throw new Error('Bundler not configured'); - } - - const { sender, callData } = options; - - // Get gas prices from the bundler (pimlico_getUserOperationGasPrice) - // which returns prices the bundler will accept, avoiding rejection - // due to stale node-reported fees. - let { maxFeePerGas, maxPriorityFeePerGas } = options; - if (!maxFeePerGas || !maxPriorityFeePerGas) { - const gasPrice = await E(providerVat).getUserOperationGasPrice(); - maxFeePerGas = maxFeePerGas ?? gasPrice.fast.maxFeePerGas; - maxPriorityFeePerGas = - maxPriorityFeePerGas ?? gasPrice.fast.maxPriorityFeePerGas; - } - - // Get nonce from EntryPoint contract (ERC-4337 nonce) - const nonceHex = await E(providerVat).getEntryPointNonce({ - entryPoint: bundlerConfig.entryPoint, - sender, - }); - - // Detect signing mode: check smartAccountConfig first, then fall back - // to on-chain code inspection. This ensures the correct signing mode - // even if smartAccountConfig is lost from baggage. - let isStateless7702 = - smartAccountConfig?.implementation === 'stateless7702'; - - // Always fetch on-chain code — needed for both factory detection and - // signing mode fallback. - const onChainCode = (await E(providerVat).request('eth_getCode', [ - sender, - 'latest', - ])) as string | undefined; - - if (typeof onChainCode !== 'string') { - throw new Error( - `eth_getCode for ${sender} returned ${String(onChainCode)}; check provider configuration`, - ); - } - - // Fall back to on-chain code detection for 7702 accounts that weren't - // configured via smartAccountConfig (e.g., restored from stale baggage). - // Any EIP-7702 designator prefix (0xef0100) indicates a Stateless7702 - // DeleGator, which uses a different EIP-712 domain name for signing. - if (!isStateless7702 && onChainCode.toLowerCase().startsWith('0xef0100')) { - isStateless7702 = true; - } - - // Check on-chain whether the smart account is deployed (eth_getCode). - // This avoids relying on a cached flag that could be stale if the - // deployment UserOp failed on-chain. - let includeFactory = false; - if ( - !isStateless7702 && - smartAccountConfig?.factory && - smartAccountConfig.factoryData - ) { - includeFactory = onChainCode === '0x' || onChainCode === '0x0'; - - if (!includeFactory && smartAccountConfig.deployed === false) { - smartAccountConfig = harden({ - ...smartAccountConfig, - deployed: true, - }); - persistBaggage('smartAccountConfig', smartAccountConfig); - } - } - - // Build unsigned UserOp with a dummy 65-byte signature so that the - // smart account's validateUserOp can parse the ECDSA signature during - // bundler/paymaster simulation. An empty signature (0x) causes revert. - const unsignedUserOp: UserOperation = { - sender, - nonce: nonceHex, - callData, - callGasLimit: '0x50000' as Hex, - verificationGasLimit: '0x60000' as Hex, - preVerificationGas: '0x10000' as Hex, - maxFeePerGas, - maxPriorityFeePerGas, - signature: - '0xfffffffffffffffffffffffffffffff0000000000000000000000000000000007aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1c' as Hex, - ...(includeFactory && smartAccountConfig - ? { - factory: smartAccountConfig.factory as Hex, - factoryData: smartAccountConfig.factoryData as Hex, - } - : {}), - }; - - let userOpWithGas: UserOperation; - - if (bundlerConfig.usePaymaster) { - // Use paymaster sponsorship instead of gas estimation - const sponsorContext: Record = {}; - if (bundlerConfig.sponsorshipPolicyId) { - sponsorContext.sponsorshipPolicyId = bundlerConfig.sponsorshipPolicyId; - } - - const sponsorResult = await E(providerVat).sponsorUserOp({ - bundlerUrl: bundlerConfig.bundlerUrl, - entryPoint: bundlerConfig.entryPoint, - userOp: unsignedUserOp, - context: sponsorContext, - }); - - userOpWithGas = { - ...unsignedUserOp, - paymaster: sponsorResult.paymaster, - paymasterData: sponsorResult.paymasterData, - paymasterVerificationGasLimit: - sponsorResult.paymasterVerificationGasLimit, - paymasterPostOpGasLimit: sponsorResult.paymasterPostOpGasLimit, - callGasLimit: sponsorResult.callGasLimit, - verificationGasLimit: sponsorResult.verificationGasLimit, - preVerificationGas: sponsorResult.preVerificationGas, - }; - } else { - // Estimate gas via bundler - const gasEstimate = await E(providerVat).estimateUserOpGas({ - bundlerUrl: bundlerConfig.bundlerUrl, - entryPoint: bundlerConfig.entryPoint, - userOp: unsignedUserOp, - }); - - userOpWithGas = { - ...unsignedUserOp, - callGasLimit: applyGasBuffer(gasEstimate.callGasLimit, 10), - verificationGasLimit: applyGasBuffer( - gasEstimate.verificationGasLimit, - 10, - ), - preVerificationGas: gasEstimate.preVerificationGas, - }; - } - - // Sign the UserOp via EIP-712 typed data. Both Hybrid and Stateless7702 - // DeleGators validate signatures using EIP-712 — the only difference is - // the domain name. - const userOpTypedData = prepareUserOpTypedData({ - userOp: userOpWithGas, - entryPoint: bundlerConfig.entryPoint, - chainId: bundlerConfig.chainId, - smartAccountAddress: sender, - ...(isStateless7702 - ? { smartAccountName: 'EIP7702StatelessDeleGator' } - : {}), - }); - const signature: Hex = await resolveTypedDataSigning(userOpTypedData); - - // Attach signature and submit - const signedUserOp: UserOperation = { - ...userOpWithGas, - signature, - }; - - return E(providerVat).submitUserOp({ - bundlerUrl: bundlerConfig.bundlerUrl, - entryPoint: bundlerConfig.entryPoint, - userOp: signedUserOp, - }); - } - - /** - * Build, sign, and submit a UserOp that redeems one or more delegations. - * - * @param options - UserOp pipeline options. - * @param options.delegations - The delegation chain (leaf to root). - * @param options.execution - The execution to perform. - * @param options.maxFeePerGas - Max fee per gas. - * @param options.maxPriorityFeePerGas - Max priority fee per gas. - * @returns The UserOp hash from the bundler. - */ - async function submitDelegationUserOp(options: { - delegations: Delegation[]; - execution: Execution; - maxFeePerGas?: Hex | undefined; - maxPriorityFeePerGas?: Hex | undefined; - }): Promise { - // Check the relay path first — it forwards raw delegations/execution to - // the home wallet and does not need local chain ID or SDK calldata. - if (!bundlerConfig && !smartAccountConfig) { - if (peerWallet) { - try { - return await E(peerWallet).handleRedemptionRequest({ - type: 'single', - delegations: options.delegations, - execution: options.execution, - maxFeePerGas: options.maxFeePerGas, - maxPriorityFeePerGas: options.maxPriorityFeePerGas, - }); - } catch (relayError) { - const detail = - relayError instanceof Error - ? relayError.message - : String(relayError); - throw new Error( - `Failed to relay delegation redemption to home wallet: ${detail}`, - { cause: relayError }, - ); - } - } - throw new Error( - 'Bundler not configured and no peer wallet available for relay', - ); - } - - const sender = - smartAccountConfig?.address ?? options.delegations[0].delegate; - - const chainId = await resolveChainId(); - const sdkCallData = buildSdkRedeemCallData({ - delegations: options.delegations, - execution: options.execution, - chainId, - }); - - if (await useDirect7702Tx(sender)) { - return buildAndSubmitDirect7702Tx({ - sender, - callData: sdkCallData, - maxFeePerGas: options.maxFeePerGas, - maxPriorityFeePerGas: options.maxPriorityFeePerGas, - }); - } - - if (!bundlerConfig) { - throw new Error( - 'Bundler not configured (required for hybrid smart account redemption)', - ); - } - - const userOpOptions: { - sender: Address; - callData: Hex; - maxFeePerGas?: Hex; - maxPriorityFeePerGas?: Hex; - } = { sender, callData: sdkCallData }; - if (options.maxFeePerGas) { - userOpOptions.maxFeePerGas = options.maxFeePerGas; - } - if (options.maxPriorityFeePerGas) { - userOpOptions.maxPriorityFeePerGas = options.maxPriorityFeePerGas; - } - - return buildAndSubmitUserOp(userOpOptions); - } - - /** - * Submit a batch of executions via delegation redemption in a single UserOp. - * Uses `ExecutionMode.BatchDefault` so all executions share the same - * delegation chain. - * - * @param options - Batch delegation options. - * @param options.delegations - The delegation chain (leaf to root). - * @param options.executions - The executions to batch. - * @returns The UserOp hash from the bundler. - */ - async function submitBatchDelegationUserOp(options: { - delegations: Delegation[]; - executions: Execution[]; - }): Promise { - // Check the relay path first — it forwards raw delegations/executions to - // the home wallet and does not need local chain ID or SDK calldata. - if (!bundlerConfig && !smartAccountConfig) { - if (peerWallet) { - try { - return await E(peerWallet).handleRedemptionRequest({ - type: 'batch', - delegations: options.delegations, - executions: options.executions, - }); - } catch (relayError) { - const detail = - relayError instanceof Error - ? relayError.message - : String(relayError); - throw new Error( - `Failed to relay batch delegation redemption to home wallet: ${detail}`, - { cause: relayError }, - ); - } - } - throw new Error( - 'Bundler not configured and no peer wallet available for relay', - ); - } - - const sender = - smartAccountConfig?.address ?? options.delegations[0]?.delegate; - if (!sender) { - throw new Error('No sender address available for batch delegation'); - } - - const chainId = await resolveChainId(); - const sdkCallData = buildSdkBatchRedeemCallData({ - delegations: options.delegations, - executions: options.executions, - chainId, - }); - - if (await useDirect7702Tx(sender)) { - return buildAndSubmitDirect7702Tx({ sender, callData: sdkCallData }); - } - - if (!bundlerConfig) { - throw new Error( - 'Bundler not configured (required for hybrid smart account batch redemption)', - ); - } - - return buildAndSubmitUserOp({ sender, callData: sdkCallData }); - } - - /** - * Submit a transaction that calls `DelegationManager.disableDelegation` to - * revoke a delegation on-chain — either via a direct EIP-1559 tx (7702) or - * an ERC-4337 UserOp (hybrid). - * - * @param delegation - The delegation to disable. - * @returns The hash and whether the direct 7702 path was used. - */ - async function submitDisableUserOp( - delegation: Delegation, - ): Promise<{ hash: Hex; isDirect: boolean }> { - const sender = smartAccountConfig?.address ?? delegation.delegator; - - const chainId = await resolveChainId(); - const disableCallData = buildSdkDisableCallData({ - delegation, - chainId, - }); - - try { - const isDirect = await useDirect7702Tx(sender); - if (isDirect) { - const hash = await buildAndSubmitDirect7702Tx({ - sender, - callData: disableCallData, - }); - return { hash, isDirect: true }; - } - if (!bundlerConfig) { - throw new Error( - 'Bundler not configured (required for hybrid on-chain revocation)', - ); - } - const hash = await buildAndSubmitUserOp({ - sender, - callData: disableCallData, - }); - return { hash, isDirect: false }; - } catch (error) { - throw new Error( - `Failed to submit on-chain revocation for delegator ${delegation.delegator}`, - { cause: error }, - ); - } - } - - /** - * Create a Stateless7702 smart account by signing and broadcasting - * an EIP-7702 authorization transaction. The user's EOA address - * becomes the smart account — no factory deployment or funding needed. - * - * @param chainId - The chain ID. - * @returns The smart account configuration. - */ - async function createStateless7702SmartAccount( - chainId: number, - ): Promise { - if (!providerVat) { - throw new Error('Provider vat required for EIP-7702 authorization'); - } - - // Resolve EOA address: keyring first, then external signer. - let eoaAddress: Address; - try { - eoaAddress = await resolveOwnerAddress(); - } catch { - throw new Error('No accounts available for EIP-7702 smart account'); - } - - // Check if already set up (persisted from a prior call) - if ( - smartAccountConfig?.implementation === 'stateless7702' && - smartAccountConfig.address === eoaAddress - ) { - return smartAccountConfig; - } - - // Best-effort on-chain check — works on providers that support - // EIP-7702 designator codes via eth_getCode (not all do, e.g. Infura). - const code = (await E(providerVat).request('eth_getCode', [ - eoaAddress, - 'latest', - ])) as string; - - if (isEip7702Delegated(code, chainId)) { - // eslint-disable-next-line require-atomic-updates - smartAccountConfig = harden({ - implementation: 'stateless7702' as const, - address: eoaAddress, - deployed: true, - }); - persistBaggage('smartAccountConfig', smartAccountConfig); - return smartAccountConfig; - } - - // EIP-7702 promotion requires signAuthorization which is only - // available on the local keyring (not supported by external signers). - if (!keyringVat || !(await E(keyringVat).hasKeys())) { - throw new Error( - 'EIP-7702 promotion requires a local keyring with initialized keys. ' + - 'Use implementation: "hybrid", or promote the account through MetaMask first.', - ); - } - - // Sign EIP-7702 authorization - const env = resolveEnvironment(chainId); - const implAddress = ( - env.implementations as Record - ).EIP7702StatelessDeleGatorImpl; - if (!implAddress) { - throw new Error( - `EIP7702StatelessDeleGatorImpl not found in environment for chain ${String(chainId)}`, - ); - } - - // Fetch the EOA nonce, gas fees, and sign the authorization in parallel. - // EIP-7702 self-execution: the tx sender is the same EOA as the - // authorization authority. The sender's nonce is incremented by the tx - // validity check BEFORE authorizations are processed, so the - // authorization nonce must be txNonce + 1. - const EIP7702_FALLBACK_GAS = '0x19000' as Hex; // 102400 - // Minimum plausible gas for an EIP-7702 auth tx (~40k). Estimates - // below this likely indicate the RPC ignored the authorizationList - // and returned a plain-transfer estimate (21000). - const EIP7702_MIN_GAS = 0xa000n; // 40960 - const [nonce, fees, estimatedAuthGas] = await Promise.all([ - E(providerVat).getNonce(eoaAddress), - E(providerVat).getGasFees(), - ( - E(providerVat).request('eth_estimateGas', [ - { - from: eoaAddress, - to: eoaAddress, - authorizationList: [{ address: implAddress, chainId }], - }, - ]) as Promise - ).then( - (result) => { - if (typeof result !== 'string' || !result.startsWith('0x')) { - logger.warn( - `eth_estimateGas returned non-hex for EIP-7702 auth: ${String(result)}, using fallback`, - ); - return EIP7702_FALLBACK_GAS; - } - if (BigInt(result) < EIP7702_MIN_GAS) { - logger.warn( - `eth_estimateGas returned suspiciously low value ${result} for EIP-7702 auth, using fallback`, - ); - return EIP7702_FALLBACK_GAS; - } - return result; - }, - (error: unknown) => { - const message = - error instanceof Error ? error.message : String(error); - // Only fall back when the RPC doesn't support authorizationList param - if ( - message.includes('-32602') || - message.includes('-32601') || - message.includes('not supported') || - message.includes('unknown field') - ) { - logger.warn( - 'eth_estimateGas does not support authorizationList, using fallback gas', - ); - return EIP7702_FALLBACK_GAS; - } - throw new Error( - `eth_estimateGas failed for EIP-7702 authorization: ${message}`, - ); - }, - ), - ]); - const authGasLimit = applyGasBuffer(estimatedAuthGas, 20); - const signedAuth = await E(keyringVat).signAuthorization({ - contractAddress: implAddress as Address, - chainId, - nonce: nonce + 1, - }); - - const signedTx = await E(keyringVat).signTransaction({ - from: eoaAddress, - to: eoaAddress, - chainId, - nonce, - maxFeePerGas: fees.maxFeePerGas, - maxPriorityFeePerGas: fees.maxPriorityFeePerGas, - gasLimit: authGasLimit, - authorizationList: [signedAuth], - }); - - const txHash = await E(providerVat).broadcastTransaction(signedTx); - - // Wait for the authorization tx to be mined. Some RPC providers (e.g. - // Infura) don't expose EIP-7702 designator code via eth_getCode, so we - // poll eth_getTransactionReceipt instead (status 0x1 = success). - if (typeof globalThis.setTimeout !== 'function') { - throw new Error( - 'EIP-7702 confirmation polling requires setTimeout ' + - '(not available in SES compartments without timer endowments)', - ); - } - const maxAttempts = 45; - for (let i = 0; i < maxAttempts; i++) { - const receipt = (await E(providerVat).request( - 'eth_getTransactionReceipt', - [txHash], - )) as { status?: string } | null; - if (receipt?.status === '0x1') { - break; - } - if (receipt?.status === '0x0') { - throw new Error( - `EIP-7702 authorization tx ${txHash as string} reverted on-chain`, - ); - } - if (i === maxAttempts - 1) { - throw new Error( - `EIP-7702 authorization tx ${txHash} not confirmed after 90s`, - ); - } - await new Promise((resolve) => setTimeout(resolve, 2000)); - } - - // eslint-disable-next-line require-atomic-updates - smartAccountConfig = harden({ - implementation: 'stateless7702' as const, - address: eoaAddress, - deployed: true, - }); - persistBaggage('smartAccountConfig', smartAccountConfig); - return smartAccountConfig; - } - - const coordinator = makeDefaultExo('walletCoordinator', { - // ------------------------------------------------------------------ - // Lifecycle - // ------------------------------------------------------------------ - - async bootstrap(vats: WalletVats, services: WalletServices): Promise { - keyringVat = vats.keyring as KeyringFacet | undefined; - providerVat = vats.provider as ProviderFacet | undefined; - delegationVat = vats.delegation as DelegationFacet | undefined; - issuerService = services.ocapURLIssuerService as - | OcapURLIssuerFacet - | undefined; - redemptionService = services.ocapURLRedemptionService as - | OcapURLRedemptionFacet - | undefined; - - if (keyringVat) { - persistBaggage('keyringVat', keyringVat); - } - if (providerVat) { - persistBaggage('providerVat', providerVat); - } - if (delegationVat) { - persistBaggage('delegationVat', delegationVat); - } - logger.info('bootstrap complete', { - hasKeyring: Boolean(keyringVat), - hasProvider: Boolean(providerVat), - hasDelegation: Boolean(delegationVat), - }); - }, - - // ------------------------------------------------------------------ - // Wallet initialization - // ------------------------------------------------------------------ - - async initializeKeyring(options: { - type: 'srp' | 'throwaway'; - mnemonic?: string; - entropy?: Hex; - password?: string; - salt?: string; - addressIndex?: number; - }): Promise { - if (!keyringVat) { - throw new Error('Keyring vat not available'); - } - let initOptions: - | { type: 'srp'; mnemonic: string; addressIndex?: number } - | { type: 'throwaway'; entropy?: Hex }; - if (options.type === 'throwaway') { - initOptions = { type: 'throwaway', entropy: options.entropy }; - } else { - initOptions = - options.addressIndex === undefined - ? { type: 'srp', mnemonic: options.mnemonic ?? '' } - : { - type: 'srp', - mnemonic: options.mnemonic ?? '', - addressIndex: options.addressIndex, - }; - } - - const password = options.type === 'srp' ? options.password : undefined; - await E(keyringVat).initialize(initOptions, password, options.salt); - }, - - async unlockKeyring(password: string): Promise { - if (!keyringVat) { - throw new Error('Keyring vat not available'); - } - await E(keyringVat).unlock(password); - }, - - async isKeyringLocked(): Promise { - if (!keyringVat) { - throw new Error('Keyring vat not available'); - } - return E(keyringVat).isLocked(); - }, - - async configureProvider(chainConfig: ChainConfig): Promise { - if (!providerVat) { - throw new Error('Provider vat not available'); - } - - // Validate RPC URL (regex — URL constructor unavailable under SES) - if (!/^https?:\/\/.+/u.test(chainConfig.rpcUrl)) { - throw new Error( - `Invalid RPC URL: "${chainConfig.rpcUrl}". Must be a valid HTTP(S) URL.`, - ); - } - - if (!Number.isInteger(chainConfig.chainId) || chainConfig.chainId <= 0) { - throw new Error( - `Invalid chain ID: ${String(chainConfig.chainId)}. Must be a positive integer.`, - ); - } - - await E(providerVat).configure(chainConfig); - - cachedProviderChainId = chainConfig.chainId; - persistBaggage('cachedProviderChainId', cachedProviderChainId); - }, - - // ------------------------------------------------------------------ - // External signer & bundler configuration - // ------------------------------------------------------------------ - - async connectExternalSigner(signer: ExternalSignerFacet): Promise { - if (!signer || typeof signer !== 'object') { - throw new Error('Invalid external signer: must be a non-null object'); - } - externalSigner = signer; - persistBaggage('externalSigner', externalSigner); - }, - - async configureBundler(config: { - bundlerUrl: string; - entryPoint?: Hex; - chainId: number; - usePaymaster?: boolean; - sponsorshipPolicyId?: string; - environment?: { - EntryPoint: Hex; - DelegationManager: Hex; - SimpleFactory: Hex; - implementations: Record; - caveatEnforcers: Record; - }; - }): Promise { - // Validate bundler URL (regex — URL constructor unavailable under SES) - if (!/^https?:\/\/.+/u.test(config.bundlerUrl)) { - throw new Error( - `Invalid bundler URL: "${config.bundlerUrl}". Must be a valid HTTP(S) URL.`, - ); - } - - if (!Number.isInteger(config.chainId) || config.chainId <= 0) { - throw new Error( - `Invalid chain ID: ${String(config.chainId)}. Must be a positive integer.`, - ); - } - - // Register a custom SDK environment for chains not in the SDK's built-in - // registry (e.g. local Anvil at chain 31337). - if (config.environment) { - registerEnvironment(config.chainId, config.environment); - - // Also register in our own getChainContracts() registry so that - // makeDelegationGrant() can build caveats for this chain. - const rawEnforcers = config.environment.caveatEnforcers ?? {}; - const enforcers = { ...PLACEHOLDER_CONTRACTS.enforcers }; - for (const [key, addr] of Object.entries(rawEnforcers)) { - const caveatType = ENFORCER_CONTRACT_KEY_MAP[key]; - if (caveatType !== undefined) { - enforcers[caveatType] = addr; - } - } - registerChainContracts(config.chainId, { - delegationManager: config.environment.DelegationManager, - enforcers, - } as ChainContracts); - } - - bundlerConfig = harden({ - bundlerUrl: config.bundlerUrl, - entryPoint: config.entryPoint ?? ENTRY_POINT_V07, - chainId: config.chainId, - usePaymaster: config.usePaymaster, - sponsorshipPolicyId: config.sponsorshipPolicyId, - environment: config.environment, - }); - persistBaggage('bundlerConfig', bundlerConfig); - logger.info('bundler configured', { - bundlerUrl: config.bundlerUrl, - chainId: config.chainId, - entryPoint: bundlerConfig.entryPoint, - hasEnvironment: Boolean(config.environment), - }); - - if (!providerVat) { - throw new Error( - 'Provider vat not available. Call configureProvider() before configureBundler().', - ); - } - await E(providerVat).configureBundler({ - bundlerUrl: config.bundlerUrl, - chainId: config.chainId, - }); - }, - - // ------------------------------------------------------------------ - // Smart account configuration - // ------------------------------------------------------------------ - - async createSmartAccount(config: { - deploySalt?: Hex; - chainId: number; - address?: Address; - implementation?: 'hybrid' | 'stateless7702'; - }): Promise { - const implementation = config.implementation ?? 'hybrid'; - - if (implementation === 'stateless7702') { - return createStateless7702SmartAccount(config.chainId); - } - - // Hybrid path (existing logic) - let { address } = config; - let factory: Address | undefined; - let factoryData: Hex | undefined; - const deploySalt = - config.deploySalt ?? - ('0x0000000000000000000000000000000000000000000000000000000000000001' as Hex); - - // Derive counterfactual address if not explicitly provided - if (!address) { - // Find the owner EOA from keyring or external signer - let owner: Address; - try { - owner = await resolveOwnerAddress(); - } catch { - throw new Error( - 'No owner account available to derive smart account address', - ); - } - - const env = resolveEnvironment(config.chainId); - factory = env.SimpleFactory; - - const derived = await computeSmartAccountAddress({ - owner, - deploySalt, - chainId: config.chainId, - }); - address = derived.address; - factoryData = derived.factoryData; - } - - smartAccountConfig = harden({ - implementation: 'hybrid' as const, - deploySalt, - address, - factory, - factoryData, - deployed: false, - }); - persistBaggage('smartAccountConfig', smartAccountConfig); - return smartAccountConfig; - }, - - async getSmartAccountAddress(): Promise
{ - return smartAccountConfig?.address; - }, - - // ------------------------------------------------------------------ - // Public wallet API - // ------------------------------------------------------------------ - - async getAccounts(): Promise { - // When a peer wallet is connected, try to fetch live accounts. - // Fall back to cached peer accounts if the peer is unreachable. - if (peerWallet) { - try { - const liveAccounts: Address[] = await raceWithTimeout( - E(peerWallet).getAccounts(), - PEER_TIMEOUT_MS, - ); - // Refresh the cache on success - cachedPeerAccounts = liveAccounts; - persistBaggage('cachedPeerAccounts', cachedPeerAccounts); - return liveAccounts; - } catch (error) { - logger.debug('peer getAccounts timed out, using cache', error); - if (cachedPeerAccounts.length > 0) { - return cachedPeerAccounts; - } - // No cache — fall through to local accounts - } - } - - // Return cached peer accounts if available (peer may have disconnected) - if (cachedPeerAccounts.length > 0) { - return cachedPeerAccounts; - } - - const localAccounts: Address[] = keyringVat - ? await E(keyringVat).getAccounts() - : []; - - const extAccounts: Address[] = externalSigner - ? await E(externalSigner).getAccounts() - : []; - - // Deduplicate by lowercasing - const seen = new Set(localAccounts.map((a) => a.toLowerCase())); - const merged = [...localAccounts]; - for (const account of extAccounts) { - if (!seen.has(account.toLowerCase())) { - seen.add(account.toLowerCase()); - merged.push(account); - } - } - return merged; - }, - - async signTransaction(tx: TransactionRequest): Promise { - return resolveTransactionSigning(tx); - }, - - async sendTransaction(tx: TransactionRequest): Promise { - if (!providerVat) { - throw new Error('Provider not configured'); - } - logger.debug('sendTransaction', { - from: tx.from, - to: tx.to, - value: tx.value, - hasDelegationVat: Boolean(delegationVat), - hasBundlerConfig: Boolean(bundlerConfig), - }); - - // Enforce delegations whenever the delegation vat exists (bundler optional - // for 7702). Delegations are a security boundary — if we cannot resolve the - // chain ID we must fail rather than silently bypassing caveat enforcement. - if (delegationVat) { - const walletChainId = await resolveChainId(); - const action: Action = { - to: tx.to, - value: tx.value, - data: tx.data, - }; - const now = Date.now(); - const delegation = await E(delegationVat).findDelegationForAction( - action, - walletChainId, - now, - ); - - if (delegation) { - logger.debug('delegation matched', { - delegationId: delegation.id, - delegate: delegation.delegate, - sender: smartAccountConfig?.address, - status: delegation.status, - }); - if (delegation.status !== 'signed') { - throw new Error( - `Found delegation ${delegation.id} but its status is '${delegation.status}' (expected 'signed'). ` + - `Direct signing is not used when a delegation exists, to avoid bypassing caveats.`, - ); - } - // Route through the provisioned twin when one exists so that local - // caveat checks (e.g. cumulativeSpend) fire before hitting the chain. - const twin = coordinatorTwins.get(delegation.id); - if (twin) { - const twinMethod = coordinatorTwinMethods.get(delegation.id); - if (twinMethod === 'transfer' || twinMethod === 'approve') { - // Decode the ABI-encoded (address, uint256) calldata args. - // Layout (after '0x'): 8 selector + 64 address word + 64 amount word - // = 138 chars total. Validate before slicing to avoid BigInt('0x') - // SyntaxError when calldata is missing or truncated. - const data = tx.data ?? ('0x' as Hex); - if (data.length < 138) { - throw new Error( - `Cannot route through ${twinMethod} twin: calldata too short ` + - `(${data.length} chars, need 138)`, - ); - } - const addrArg = `0x${data.slice(34, 74)}`; - const amountArg = BigInt(`0x${data.slice(74, 138)}`); - return E(twin)[twinMethod](addrArg, amountArg); - } - return E(twin).call( - tx.to, - tx.value ?? ('0x0' as Hex), - tx.data ?? ('0x' as Hex), - ); - } - return submitDelegationUserOp({ - delegations: [delegation], - execution: { - target: tx.to, - value: tx.value ?? ('0x0' as Hex), - callData: tx.data ?? ('0x' as Hex), - }, - maxFeePerGas: tx.maxFeePerGas, - maxPriorityFeePerGas: tx.maxPriorityFeePerGas, - }); - } - - // No delegation matched — explain why before falling through - logger.debug('no delegation matched, checking explanations'); - const explanations = await E(delegationVat).explainActionMatch( - action, - walletChainId, - now, - ); - if (explanations.length > 0) { - const valueDesc = tx.value - ? `${BigInt(tx.value)} wei (${Number(BigInt(tx.value)) / 1e18} ETH)` - : 'no value'; - throw new Error( - buildDelegationMismatchError( - explanations, - `No delegation covers this transaction (to: ${tx.to}, value: ${valueDesc})`, - ), - ); - } - } - - logger.debug('sendTransaction: no delegation path, using direct send'); - // Estimate missing gas fields for direct (non-delegation) sends - const filledTx = { ...tx }; - - filledTx.nonce ??= await E(providerVat).getNonce(filledTx.from); - filledTx.chainId ??= await E(providerVat).getChainId(); - if (!filledTx.maxFeePerGas || !filledTx.maxPriorityFeePerGas) { - const fees = await E(providerVat).getGasFees(); - filledTx.maxFeePerGas ??= fees.maxFeePerGas; - filledTx.maxPriorityFeePerGas ??= fees.maxPriorityFeePerGas; - } - filledTx.gasLimit ??= applyGasBuffer( - validateGasEstimate( - await E(providerVat).request('eth_estimateGas', [ - { - from: filledTx.from, - to: filledTx.to, - value: filledTx.value, - data: filledTx.data, - }, - ]), - ), - 10, - ); - - const signedTx = await resolveTransactionSigning(filledTx); - return E(providerVat).broadcastTransaction(signedTx); - }, - - async sendBatchTransaction( - txs: TransactionRequest[], - ): Promise { - if (txs.length === 0) { - throw new Error('No transactions to send'); - } - - if (txs.length === 1) { - return coordinator.sendTransaction(txs[0]); - } - - if (!providerVat) { - throw new Error('Provider not configured'); - } - - const batchSender = - smartAccountConfig?.address ?? (await coordinator.getAccounts())[0]; - - // Cache the predicate result — useDirect7702Tx is impure (eth_getCode) - // and must not be called twice for the same sender (see revokeDelegation). - const isDirect7702Batch = - batchSender !== undefined && - smartAccountConfig?.implementation === 'stateless7702' && - (await useDirect7702Tx(batchSender)); - - const useSmartAccountBatchPath = - bundlerConfig !== undefined || - isDirect7702Batch || - (peerWallet !== undefined && delegationVat !== undefined); - - // Smart account path: single UserOp or direct 7702 self-call - if (useSmartAccountBatchPath) { - const executions: Execution[] = txs.map((tx) => ({ - target: tx.to, - value: tx.value ?? ('0x0' as Hex), - callData: tx.data ?? ('0x' as Hex), - })); - - const walletChainId = await resolveChainId(); - - // Delegation path: batch via redeemDelegations with BatchDefault mode. - // Validate that the delegation covers ALL actions in the batch, - // not just the first one, to avoid on-chain reverts. - if (delegationVat) { - const now = Date.now(); - const actions: Action[] = txs.map((tx) => ({ - to: tx.to, - value: tx.value, - data: tx.data, - })); - - let delegation: Delegation | undefined; - for (const action of actions) { - const found = await E(delegationVat).findDelegationForAction( - action, - walletChainId, - now, - ); - if (!found || found.status !== 'signed') { - delegation = undefined; - break; - } - // All actions must be covered by the same delegation. - if (delegation && delegation.id !== found.id) { - delegation = undefined; - break; - } - delegation = found; - } - - if (delegation) { - return submitBatchDelegationUserOp({ - delegations: [delegation], - executions, - }); - } - - // No single delegation covers all batch actions — check whether - // delegations exist that partially match. If so, block the batch - // (same enforcement as sendTransaction) to prevent bypassing caveats - // via the direct execute path. - for (const action of actions) { - const explanations = await E(delegationVat).explainActionMatch( - action, - walletChainId, - now, - ); - if (explanations.length > 0) { - throw new Error( - buildDelegationMismatchError( - explanations, - `No single delegation covers all ${String(actions.length)} batch actions`, - ), - ); - } - } - } - - // Direct smart account batch (no delegation) - const sender = batchSender; - if (!sender) { - throw new Error('No accounts available for batch'); - } - - const callData = buildBatchExecuteCallData({ executions }); - if (isDirect7702Batch) { - return buildAndSubmitDirect7702Tx({ sender, callData }); - } - if (!bundlerConfig) { - throw new Error( - 'Non-delegation batch execution requires a bundler or direct 7702; ' + - 'peer relay is only available for delegation redemptions', - ); - } - return buildAndSubmitUserOp({ sender, callData }); - } - - // EOA fallback: execute sequentially - const hashes: Hex[] = []; - for (const tx of txs) { - hashes.push(await coordinator.sendTransaction(tx)); - } - return hashes; - }, - - async signTypedData(data: Eip712TypedData, from?: Address): Promise { - return resolveTypedDataSigning(data, from); - }, - - async signMessage(message: string, account?: Address): Promise { - return resolveMessageSigning(message, account); - }, - - async request(method: string, params?: unknown[]): Promise { - if (!providerVat) { - throw new Error('Provider not configured'); - } - return E(providerVat).request(method, params); - }, - - /** - * Look up a transaction by hash. Tries the bundler first (in case the - * hash is a UserOp hash from delegation redemption), then falls back - * to a regular `eth_getTransactionReceipt` RPC call. - * - * @param hash - A UserOp hash or regular tx hash. - * @returns An object with `txHash` and `receipt`, or null if not found. - */ - async getTransactionReceipt(hash: Hex): Promise<{ - txHash: Hex; - userOpHash?: Hex; - success: boolean; - } | null> { - if (!providerVat) { - throw new Error('Provider not configured'); - } - - // Try bundler first (UserOp hash) - if (bundlerConfig) { - try { - const userOpReceipt = (await E(providerVat).getUserOpReceipt({ - bundlerUrl: bundlerConfig.bundlerUrl, - userOpHash: hash, - })) as { - success: boolean; - receipt?: { transactionHash?: string }; - } | null; - - if (userOpReceipt?.receipt?.transactionHash) { - return harden({ - txHash: userOpReceipt.receipt.transactionHash as Hex, - userOpHash: hash, - success: userOpReceipt.success, - }); - } - } catch (error) { - // Not a UserOp hash — fall through to regular RPC - logger.debug( - 'UserOp receipt lookup failed, trying regular RPC', - error, - ); - } - } - - // Try regular tx receipt - const receipt = (await E(providerVat).request( - 'eth_getTransactionReceipt', - [hash], - )) as { status?: string; transactionHash?: string } | null; - - if (receipt?.transactionHash) { - return harden({ - txHash: receipt.transactionHash as Hex, - success: receipt.status === '0x1', - }); - } - - return null; - }, - - // ------------------------------------------------------------------ - // Delegation management - // ------------------------------------------------------------------ - - async createDelegation(opts: CreateDelegationOptions): Promise { - if (!delegationVat) { - throw new Error('Delegation vat not available'); - } - - // Determine delegator and signing function. - // When a smart account is configured, use its address as delegator - // but sign with the underlying EOA key (the smart account's owner). - let delegator: Address | undefined; - let signTypedDataFn: - | ((data: Eip712TypedData) => Promise) - | undefined; - - if (keyringVat) { - const accounts = await E(keyringVat).getAccounts(); - if (accounts.length > 0) { - delegator = smartAccountConfig?.address ?? accounts[0]; - const kv = keyringVat; - signTypedDataFn = async (data: Eip712TypedData) => - E(kv).signTypedData(data); - } - } - - if (!delegator && externalSigner) { - const accounts = await E(externalSigner).getAccounts(); - if (accounts.length > 0) { - delegator = smartAccountConfig?.address ?? accounts[0]; - const ext = externalSigner; - // Smart-account delegations are signed by the owner EOA, not the - // smart-account address used as delegator in typed data. - const from = accounts[0]; - signTypedDataFn = async (data: Eip712TypedData) => - E(ext).signTypedData(data, from); - } - } - - if (!delegator || !signTypedDataFn) { - throw new Error('No accounts available to create delegation'); - } - - const delegation = await E(delegationVat).createDelegation({ - ...opts, - delegator, - }); - - const typedData = await E(delegationVat).prepareDelegationForSigning( - delegation.id, - ); - - const signature = await signTypedDataFn(typedData); - - await E(delegationVat).storeSigned(delegation.id, signature); - logger.info('delegation created', { - id: delegation.id, - delegator, - delegate: opts.delegate, - caveats: opts.caveats?.length ?? 0, - }); - - return E(delegationVat).getDelegation(delegation.id); - }, - - async receiveDelegation(delegation: Delegation): Promise { - if (!delegationVat) { - throw new Error('Delegation vat not available'); - } - await E(delegationVat).receiveDelegation(delegation); - }, - - /** - * Mark a delegation as revoked in the local store without submitting - * an on-chain transaction. Used by the home device to propagate - * revocations to the away device over CapTP. - * - * @param id - The delegation identifier. - */ - async revokeDelegationLocally(id: string): Promise { - if (!delegationVat) { - throw new Error('Delegation vat not available'); - } - // Silently ignore if the delegation doesn't exist locally - // (the away device may not have received it yet). - try { - const delegation = await E(delegationVat).getDelegation(id); - if (delegation.status !== 'revoked') { - await E(delegationVat).revokeDelegation(id); - } - } catch (error) { - // Delegation not found locally — nothing to revoke. - logger.debug('revokeDelegationLocally: delegation not found', error); - } - }, - - /** - * Revoke a delegation on-chain by calling `DelegationManager.disableDelegation` - * via a UserOp (hybrid) or a direct EIP-1559 transaction (stateless 7702). - * Blocks until the transaction is confirmed on-chain, then updates the local - * delegation status. - * - * Hybrid accounts require a configured bundler (paymaster optional). - * - * @param id - The delegation identifier. - * @returns The UserOp hash or transaction hash of the on-chain revocation. - */ - async revokeDelegation(id: string): Promise { - if (!delegationVat) { - throw new Error('Delegation vat not available'); - } - - const delegation = await E(delegationVat).getDelegation(id); - if (delegation.status === 'revoked') { - throw new Error(`Delegation ${id} is already revoked`); - } - if (delegation.status !== 'signed') { - throw new Error( - `Delegation ${id} has status '${delegation.status}', expected 'signed'`, - ); - } - - // Verify this wallet controls the delegator address - const accounts = await coordinator.getAccounts(); - const delegatorLower = delegation.delegator.toLowerCase(); - const smartAccountLower = smartAccountConfig?.address?.toLowerCase(); - const matchesAccount = accounts.some( - (a: string) => a.toLowerCase() === delegatorLower, - ); - const isOwned = matchesAccount - ? true - : smartAccountLower === delegatorLower; - if (!isOwned) { - throw new Error( - `Cannot revoke delegation ${id}: delegator ${delegation.delegator} is not controlled by this wallet`, - ); - } - - // Submit on-chain disable — returns the hash and which path was used - // so we poll the right receipt endpoint without calling useDirect7702Tx - // a second time (the predicate is impure due to eth_getCode). - const { hash: submissionHash, isDirect } = - await submitDisableUserOp(delegation); - - if (isDirect) { - const receipt = await pollTransactionReceipt({ - txHash: submissionHash, - }); - if (!receipt.success) { - throw new Error( - `On-chain revocation reverted for delegation ${id} (tx: ${submissionHash})`, - ); - } - } else { - // waitForUserOpReceipt either returns a non-null receipt or throws - // on timeout — validate the shape to catch unexpected bundler responses. - const rawReceipt = await coordinator.waitForUserOpReceipt({ - userOpHash: submissionHash, - }); - const receipt = rawReceipt as { success?: boolean } | undefined; - if ( - !receipt || - typeof receipt !== 'object' || - !('success' in receipt) - ) { - throw new Error( - `Unexpected UserOp receipt format for delegation ${id} ` + - `(userOpHash: ${submissionHash})`, - ); - } - if (!receipt.success) { - throw new Error( - `On-chain revocation reverted for delegation ${id} (userOpHash: ${submissionHash})`, - ); - } - } - - // Update local status after on-chain confirmation - await E(delegationVat).revokeDelegation(id); - - return submissionHash; - }, - - async listDelegations(): Promise { - if (!delegationVat) { - throw new Error('Delegation vat not available'); - } - return E(delegationVat).listDelegations(); - }, - - // ------------------------------------------------------------------ - // Delegation twins - // ------------------------------------------------------------------ - - async makeDelegationGrant( - method: CatalogMethodName, - options: Record, - ): Promise { - if (!delegationVat) { - throw new Error('Delegation vat not available'); - } - - // Resolve delegator: smart account address if configured, else first local account - const delegator = - smartAccountConfig?.address ?? (await resolveOwnerAddress()); - - // Coerce numeric options that arrive as strings over the JSON-RPC boundary - // (the daemon queueMessage protocol only carries plain JSON). - const rawOptions = { delegator, ...options }; - const coercedOptions = { - ...rawOptions, - ...(rawOptions.max !== undefined && { - max: BigInt(rawOptions.max as string | number | bigint), - }), - ...(rawOptions.maxValue !== undefined && { - maxValue: BigInt(rawOptions.maxValue as string | number | bigint), - }), - }; - - const grant = grantBuilder.buildDelegationGrant( - method, - coercedOptions as Parameters[1], - ); - - // Sign the delegation via the existing create → prepare → sign → store flow - const { delegation } = grant; - - // Determine signing function (same logic as createDelegation) - let signTypedDataFn: - | ((data: Eip712TypedData) => Promise) - | undefined; - - if (keyringVat) { - const accounts = await E(keyringVat).getAccounts(); - if (accounts.length > 0) { - const kv = keyringVat; - signTypedDataFn = async (data: Eip712TypedData) => - E(kv).signTypedData(data); - } - } - - if (!signTypedDataFn && externalSigner) { - const accounts = await E(externalSigner).getAccounts(); - if (accounts.length > 0) { - const ext = externalSigner; - const from = accounts[0]; - signTypedDataFn = async (data: Eip712TypedData) => - E(ext).signTypedData(data, from); - } - } - - if (!signTypedDataFn) { - throw new Error('No signing authority available'); - } - - // Store, prepare, sign, and finalize the delegation - const stored = await E(delegationVat).createDelegation({ - delegate: delegation.delegate, - caveats: delegation.caveats, - chainId: delegation.chainId, - salt: delegation.salt, - delegator, - }); - - const typedData = await E(delegationVat).prepareDelegationForSigning( - stored.id, - ); - const signature = await signTypedDataFn(typedData); - await E(delegationVat).storeSigned(stored.id, signature); - - const signedDelegation = await E(delegationVat).getDelegation(stored.id); - - return harden({ - ...grant, - delegation: signedDelegation, - }); - }, - - async provisionTwin(grant: DelegationGrant): Promise { - if (!delegationVat) { - throw new Error('Delegation vat not available'); - } - - // Coerce BigInt fields in caveatSpecs — they arrive as strings when the - // grant crosses the daemon JSON-RPC boundary. - const coercedGrant: DelegationGrant = harden({ - ...grant, - caveatSpecs: grant.caveatSpecs.map((spec) => { - if (spec.type === 'cumulativeSpend') { - return harden({ - ...spec, - max: BigInt(spec.max as unknown as string | number | bigint), - }); - } - if (spec.type === 'blockWindow') { - return harden({ - ...spec, - after: BigInt(spec.after as unknown as string | number | bigint), - before: BigInt( - spec.before as unknown as string | number | bigint, - ), - }); - } - if (spec.type === 'valueLte') { - return harden({ - ...spec, - max: BigInt(spec.max as unknown as string | number | bigint), - }); - } - return spec; - }), - }); - - const { delegation } = coercedGrant; - - // Build redeemFn closure that submits a delegation UserOp - const redeemFn = async (execution: Execution): Promise => { - return submitDelegationUserOp({ - delegations: [delegation], - execution, - }); - }; - - // Build readFn closure if provider is available - let readFn: - | ((opts: { to: Address; data: Hex }) => Promise) - | undefined; - if (providerVat) { - const pv = providerVat; - readFn = async (opts: { to: Address; data: Hex }): Promise => { - const result = await E(pv).request('eth_call', [ - { to: opts.to, data: opts.data }, - 'latest', - ]); - return result as Hex; - }; - } - - // Create the twin locally — redeemFn/readFn are closures that cannot - // cross the CapTP vat boundary, so we build the twin here and store - // the delegation in the delegation vat via a plain-data call. - await E(delegationVat).storeDelegation(coercedGrant); - const twin = makeDelegationTwin({ - grant: coercedGrant, - redeemFn, - readFn, - }); - coordinatorTwins.set(coercedGrant.delegation.id, twin); - coordinatorTwinMethods.set( - coercedGrant.delegation.id, - coercedGrant.methodName as CatalogMethodName, - ); - return twin; - }, - - // ------------------------------------------------------------------ - // Delegation redemption (ERC-4337) - // ------------------------------------------------------------------ - - async redeemDelegation(options: { - execution: Execution; - delegations?: Delegation[]; - delegationId?: string; - action?: Action; - maxFeePerGas?: Hex; - maxPriorityFeePerGas?: Hex; - }): Promise { - if (!delegationVat) { - throw new Error('Delegation vat not available'); - } - - // Resolve the delegation chain - let delegations: Delegation[]; - - if (options.delegations && options.delegations.length > 0) { - // Explicit delegation chain provided - delegations = options.delegations; - } else if (options.delegationId) { - const delegation = await E(delegationVat).getDelegation( - options.delegationId, - ); - delegations = [delegation]; - } else if (options.action) { - // Only resolve chain ID when needed for delegation matching - const walletChainId = await resolveChainId(); - const now = Date.now(); - const delegation = await E(delegationVat).findDelegationForAction( - options.action, - walletChainId, - now, - ); - if (!delegation) { - const explanations = await E(delegationVat).explainActionMatch( - options.action, - walletChainId, - now, - ); - throw new Error( - buildDelegationMismatchError( - explanations, - 'No matching delegation found', - ), - ); - } - delegations = [delegation]; - } else { - throw new Error('Must provide delegations, delegationId, or action'); - } - - // Validate all delegations in the chain are signed - for (const delegation of delegations) { - if (delegation.status !== 'signed') { - throw new Error( - `Delegation ${delegation.id} has status '${delegation.status}', expected 'signed'`, - ); - } - } - - return submitDelegationUserOp({ - delegations, - execution: options.execution, - maxFeePerGas: options.maxFeePerGas, - maxPriorityFeePerGas: options.maxPriorityFeePerGas, - }); - }, - - // ------------------------------------------------------------------ - // ERC-20 token utilities - // ------------------------------------------------------------------ - - async getTokenBalance(options: { - token: Address; - owner: Address; - }): Promise { - if (!providerVat) { - throw new Error('Provider not configured'); - } - const callData = encodeBalanceOf(options.owner); - const result = await E(providerVat).request('eth_call', [ - { to: options.token, data: callData }, - 'latest', - ]); - const validated = validateTokenCallResult( - result, - 'balanceOf', - options.token, - ); - return decodeBalanceOfResult(validated).toString(); - }, - - async getTokenMetadata(options: { - token: Address; - }): Promise<{ name: string; symbol: string; decimals: number }> { - if (!providerVat) { - throw new Error('Provider not configured'); - } - const [nameSettled, symbolSettled, decimalsSettled] = - await Promise.allSettled([ - E(providerVat).request('eth_call', [ - { to: options.token, data: encodeName() }, - 'latest', - ]), - E(providerVat).request('eth_call', [ - { to: options.token, data: encodeSymbol() }, - 'latest', - ]), - E(providerVat).request('eth_call', [ - { to: options.token, data: encodeDecimals() }, - 'latest', - ]), - ]); - - // decimals is mandatory — wrong decimals causes financial errors - if (decimalsSettled.status === 'rejected') { - throw new Error( - `decimals() call failed for token ${options.token}: ${ - decimalsSettled.reason instanceof Error - ? decimalsSettled.reason.message - : String(decimalsSettled.reason) - }`, - ); - } - - // name and symbol are optional in ERC-20; fall back gracefully - let name = 'Unknown'; - if (nameSettled.status === 'fulfilled') { - try { - name = decodeNameResult( - validateTokenCallResult(nameSettled.value, 'name', options.token), - ); - } catch { - // name() not implemented or returned invalid data - } - } - - let symbol = 'Unknown'; - if (symbolSettled.status === 'fulfilled') { - try { - symbol = decodeSymbolResult( - validateTokenCallResult( - symbolSettled.value, - 'symbol', - options.token, - ), - ); - } catch { - // symbol() not implemented or returned invalid data - } - } - - return harden({ - name, - symbol, - decimals: decodeDecimalsResult( - validateTokenCallResult( - decimalsSettled.value, - 'decimals', - options.token, - ), - ), - }); - }, - - async sendErc20Transfer(options: { - token: Address; - to: Address; - amount: bigint | Hex; - from?: Address; - }): Promise { - const accounts = await coordinator.getAccounts(); - const from = options.from ?? accounts[0]; - if (!from) { - throw new Error('No accounts available'); - } - const rawAmount = - typeof options.amount === 'bigint' - ? options.amount - : BigInt(options.amount); - const callData = encodeTransfer(options.to, rawAmount); - return coordinator.sendTransaction({ - from, - to: options.token, - data: callData, - value: '0x0' as Hex, - }); - }, - - // ------------------------------------------------------------------ - // Token swaps (MetaSwap API) - // ------------------------------------------------------------------ - - async getSwapQuote(options: { - srcToken: Address; - destToken: Address; - srcAmount: Hex; - slippage: number; - walletAddress?: Address; - }): Promise { - if (!providerVat) { - throw new Error('Provider not configured'); - } - - if (options.slippage < 0.1 || options.slippage > 50) { - throw new Error('Slippage must be between 0.1 and 50'); - } - - const walletAddress = - options.walletAddress ?? (await coordinator.getAccounts())[0]; - if (!walletAddress) { - throw new Error('No accounts available'); - } - - const chainId = await resolveChainId(); - - const rawAmount = BigInt(options.srcAmount).toString(); - - // Build query string manually — URLSearchParams is unavailable in SES vats. - const queryEntries: [string, string][] = [ - ['sourceToken', options.srcToken.toLowerCase()], - ['destinationToken', options.destToken.toLowerCase()], - ['sourceAmount', rawAmount], - ['slippage', String(options.slippage)], - ['walletAddress', walletAddress], - ['timeout', '10000'], - ]; - const query = queryEntries - .map( - ([key, val]) => - `${encodeURIComponent(key)}=${encodeURIComponent(val)}`, - ) - .join('&'); - - const url = `https://swap.api.cx.metamask.io/networks/${String(chainId)}/trades?${query}`; - - const response = await E(providerVat).httpGetJson(url); - - if (!Array.isArray(response) || response.length === 0) { - throw new Error( - 'No swap quotes available for this token pair and amount', - ); - } - - // Select the best quote by highest destinationAmount - let best: SwapQuote | undefined; - let bestAmount = -1n; - - for (const entry of response) { - const quote = entry as Record; - if (quote.error) { - continue; - } - const rawDest = - typeof quote.destinationAmount === 'string' - ? quote.destinationAmount - : '0'; - const destAmount = BigInt(rawDest); - if (destAmount > bestAmount) { - bestAmount = destAmount; - best = quote as unknown as SwapQuote; - } - } - - if (!best) { - throw new Error( - 'All swap aggregators returned errors. Try a different amount or token pair.', - ); - } - - return harden(best); - }, - - async swapTokens(options: { - srcToken: Address; - destToken: Address; - srcAmount: Hex; - slippage: number; - }): Promise { - const ZERO_ADDRESS = - '0x0000000000000000000000000000000000000000' as Address; - - const accounts = await coordinator.getAccounts(); - const from = accounts[0]; - if (!from) { - throw new Error('No accounts available'); - } - - // Fetch a fresh quote at execution time, reusing the resolved account - const quote = await coordinator.getSwapQuote({ - ...options, - walletAddress: from, - }); - - // Determine if approval is needed - const needsApproval = - quote.approvalNeeded !== null && - options.srcToken.toLowerCase() !== ZERO_ADDRESS; - - const approvalInfo = needsApproval ? quote.approvalNeeded : null; - let approvalNeeded = false; - if (approvalInfo) { - if (!providerVat) { - throw new Error('Provider not configured'); - } - - const spender = approvalInfo.to as Address; - const allowanceCallData = encodeAllowance(from, spender); - const allowanceResult = await E(providerVat).request('eth_call', [ - { to: options.srcToken, data: allowanceCallData }, - 'latest', - ]); - - const currentAllowance = - typeof allowanceResult === 'string' && allowanceResult !== '0x' - ? decodeAllowanceResult(allowanceResult as Hex) - : 0n; - - approvalNeeded = currentAllowance < BigInt(options.srcAmount); - } - - const swapTx: TransactionRequest = { - from, - to: quote.trade.to as Address, - data: quote.trade.data as Hex, - value: (quote.trade.value ?? '0x0') as Hex, - }; - - // Batch path: combine approve + swap in a single UserOp when - // the bundler is configured (smart account). - if (approvalNeeded && approvalInfo && bundlerConfig) { - const approvalTx: TransactionRequest = { - from, - to: options.srcToken, - data: approvalInfo.data as Hex, - value: (approvalInfo.value ?? '0x0') as Hex, - }; - - const batchResult = await coordinator.sendBatchTransaction([ - approvalTx, - swapTx, - ]); - - // sendBatchTransaction returns a single Hex for batched UserOps - const batchHash = Array.isArray(batchResult) - ? (batchResult[0] as Hex) - : batchResult; - - return harden({ - approvalTxHash: undefined, - swapTxHash: batchHash, - sourceAmount: quote.sourceAmount, - destinationAmount: quote.destinationAmount, - aggregator: quote.aggregator, - batched: true, - }); - } - - // Sequential path: approve then swap (EOA or no approval needed) - let approvalTxHash: Hex | undefined; - if (approvalNeeded && approvalInfo) { - approvalTxHash = await coordinator.sendTransaction({ - from, - to: options.srcToken, - data: approvalInfo.data as Hex, - value: (approvalInfo.value ?? '0x0') as Hex, - }); - } - - try { - const swapTxHash = await coordinator.sendTransaction(swapTx); - - return harden({ - approvalTxHash, - swapTxHash, - sourceAmount: quote.sourceAmount, - destinationAmount: quote.destinationAmount, - aggregator: quote.aggregator, - }); - } catch (error: unknown) { - if (approvalTxHash) { - const message = - error instanceof Error ? error.message : String(error); - throw new Error( - `Swap transaction failed after approval was sent (approval tx: ${approvalTxHash}). ` + - `The token allowance was set but the swap did not complete: ${message}`, - ); - } - throw error; - } - }, - - async waitForUserOpReceipt(options: { - userOpHash: Hex; - pollIntervalMs?: number; - timeoutMs?: number; - }): Promise { - if (!providerVat || !bundlerConfig) { - throw new Error('Provider and bundler must be configured'); - } - - if ( - typeof globalThis.Date?.now !== 'function' || - typeof globalThis.setTimeout !== 'function' - ) { - throw new Error( - 'waitForUserOpReceipt requires Date.now and setTimeout ' + - '(not available in SES compartments without timer endowments)', - ); - } - - const interval = options.pollIntervalMs ?? 2000; - const timeout = options.timeoutMs ?? 60000; - const start = Date.now(); - - while (Date.now() - start < timeout) { - const receipt = await E(providerVat).getUserOpReceipt({ - bundlerUrl: bundlerConfig.bundlerUrl, - userOpHash: options.userOpHash, - }); - if (receipt !== null) { - return receipt; - } - await new Promise((resolve) => setTimeout(resolve, interval)); - } - throw new Error( - `UserOp ${options.userOpHash} not found after ${timeout}ms`, - ); - }, - - /** - * Poll until a regular EIP-1559 transaction is mined (e.g. stateless 7702 - * direct sends). Prefer `waitForUserOpReceipt` for ERC-4337 UserOp hashes. - * - * @param options - Polling options. - * @param options.txHash - Transaction hash to wait for. - * @param options.pollIntervalMs - Delay between RPC polls in milliseconds. - * @param options.timeoutMs - Maximum time to wait in milliseconds. - * @returns Whether the mined transaction succeeded (`status` 0x1). - */ - async waitForTransactionReceipt(options: { - txHash: Hex; - pollIntervalMs?: number; - timeoutMs?: number; - }): Promise<{ success: boolean }> { - return pollTransactionReceipt(options); - }, - - // ------------------------------------------------------------------ - // Peer wallet connectivity - // ------------------------------------------------------------------ - - async issueOcapUrl(): Promise { - if (!issuerService) { - throw new Error('OCAP URL issuer service not available'); - } - return E(issuerService).issue(coordinator); - }, - - async connectToPeer(ocapUrl: string): Promise { - if (!redemptionService) { - throw new Error('OCAP URL redemption service not available'); - } - peerWallet = (await E(redemptionService).redeem( - ocapUrl, - )) as PeerWalletFacet; - persistBaggage('peerWallet', peerWallet); - - // Cache the peer accounts for offline autonomy - try { - cachedPeerAccounts = await E(peerWallet).getAccounts(); - persistBaggage('cachedPeerAccounts', cachedPeerAccounts); - } catch (error) { - // Peer may not be ready yet; accounts can be cached later - // via refreshPeerAccounts() - logger.warn('peer account fetch failed during connect', error); - } - - // Register this coordinator as the away wallet on the home device - // so the home can push delegations directly over CapTP. - try { - await E(peerWallet).registerAwayWallet(coordinator); - } catch (error) { - // Home device may not support registerAwayWallet yet (older version). - // Delegation transfer falls back to copy-paste. - logger.warn('registerAwayWallet failed', error); - } - }, - - async refreshPeerAccounts(): Promise { - if (!peerWallet) { - throw new Error('No peer wallet connected'); - } - cachedPeerAccounts = await E(peerWallet).getAccounts(); - persistBaggage('cachedPeerAccounts', cachedPeerAccounts); - return cachedPeerAccounts; - }, - - async registerAwayWallet(awayRef: unknown): Promise { - if (!awayRef || typeof awayRef !== 'object') { - throw new Error( - 'Invalid away wallet reference: must be a non-null object', - ); - } - awayWallet = awayRef as AwayWalletFacet; - persistBaggage('awayWallet', awayWallet); - }, - - async pushDelegationToAway( - delegation: Delegation, - revokeIds?: string[], - ): Promise { - logger.info('pushDelegationToAway', { - delegationId: delegation.id, - revokeCount: revokeIds?.length ?? 0, - hasAwayWallet: Boolean(awayWallet), - }); - if (!awayWallet) { - throw new Error( - 'No away wallet registered. The away device must connect first.', - ); - } - - // Revoke old delegations on the away device first so it stops using them - if (revokeIds && revokeIds.length > 0) { - for (const id of revokeIds) { - await E(awayWallet).revokeDelegationLocally(id); - } - } - - await E(awayWallet).receiveDelegation(delegation); - }, - - async registerDelegateAddress(address: string): Promise { - if ( - !address || - typeof address !== 'string' || - !/^0x[\da-f]{40}$/iu.test(address) - ) { - throw new Error( - 'Invalid delegate address: must be a 0x-prefixed 40-hex-char string', - ); - } - pendingDelegateAddress = address as Address; - persistBaggage('pendingDelegateAddress', pendingDelegateAddress); - }, - - async getDelegateAddress(): Promise
{ - return pendingDelegateAddress; - }, - - async sendDelegateAddressToPeer(address: string): Promise { - if (!peerWallet) { - throw new Error('No peer wallet connected'); - } - await E(peerWallet).registerDelegateAddress(address); - }, - - async handleSigningRequest(request: { - type: string; - tx?: TransactionRequest; - data?: Eip712TypedData; - message?: string; - account?: Address; - }): Promise { - switch (request.type) { - case 'transaction': - if (!request.tx) { - throw new Error('Missing transaction in signing request'); - } - throw new Error( - 'Peer transaction signing is disabled; use delegation redemption', - ); - - case 'typedData': - if (!request.data) { - throw new Error('Missing typed data in signing request'); - } - if (keyringVat) { - const hasKeys = await E(keyringVat).hasKeys(); - if (hasKeys) { - return E(keyringVat).signTypedData(request.data, request.account); - } - } - if (externalSigner) { - const accounts = await E(externalSigner).getAccounts(); - if (accounts.length > 0) { - return E(externalSigner).signTypedData( - request.data, - request.account ?? accounts[0], - ); - } - } - throw new Error('No signer available to handle signing request'); - - case 'message': - if (!request.message) { - throw new Error('Missing message in signing request'); - } - if (keyringVat) { - const hasKeys = await E(keyringVat).hasKeys(); - if (hasKeys) { - return E(keyringVat).signMessage( - request.message, - request.account, - ); - } - } - if (externalSigner) { - const accounts = await E(externalSigner).getAccounts(); - if (accounts.length > 0) { - return E(externalSigner).signMessage( - request.message, - request.account ?? accounts[0], - ); - } - } - throw new Error('No signer available to handle signing request'); - - default: - throw new Error(`Unknown signing request type: ${request.type}`); - } - }, - - // ------------------------------------------------------------------ - // Peer delegation redemption relay - // ------------------------------------------------------------------ - - async handleRedemptionRequest(request: { - type: 'single' | 'batch'; - delegations: Delegation[]; - execution?: Execution; - executions?: Execution[]; - maxFeePerGas?: Hex; - maxPriorityFeePerGas?: Hex; - }): Promise { - if (!request.delegations || request.delegations.length === 0) { - throw new Error('Missing or empty delegations in redemption request'); - } - - // Guard against infinite relay loops: if this wallet cannot fulfill - // the request locally, it must not relay it back to its own peer. - // Uses the same config-based check as the relay entry condition in - // submitDelegationUserOp/submitBatchDelegationUserOp — keep in sync. - const canFulfillLocally = - bundlerConfig !== undefined || - (smartAccountConfig?.implementation === 'stateless7702' && - providerVat !== undefined); - if (!canFulfillLocally) { - throw new Error( - 'Cannot fulfill relayed redemption: no bundler or direct 7702 configured', - ); - } - - if (request.type === 'single') { - if (!request.execution) { - throw new Error('Missing execution in single redemption request'); - } - return submitDelegationUserOp({ - delegations: request.delegations, - execution: request.execution, - maxFeePerGas: request.maxFeePerGas, - maxPriorityFeePerGas: request.maxPriorityFeePerGas, - }); - } - - if (request.type === 'batch') { - if (!request.executions || request.executions.length === 0) { - throw new Error('Missing executions in batch redemption request'); - } - return submitBatchDelegationUserOp({ - delegations: request.delegations, - executions: request.executions, - }); - } - - throw new Error( - `Unknown redemption request type: ${String(request.type)}`, - ); - }, - - // ------------------------------------------------------------------ - // Introspection - // ------------------------------------------------------------------ - - async getCapabilities(): Promise { - const hasLocalKeys = keyringVat ? await E(keyringVat).hasKeys() : false; - - const localAccounts: Address[] = keyringVat - ? await E(keyringVat).getAccounts() - : []; - - const allDelegations: Delegation[] = delegationVat - ? await E(delegationVat).listDelegations() - : []; - const activeDelegations = allDelegations.filter( - (del) => del.status === 'signed', - ); - - // Resolve the signing mode so consumers (including AI agents) know - // how signing works and whether user approval is needed. - // Peer wallet takes priority — when present, it is the actual signing - // authority (the local throwaway key is an implementation detail). - let signingMode: string = 'none'; - if (peerWallet) { - try { - const peerCaps = await raceWithTimeout( - E(peerWallet).getCapabilities(), - PEER_TIMEOUT_MS, - ); - signingMode = `peer:${peerCaps.signingMode ?? 'unknown'}`; - cachedPeerSigningMode = signingMode; - persistBaggage('cachedPeerSigningMode', cachedPeerSigningMode); - } catch (error) { - logger.warn('peer getCapabilities failed, using cache', error); - signingMode = cachedPeerSigningMode ?? 'peer:unknown'; - } - } else if (externalSigner) { - signingMode = 'external:metamask'; - } else if (hasLocalKeys) { - signingMode = 'local'; - } - - // Build human-readable delegation summaries so AI agents understand - // what they can do autonomously without further user approval. - const delegationInfos = activeDelegations.map((del) => ({ - id: del.id, - delegator: del.delegator, - delegate: del.delegate, - caveats: del.caveats.map((cav) => ({ - type: cav.type, - humanReadable: describeCaveat(cav), - })), - })); - - // Determine the agent's autonomy level based on delegations. - // When delegations exist, the agent can send ETH within the - // delegation's limits without requiring further user approval. - // Stateless 7702 can redeem via direct RPC without a bundler. - // Peer relay can redeem but requires the home wallet to be online. - let autonomy: string; - const canRedeemLocally = - bundlerConfig !== undefined || - (smartAccountConfig?.implementation === 'stateless7702' && - providerVat !== undefined); - // Relay requires no smartAccountConfig — mirrors the entry condition - // in submitDelegationUserOp/submitBatchDelegationUserOp. - const canRedeemViaRelay = - !canRedeemLocally && !smartAccountConfig && peerWallet !== undefined; - const canRedeemDelegationsOnChain = - activeDelegations.length > 0 && (canRedeemLocally || canRedeemViaRelay); - if (canRedeemDelegationsOnChain) { - const limits = activeDelegations - .flatMap((del) => del.caveats) - .map(describeCaveat) - .filter(Boolean); - const base = - limits.length > 0 - ? `autonomous within limits: ${limits.join('; ')}` - : 'autonomous (no spending limits)'; - if (canRedeemViaRelay) { - autonomy = `${base} (relay, requires home online)`; - } else { - autonomy = - cachedPeerAccounts.length > 0 ? `${base} (offline-capable)` : base; - } - } else if (peerWallet) { - autonomy = 'requires peer wallet approval for each action'; - } else { - autonomy = 'no signing authority'; - } - - let capabilityChainId: number | undefined; - try { - capabilityChainId = await resolveChainId(); - } catch (error) { - logger.warn('Failed to resolve chain ID for capabilities', error); - } - - return harden({ - hasLocalKeys, - localAccounts, - delegationCount: activeDelegations.length, - delegations: delegationInfos, - hasPeerWallet: peerWallet !== undefined, - hasExternalSigner: externalSigner !== undefined, - hasBundlerConfig: bundlerConfig !== undefined, - smartAccountAddress: smartAccountConfig?.address, - chainId: capabilityChainId, - signingMode, - autonomy, - peerAccountsCached: cachedPeerAccounts.length > 0, - cachedPeerAccounts, - hasAwayWallet: awayWallet !== undefined, - }); - }, - }); - return coordinator; -} diff --git a/packages/evm-wallet-experiment/src/vats/delegation-vat.test.ts b/packages/evm-wallet-experiment/src/vats/delegation-vat.test.ts deleted file mode 100644 index d362451852..0000000000 --- a/packages/evm-wallet-experiment/src/vats/delegation-vat.test.ts +++ /dev/null @@ -1,416 +0,0 @@ -import { privateKeyToAccount } from 'viem/accounts'; -import { describe, it, expect, beforeEach } from 'vitest'; - -import { buildRootObject } from './delegation-vat.ts'; -import { makeMockBaggage } from '../../test/helpers.ts'; -import { DEFAULT_DELEGATION_MANAGER } from '../constants.ts'; -import { - encodeAllowedTargets, - encodeValueLte, - makeCaveat, -} from '../lib/caveats.ts'; -import { - finalizeDelegation, - makeDelegation, - prepareDelegationTypedData, -} from '../lib/delegation.ts'; -import type { Address, Hex } from '../types.ts'; - -// Deterministic test key (DO NOT use in production) -const TEST_PRIVATE_KEY = - '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80'; -const TEST_ACCOUNT = privateKeyToAccount(TEST_PRIVATE_KEY); - -const ALICE = '0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266' as Address; -const BOB = '0x70997970c51812dc3a010c7d01b50e0d17dc79c8' as Address; -const TARGET = '0x1234567890abcdef1234567890abcdef12345678' as Address; - -describe('delegation-vat', () => { - let baggage: ReturnType; - let root: ReturnType; - - beforeEach(() => { - baggage = makeMockBaggage(); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - root = buildRootObject({}, {}, baggage as any); - }); - - describe('bootstrap', () => { - it('completes without error', async () => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - expect(await (root as any).bootstrap()).toBeUndefined(); - }); - }); - - describe('createDelegation', () => { - it('creates an unsigned delegation', async () => { - const caveats = [ - makeCaveat({ - type: 'allowedTargets', - terms: encodeAllowedTargets([TARGET]), - }), - ]; - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const delegation = await (root as any).createDelegation({ - delegator: ALICE, - delegate: BOB, - caveats, - chainId: 1, - }); - - expect(delegation.delegator).toBe(ALICE); - expect(delegation.delegate).toBe(BOB); - expect(delegation.status).toBe('pending'); - expect(delegation.caveats).toHaveLength(1); - }); - - it('persists the delegation in baggage', async () => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - await (root as any).createDelegation({ - delegator: ALICE, - delegate: BOB, - caveats: [], - chainId: 1, - }); - - expect(baggage.has('delegations')).toBe(true); - }); - }); - - describe('prepareDelegationForSigning', () => { - it('returns EIP-712 typed data', async () => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const delegation = await (root as any).createDelegation({ - delegator: ALICE, - delegate: BOB, - caveats: [], - chainId: 1, - }); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const typedData = await (root as any).prepareDelegationForSigning( - delegation.id, - ); - - expect(typedData.primaryType).toBe('Delegation'); - expect(typedData.types).toHaveProperty('Delegation'); - expect(typedData.domain).toHaveProperty('chainId', 1); - }); - - it('throws for unknown delegation', async () => { - await expect( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (root as any).prepareDelegationForSigning('nonexistent'), - ).rejects.toThrow('Delegation not found'); - }); - }); - - describe('storeSigned', () => { - it('marks delegation as signed', async () => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const delegation = await (root as any).createDelegation({ - delegator: ALICE, - delegate: BOB, - caveats: [], - chainId: 1, - }); - - const signature = '0xdeadbeef' as Hex; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - await (root as any).storeSigned(delegation.id, signature); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const stored = await (root as any).getDelegation(delegation.id); - expect(stored.status).toBe('signed'); - expect(stored.signature).toBe(signature); - }); - }); - - describe('receiveDelegation', () => { - async function makeProperlySignedDelegation() { - const unsigned = makeDelegation({ - delegator: ALICE, - delegate: BOB, - caveats: [], - chainId: 1, - }); - const typedData = prepareDelegationTypedData({ - delegation: unsigned, - verifyingContract: DEFAULT_DELEGATION_MANAGER, - }); - const signature = await TEST_ACCOUNT.signTypedData({ - domain: typedData.domain as Record, - types: typedData.types as Record< - string, - { name: string; type: string }[] - >, - primaryType: typedData.primaryType, - message: typedData.message, - }); - return finalizeDelegation(unsigned, signature); - } - - it('stores a signed delegation with valid signature', async () => { - const delegation = await makeProperlySignedDelegation(); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - await (root as any).receiveDelegation(delegation); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const stored = await (root as any).getDelegation(delegation.id); - expect(stored.status).toBe('signed'); - }); - - it('rejects unsigned delegations', async () => { - const delegation = makeDelegation({ - delegator: ALICE, - delegate: BOB, - caveats: [], - chainId: 1, - }); - - await expect( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (root as any).receiveDelegation(delegation), - ).rejects.toThrow('Can only receive signed delegations'); - }); - - it('rejects delegation with missing signature', async () => { - const delegation = makeDelegation({ - delegator: ALICE, - delegate: BOB, - caveats: [], - chainId: 1, - }); - // Force status to 'signed' but without a signature - const noSig = { ...delegation, status: 'signed' as const }; - - await expect( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (root as any).receiveDelegation(noSig), - ).rejects.toThrow('Delegation has no signature'); - }); - - it('rejects delegation with mismatched ID', async () => { - const delegation = await makeProperlySignedDelegation(); - const tampered = { ...delegation, id: '0xbadid' }; - - await expect( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (root as any).receiveDelegation(tampered), - ).rejects.toThrow('Delegation ID mismatch'); - }); - - it('accepts delegation without verifying signature (deferred to on-chain)', async () => { - const unsigned = makeDelegation({ - delegator: ALICE, - delegate: BOB, - caveats: [], - chainId: 1, - }); - // Garbage signature — accepted because verification is deferred to on-chain - const fakeSig = `0x${'ab'.repeat(32)}${'cd'.repeat(32)}1b`; - const signed = finalizeDelegation(unsigned, fakeSig as Hex); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - await (root as any).receiveDelegation(signed); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const found = await (root as any).findDelegationForAction( - { to: '0x0000000000000000000000000000000000000001' }, - 1, - ); - expect(found).toBeDefined(); - expect(found.id).toBe(signed.id); - }); - }); - - describe('findDelegationForAction', () => { - it('finds a matching delegation', async () => { - const caveats = [ - makeCaveat({ - type: 'allowedTargets', - terms: encodeAllowedTargets([TARGET]), - }), - ]; - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const delegation = await (root as any).createDelegation({ - delegator: ALICE, - delegate: BOB, - caveats, - chainId: 1, - }); - - // Sign it - // eslint-disable-next-line @typescript-eslint/no-explicit-any - await (root as any).storeSigned(delegation.id, '0xdeadbeef' as Hex); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const found = await (root as any).findDelegationForAction({ - to: TARGET, - }); - - expect(found).toBeDefined(); - expect(found.id).toBe(delegation.id); - }); - - it('returns undefined when no delegation matches', async () => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const found = await (root as any).findDelegationForAction({ - to: TARGET, - }); - - expect(found).toBeUndefined(); - }); - - it('filters by chainId when provided', async () => { - // Create delegation on chain 1 - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const delegation = await (root as any).createDelegation({ - delegator: ALICE, - delegate: BOB, - caveats: [ - makeCaveat({ - type: 'allowedTargets', - terms: encodeAllowedTargets([TARGET]), - }), - ], - chainId: 1, - }); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - await (root as any).storeSigned(delegation.id, '0xdeadbeef' as Hex); - - // Should find on chain 1 - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const found = await (root as any).findDelegationForAction( - { to: TARGET }, - 1, - ); - expect(found).toBeDefined(); - expect(found.id).toBe(delegation.id); - - // Should NOT find on chain 42 - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const notFound = await (root as any).findDelegationForAction( - { to: TARGET }, - 42, - ); - expect(notFound).toBeUndefined(); - }); - - it('returns all chains when chainId is omitted', async () => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const delegation = await (root as any).createDelegation({ - delegator: ALICE, - delegate: BOB, - caveats: [], - chainId: 137, - }); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - await (root as any).storeSigned(delegation.id, '0xdeadbeef' as Hex); - - // No chainId filter — should still find it - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const found = await (root as any).findDelegationForAction({ - to: TARGET, - }); - expect(found).toBeDefined(); - }); - }); - - describe('explainActionMatch', () => { - it('returns failure reasons for non-matching delegations', async () => { - const caveats = [ - makeCaveat({ - type: 'valueLte', - terms: encodeValueLte(100000000000000000n), // 0.1 ETH - }), - ]; - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const delegation = await (root as any).createDelegation({ - delegator: ALICE, - delegate: BOB, - caveats, - chainId: 1, - }); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - await (root as any).storeSigned(delegation.id, '0xdeadbeef' as Hex); - - // Value exceeds limit - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const results = await (root as any).explainActionMatch( - { to: TARGET, value: '0x1BC16D674EC80000' }, // 2 ETH - 1, - ); - - expect(results).toHaveLength(1); - expect(results[0].result.matches).toBe(false); - expect(results[0].result.failedCaveat).toBe('valueLte'); - expect(results[0].result.reason).toMatch(/exceeds maximum/u); - }); - - it('returns match for matching delegations', async () => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const delegation = await (root as any).createDelegation({ - delegator: ALICE, - delegate: BOB, - caveats: [], - chainId: 1, - }); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - await (root as any).storeSigned(delegation.id, '0xdeadbeef' as Hex); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const results = await (root as any).explainActionMatch({ to: TARGET }, 1); - - expect(results).toHaveLength(1); - expect(results[0].result.matches).toBe(true); - }); - }); - - describe('listDelegations', () => { - it('lists all delegations', async () => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - await (root as any).createDelegation({ - delegator: ALICE, - delegate: BOB, - caveats: [], - chainId: 1, - }); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - await (root as any).createDelegation({ - delegator: ALICE, - delegate: BOB, - caveats: [], - chainId: 1, - }); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const delegations = await (root as any).listDelegations(); - expect(delegations).toHaveLength(2); - }); - }); - - describe('revokeDelegation', () => { - it('marks a delegation as revoked', async () => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const delegation = await (root as any).createDelegation({ - delegator: ALICE, - delegate: BOB, - caveats: [], - chainId: 1, - }); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - await (root as any).revokeDelegation(delegation.id); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const revoked = await (root as any).getDelegation(delegation.id); - expect(revoked.status).toBe('revoked'); - }); - }); -}); diff --git a/packages/evm-wallet-experiment/src/vats/delegation-vat.ts b/packages/evm-wallet-experiment/src/vats/delegation-vat.ts deleted file mode 100644 index 6637a76e07..0000000000 --- a/packages/evm-wallet-experiment/src/vats/delegation-vat.ts +++ /dev/null @@ -1,211 +0,0 @@ -import { makeDefaultExo } from '@metamask/kernel-utils/exo'; -import type { Logger } from '@metamask/logger'; -import type { Baggage } from '@metamask/ocap-kernel'; - -import { DEFAULT_DELEGATION_MANAGER } from '../constants.ts'; -import { - computeDelegationId, - makeDelegation, - prepareDelegationTypedData, - delegationMatchesAction, - explainDelegationMatch, - finalizeDelegation, -} from '../lib/delegation.ts'; -import type { - Action, - Address, - CreateDelegationOptions, - Delegation, - DelegationGrant, - DelegationMatchResult, - Eip712TypedData, - Hex, -} from '../types.ts'; - -const harden = globalThis.harden ?? ((value: T): T => value); - -/** - * Vat powers for the delegation vat. - */ -type VatPowers = { - logger?: Logger; -}; - -/** - * Build the root object for the delegation vat. - * - * The delegation vat manages Gator delegations: creating, storing, - * signing, matching, and revoking them. - * - * @param _vatPowers - Special powers granted to this vat. - * @param parameters - Initialization parameters. - * @param parameters.delegationManagerAddress - The delegation manager contract address. - * @param baggage - Root of vat's persistent state. - * @returns The root object for the delegation vat. - */ -export function buildRootObject( - _vatPowers: VatPowers, - parameters: { delegationManagerAddress?: Address } | undefined, - baggage: Baggage, -): object { - const delegationManagerAddress = - parameters?.delegationManagerAddress ?? DEFAULT_DELEGATION_MANAGER; - - // Restore delegations from baggage - const delegations: Map = baggage.has('delegations') - ? new Map( - Object.entries( - baggage.get('delegations') as Record, - ), - ) - : new Map(); - - /** - * Persist the current delegations map to baggage. - */ - function persistDelegations(): void { - const serialized = harden(Object.fromEntries(delegations)); - if (baggage.has('delegations')) { - baggage.set('delegations', serialized); - } else { - baggage.init('delegations', serialized); - } - } - - return makeDefaultExo('walletDelegation', { - async bootstrap(): Promise { - // No services needed for the delegation vat - }, - - async createDelegation( - options: CreateDelegationOptions & { delegator: Address }, - ): Promise { - const delegation = harden( - makeDelegation({ - delegator: options.delegator, - delegate: options.delegate, - caveats: options.caveats, - chainId: options.chainId, - ...(options.salt ? { salt: options.salt } : {}), - }), - ); - delegations.set(delegation.id, delegation); - persistDelegations(); - return delegation; - }, - - async prepareDelegationForSigning(id: string): Promise { - const delegation = delegations.get(id); - if (!delegation) { - throw new Error(`Delegation not found: ${id}`); - } - return harden( - prepareDelegationTypedData({ - delegation, - verifyingContract: delegationManagerAddress, - }), - ); - }, - - async storeSigned(id: string, signature: Hex): Promise { - const delegation = delegations.get(id); - if (!delegation) { - throw new Error(`Delegation not found: ${id}`); - } - const signed = harden(finalizeDelegation(delegation, signature)); - delegations.set(id, signed); - persistDelegations(); - }, - - async receiveDelegation(delegation: Delegation): Promise { - if (delegation.status !== 'signed') { - throw new Error('Can only receive signed delegations'); - } - if (!delegation.signature) { - throw new Error('Delegation has no signature'); - } - - // Verify the delegation ID is consistent with the fields - const expectedId = computeDelegationId(delegation); - if (delegation.id !== expectedId) { - throw new Error('Delegation ID mismatch'); - } - - // Signature verification is skipped here. When the delegator is a - // smart account, the EIP-712 signature is made by the underlying - // EOA owner — ecrecover returns the EOA, not the smart account - // address. The on-chain DelegationManager performs the authoritative - // signature check during delegation redemption. - - delegations.set(delegation.id, delegation); - persistDelegations(); - }, - - async findDelegationForAction( - action: Action, - chainId?: number, - currentTime?: number, - ): Promise { - for (const delegation of delegations.values()) { - if (chainId !== undefined && delegation.chainId !== chainId) { - continue; - } - if (delegationMatchesAction(delegation, action, currentTime)) { - return delegation; - } - } - return undefined; - }, - - async explainActionMatch( - action: Action, - chainId?: number, - currentTime?: number, - ): Promise<{ delegationId: string; result: DelegationMatchResult }[]> { - const results: { - delegationId: string; - result: DelegationMatchResult; - }[] = []; - for (const delegation of delegations.values()) { - if (chainId !== undefined && delegation.chainId !== chainId) { - continue; - } - results.push({ - delegationId: delegation.id, - result: explainDelegationMatch(delegation, action, currentTime), - }); - } - return harden(results); - }, - - async getDelegation(id: string): Promise { - const delegation = delegations.get(id); - if (!delegation) { - throw new Error(`Delegation not found: ${id}`); - } - return harden(delegation); - }, - - async listDelegations(): Promise { - return harden([...delegations.values()]); - }, - - async revokeDelegation(id: string): Promise { - const delegation = delegations.get(id); - if (!delegation) { - throw new Error(`Delegation not found: ${id}`); - } - delegations.set( - id, - harden({ ...delegation, status: 'revoked' as const }), - ); - persistDelegations(); - }, - - async storeDelegation(grant: DelegationGrant): Promise { - const { delegation } = grant; - delegations.set(delegation.id, delegation); - persistDelegations(); - }, - }); -} From f7c0c5e8e8c1ee73df27cf7dbfbb404aae024e54 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Mon, 20 Apr 2026 11:15:24 -0400 Subject: [PATCH 08/23] test(evm-wallet-experiment): migrate Docker e2e tests to new coordinator API Replace old coordinator API references (createDelegation, provisionTwin, listDelegations, pushDelegationToAway) with the new semantic grant methods (buildTransferNativeGrant, receiveDelegation, listGrants, transferNative). - wallet-setup.ts: launchWalletSubcluster now accepts role:'home'|'away' and points to home-coordinator.bundle/away-coordinator.bundle with the appropriate delegator/redeemer auxiliary vat - setup-wallets.ts: replace createDelegation+provisionTwin with buildTransferNativeGrant+receiveDelegation - docker-e2e.test.ts: delegation redemption suite uses new grant API; DelegationGrant type replaces old Delegation type; transferNative replaces sendTransaction for delegated ETH sends - run-delegation-twin-e2e.mjs: rewrites twin test using buildTransferFungibleGrant+receiveDelegation+transferFungible - docker-exec.ts: callVat now passes --raw to the CLI and decodes smallcaps CapData inline, fixing BigInt values arriving as '1000000000000000000n' strings from prettifySmallcaps Co-Authored-By: Claude Sonnet 4.6 --- packages/evm-wallet-experiment/package.json | 2 +- .../src/lib/delegation-twin.test.ts | 19 --- .../test/e2e/docker/docker-e2e.test.ts | 48 ++++---- .../test/e2e/docker/helpers/docker-exec.ts | 77 +++++++++++- .../test/e2e/docker/helpers/scenarios.ts | 7 +- .../test/e2e/docker/helpers/wallet-setup.ts | 39 ++++-- .../e2e/docker/run-delegation-twin-e2e.mjs | 116 +++++------------- .../test/e2e/docker/setup-wallets.ts | 44 ++----- .../test/integration/peer-wallet.test.ts | 62 +++------- 9 files changed, 197 insertions(+), 217 deletions(-) diff --git a/packages/evm-wallet-experiment/package.json b/packages/evm-wallet-experiment/package.json index 5b2f5f693c..420a698933 100644 --- a/packages/evm-wallet-experiment/package.json +++ b/packages/evm-wallet-experiment/package.json @@ -29,7 +29,7 @@ "dist/" ], "scripts": { - "build": "ocap bundle src/vats/home-coordinator.ts src/vats/away-coordinator.ts src/vats/keyring-vat.ts src/vats/provider-vat.ts", + "build": "ocap bundle src/vats/home-coordinator.ts src/vats/away-coordinator.ts src/vats/keyring-vat.ts src/vats/provider-vat.ts src/vats/delegator-vat.ts src/vats/redeemer-vat.ts", "build:docs": "typedoc", "changelog:validate": "../../scripts/validate-changelog.sh @ocap/evm-wallet-experiment", "clean": "rimraf --glob './*.tsbuildinfo' ./.eslintcache ./coverage ./dist ./.turbo ./logs", 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 b0c4074eaf..635ccbd90a 100644 --- a/packages/evm-wallet-experiment/src/lib/delegation-twin.test.ts +++ b/packages/evm-wallet-experiment/src/lib/delegation-twin.test.ts @@ -27,10 +27,6 @@ vi.mock('@metamask/kernel-utils/discoverable', () => ({ }, })); -vi.mock('@metamask/kernel-utils', () => ({ - constant: (value: unknown) => ({ kind: 'constant', value }), -})); - const ALICE = '0x1111111111111111111111111111111111111111' as Address; const BOB = '0x2222222222222222222222222222222222222222' as Address; const TOKEN = '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as Address; @@ -123,21 +119,6 @@ describe('makeDelegationTwin', () => { /exceeds limit/u, ); }); - - it('attaches constant delegation metadata', () => { - const redeemFn = vi.fn(); - const section = makeDelegationTwin({ - grant: makeTransferNativeGrant(), - redeemFn, - }); - expect(section.metadata).toStrictEqual({ - kind: 'constant', - value: { - mode: 'delegation', - delegationId: BASE_DELEGATION.id, - }, - }); - }); }); describe('transferFungible twin', () => { 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 bfd19c5734..139a5bd35d 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 @@ -52,11 +52,15 @@ function dockerE2eDelegationModes(): DockerE2eKernelMode[] { } const BURN_ADDRESS = '0x000000000000000000000000000000000000dEaD'; -type Delegation = { - id: string; - delegate: string; - delegator: string; - status: string; +type DelegationGrant = { + method: string; + delegation: { + id: string; + delegate: string; + delegator: string; + status: string; + chainId: number; + }; }; type Capabilities = { @@ -245,7 +249,7 @@ describe('Docker E2E', () => { // --------------------------------------------------------------------------- describe('delegation redemption', () => { - let delegation: Delegation; + let grant: DelegationGrant; beforeAll(() => { const delegate = resolveOnChainDelegateAddress({ @@ -254,27 +258,26 @@ describe('Docker E2E', () => { away: awayResult, }); - delegation = callHome('createDelegation', [ - { delegate, caveats: [], chainId: 31337 }, - ]) as Delegation; + // Build and sign a transfer-native grant on home (1 ETH max spend). + // maxAmount is a string because JSON cannot carry BigInt. + grant = callHome('buildTransferNativeGrant', [ + { delegate, maxAmount: '1000000000000000000', chainId: 31337 }, + ]) as DelegationGrant; - callAway('receiveDelegation', [delegation]); + callAway('receiveDelegation', [grant]); }); - it('creates a signed delegation', () => { - expect(delegation.status).toBe('signed'); + it('creates a signed grant', () => { + expect(grant.delegation.status).toBe('signed'); }); - it('lists delegation on away', () => { - const delegations = callAway('listDelegations') as Delegation[]; - expect(delegations.length).toBeGreaterThanOrEqual(1); - expect(delegations[0]?.id).toBe(delegation.id); + it('lists grant on away', () => { + const grants = callAway('listGrants') as DelegationGrant[]; + expect(grants.length).toBeGreaterThanOrEqual(1); + expect(grants[0]?.delegation.id).toBe(grant.delegation.id); }); it('sends ETH via delegated authority', async () => { - const homeSA = homeResult.smartAccountAddress; - expect(homeSA).toBeDefined(); - const balanceBefore = BigInt( (await evmRpc('eth_getBalance', [ BURN_ADDRESS, @@ -282,8 +285,11 @@ describe('Docker E2E', () => { ])) as string, ); - const submitHash = callAway('sendTransaction', [ - { from: homeSA, to: BURN_ADDRESS, value: '0xDE0B6B3A7640000' }, + // transferNative routes through the away coordinator → delegation twin + const submitHash = callAway('transferNative', [ + BURN_ADDRESS, + // 0.1 ETH as a string (BigInt not supported in JSON) + '100000000000000000', ]) as string; expect(submitHash).toMatch(/^0x[\da-f]{64}$/iu); 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 e3fd4e7239..93960a70d3 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 @@ -11,6 +11,67 @@ import { fileURLToPath } from 'node:url'; import { dockerE2eRequiredComposeServices } from './docker-e2e-kernel-services.ts'; +/** + * Decode a smallcaps CapData value into a plain JS value safe for use as RPC + * arguments. BigInts encoded as "+N" are returned as decimal strings "N" so + * downstream BigInt("N") calls succeed without a trailing-n error. + * + * This is intentionally a subset of full kunser: it does not reconstruct + * remotables or promises — only plain data structures appear in grant objects. + * + * @param value - The parsed JSON value from the smallcaps body. + * @returns The decoded value. + */ +function decodeSmallcapsValue(value: unknown): unknown { + if (typeof value === 'string') { + if (value.startsWith('+')) { + return value.slice(1); + } // non-negative bigint → decimal string + if (value.startsWith('-')) { + return value; + } // negative bigint stays as-is (valid for BigInt()) + if (value.startsWith('!')) { + return value.slice(1); + } // escaped string + return value; + } + if (Array.isArray(value)) { + return value.map(decodeSmallcapsValue); + } + if (typeof value === 'object' && value !== null) { + const result: Record = {}; + for (const [key, val] of Object.entries(value as Record)) { + const unescapedKey = key.startsWith('!') ? key.slice(1) : key; + result[unescapedKey] = decodeSmallcapsValue(val); + } + return result; + } + return value; +} + +/** + * Decode a raw CapData object (as produced by the CLI with --raw) into a + * plain JS value suitable for passing as RPC arguments. + * + * @param capData - The CapData object with body and slots. + * @param capData.body - Smallcaps-encoded body string (prefixed with `#`). + * @param capData.slots - Slot KRefs (unused for plain-data objects). + * @returns The decoded value. + */ +function decodeCapDataForRpc(capData: { + body: string; + slots: string[]; +}): unknown { + const { body } = capData; + if (!body.startsWith('#')) { + throw new Error( + `Unexpected CapData body format (missing # prefix): ${body.slice(0, 40)}`, + ); + } + const parsed: unknown = JSON.parse(body.slice(1)); + return decodeSmallcapsValue(parsed); +} + const currentDir = dirname(fileURLToPath(import.meta.url)); const COMPOSE_FILE = resolve( @@ -113,9 +174,11 @@ export function callVat( ): unknown { const daemonTimeout = options?.daemonTimeoutSeconds ?? 60; const execTimeoutMs = daemonTimeout * 1000 + 30_000; - const argsJson = JSON.stringify(args); + const argsJson = JSON.stringify(args, (_key, value: unknown) => + typeof value === 'bigint' ? String(value) : value, + ); const raw = execSync( - `docker ${composePrefix()} exec -T ${service} ${CLI} daemon queueMessage ${shellSingleQuote(kref)} ${shellSingleQuote(method)} ${shellSingleQuote(argsJson)} --timeout ${daemonTimeout}`, + `docker ${composePrefix()} exec -T ${service} ${CLI} daemon queueMessage ${shellSingleQuote(kref)} ${shellSingleQuote(method)} ${shellSingleQuote(argsJson)} --raw --timeout ${daemonTimeout}`, { encoding: 'utf-8', timeout: execTimeoutMs }, ).trim(); @@ -130,6 +193,16 @@ export function callVat( throw new Error(parsed); } + if ( + typeof parsed === 'object' && + parsed !== null && + 'body' in parsed && + 'slots' in parsed && + typeof (parsed as { body: unknown }).body === 'string' && + Array.isArray((parsed as { slots: unknown }).slots) + ) { + return decodeCapDataForRpc(parsed as { body: string; slots: string[] }); + } return parsed; } diff --git a/packages/evm-wallet-experiment/test/e2e/docker/helpers/scenarios.ts b/packages/evm-wallet-experiment/test/e2e/docker/helpers/scenarios.ts index 2a0ff273bc..ea0263e0d0 100644 --- a/packages/evm-wallet-experiment/test/e2e/docker/helpers/scenarios.ts +++ b/packages/evm-wallet-experiment/test/e2e/docker/helpers/scenarios.ts @@ -52,7 +52,8 @@ export type AwayResult = { export type { DockerKernelServicePair }; /** - * Address to pass as `delegate` when calling `createDelegation`. + * Address to pass as `delegate` when calling `buildTransferNativeGrant` or + * `buildTransferFungibleGrant`. * * DelegationManager.redeemDelegations requires `delegations[0].delegate == msg.sender` * (unless delegate is `ANY_DELEGATE`). Away wallets with a bundler redeem with @@ -63,7 +64,7 @@ export type { DockerKernelServicePair }; * @param options.delegationMode - `bundler-7702`, `bundler-hybrid`, or `peer-relay`. * @param options.home - Home wallet setup result. * @param options.away - Away wallet setup result. - * @returns The `delegate` field for `createDelegation`. + * @returns The `delegate` field for grant creation. */ export function resolveOnChainDelegateAddress(options: { delegationMode: string; @@ -94,6 +95,7 @@ export function setupHome( const info = getServiceInfo(services.home); const kref = launchWalletSubcluster(services.home, { + role: 'home', contracts, allowedHosts: ALLOWED_HOSTS, }); @@ -157,6 +159,7 @@ function setupAwayBase( home: HomeResult, ): { kref: string; delegateAddress: string } { const kref = launchWalletSubcluster(services.away, { + role: 'away', contracts, allowedHosts: ALLOWED_HOSTS, }); diff --git a/packages/evm-wallet-experiment/test/e2e/docker/helpers/wallet-setup.ts b/packages/evm-wallet-experiment/test/e2e/docker/helpers/wallet-setup.ts index 79f85f285b..6edb24862d 100644 --- a/packages/evm-wallet-experiment/test/e2e/docker/helpers/wallet-setup.ts +++ b/packages/evm-wallet-experiment/test/e2e/docker/helpers/wallet-setup.ts @@ -21,6 +21,7 @@ const ANVIL_FUNDER = '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266'; * * @param service - The compose service name. * @param options - Subcluster configuration. + * @param options.role - Whether this is a 'home' or 'away' coordinator. * @param options.contracts - Deployed contract addresses. * @param options.allowedHosts - Hostnames the provider vat may fetch from. * @returns The root coordinator kref (e.g. 'ko4'). @@ -28,18 +29,40 @@ const ANVIL_FUNDER = '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266'; export function launchWalletSubcluster( service: string, options: { + role: 'home' | 'away'; contracts: ContractAddresses; allowedHosts: string[]; }, ): string { - const { contracts, allowedHosts } = options; + const { role, allowedHosts } = options; + + const coordinatorBundle = + role === 'home' + ? `${BUNDLE_BASE}/home-coordinator.bundle` + : `${BUNDLE_BASE}/away-coordinator.bundle`; + + const auxiliaryVat = + role === 'home' + ? { + delegator: { + bundleSpec: `${BUNDLE_BASE}/delegator-vat.bundle`, + globals: ['TextEncoder', 'TextDecoder'], + }, + } + : { + redeemer: { + bundleSpec: `${BUNDLE_BASE}/redeemer-vat.bundle`, + globals: ['TextEncoder', 'TextDecoder'], + }, + }; + const config = { bootstrap: 'coordinator', forceReset: false, services: ['ocapURLIssuerService', 'ocapURLRedemptionService'], vats: { coordinator: { - bundleSpec: `${BUNDLE_BASE}/coordinator-vat.bundle`, + bundleSpec: coordinatorBundle, globals: ['TextEncoder', 'TextDecoder', 'Date', 'setTimeout'], }, keyring: { @@ -51,17 +74,7 @@ export function launchWalletSubcluster( globals: ['TextEncoder', 'TextDecoder'], platformConfig: { fetch: { allowedHosts } }, }, - delegation: { - bundleSpec: `${BUNDLE_BASE}/delegation-vat.bundle`, - globals: ['TextEncoder', 'TextDecoder'], - ...(contracts.DelegationManager - ? { - parameters: { - delegationManagerAddress: contracts.DelegationManager, - }, - } - : {}), - }, + ...auxiliaryVat, }, }; 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 b97cf4ee9f..55aa19dfdd 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 @@ -2,20 +2,18 @@ /** * Delegation-twin E2E test — runs **inside** the away container. * - * Exercises the delegation twin as a live exo capability by connecting - * directly to the kernel daemon sockets via daemon-client.mjs. Both the - * home and away sockets are accessible through the shared `ocap-run` volume - * at /run/ocap/-ready.json. + * Exercises the delegation twin as a live exo capability by calling the + * home coordinator to build a signed grant, sending it to the away + * coordinator via receiveDelegation, and then calling transferFungible + * through the away coordinator (which routes via the delegation twin). * * ── What it tests ───────────────────────────────────────────────────────── * - * 1. Home creates a transfer grant (max spend = 5 units, fake token). - * 2. Away provisions the twin and calls transfer(3) → succeeds on-chain. - * 3. Away calls transfer(3) again → twin rejects LOCALLY ("Insufficient - * budget") before any network call is made. - * 4. Away provisions a call twin with a valueLte(100) caveat and calls - * with value=200 → twin passes through, bundler simulation rejects. - * Demonstrates chain enforcement of a caveat the twin doesn't check. + * 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. * * ── Usage ───────────────────────────────────────────────────────────────── * @@ -98,44 +96,40 @@ function assert(condition, label) { console.log(`\n=== Delegation Twin E2E (${mode}) ===\n`); -// ── Test 1: Twin enforces cumulative spend locally ───────────────────────── +// ── Test: Twin enforces cumulative spend locally ─────────────────────────── console.log('--- Transfer twin: spend tracking ---'); -const transferGrant = await homeClient.callVat( +// Home builds and signs the grant. maxAmount is a string because JSON cannot +// carry BigInt; buildTransferFungibleGrant coerces it back to BigInt. +const signedGrant = await homeClient.callVat( homeKref, - 'makeDelegationGrant', + 'buildTransferFungibleGrant', [ - 'transfer', { delegate: delegateAddress, token: FAKE_TOKEN, - // Passed as a string because the daemon JSON-RPC protocol carries plain - // JSON; coordinator-vat coerces it to BigInt before buildDelegationGrant. - max: '5', + maxAmount: '5', chainId: CHAIN_ID, }, ], ); assert( - transferGrant !== null && typeof transferGrant === 'object', - 'home created transfer grant', + signedGrant !== null && typeof signedGrant === 'object', + 'home built and signed transfer-fungible grant', ); -const twinStandin = await awayClient.callVat(awayKref, 'provisionTwin', [ - transferGrant, -]); -const twinKref = twinStandin.getKref(); +// Away receives the grant: redeemer vat stores it, routing is rebuilt +// with a delegation twin that enforces the 5-unit budget. +await awayClient.callVat(awayKref, 'receiveDelegation', [signedGrant]); -assert( - typeof twinKref === 'string' && twinKref.length > 0, - `twin kref: ${twinKref}`, -); +assert(true, 'away received delegation and rebuilt routing'); // First spend: 3 ≤ 5 remaining → should reach the chain and succeed. -console.log(' Calling transfer(3) — should hit chain...'); -const txHash = await awayClient.callVat(twinKref, 'transfer', [ +console.log(' Calling transferFungible(3) — should hit chain...'); +const txHash = await awayClient.callVat(awayKref, 'transferFungible', [ + FAKE_TOKEN, BURN_ADDRESS, '3', ]); @@ -154,62 +148,18 @@ assert( ); // Second spend: 3 + 3 = 6 > 5 → should be rejected LOCALLY by the -// SpendTracker without making any network call. -console.log(' Calling transfer(3) again — should fail locally...'); -const secondBody = await awayClient.callVatExpectError(twinKref, 'transfer', [ - BURN_ADDRESS, - '3', -]); - -assert( - typeof secondBody === 'string' && secondBody.includes('Insufficient budget'), - `second spend (3 units) rejected locally: ${String(secondBody).slice(0, 80)}`, -); - -// ── Test 2 (comparison): expired delegation — twin is blind, chain rejects ── -// -// The twin has no local check for blockWindow / TimestampEnforcer. It passes -// the call straight to redeemFn; the chain rejects because validUntil is in -// the past. This is the canonical example of a caveat the twin doesn't track. - -console.log('\n--- Expired delegation: chain enforcement ---'); - -// validUntil 60 s in the past — delegation is already expired. -const expiredAt = Math.floor(Date.now() / 1000) - 60; -const expiredGrant = await homeClient.callVat(homeKref, 'makeDelegationGrant', [ - 'call', - { - delegate: delegateAddress, - targets: [BURN_ADDRESS], - chainId: CHAIN_ID, - validUntil: expiredAt, - }, -]); - -assert( - expiredGrant !== null && typeof expiredGrant === 'object', - 'home created expired call grant', -); - -const expiredTwinStandin = await awayClient.callVat(awayKref, 'provisionTwin', [ - expiredGrant, -]); -const expiredTwinKref = expiredTwinStandin.getKref(); - -// The twin has no blockWindow check — it calls redeemFn, which reaches the -// chain/bundler, which rejects with a TimestampEnforcer revert. -console.log( - ' Calling with expired delegation — twin should pass, chain should reject...', -); -const expiredError = await awayClient.callVatExpectError( - expiredTwinKref, - 'call', - [BURN_ADDRESS, 0, '0x'], +// 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'], ); assert( - typeof expiredError === 'string' && expiredError.length > 0, - `expired delegation rejected by chain (not twin): ${String(expiredError).slice(0, 80)}`, + typeof secondError === 'string' && + secondError.includes('Insufficient budget'), + `second spend (3 units) rejected locally: ${String(secondError).slice(0, 80)}`, ); // ── Results ──────────────────────────────────────────────────────────────── diff --git a/packages/evm-wallet-experiment/test/e2e/docker/setup-wallets.ts b/packages/evm-wallet-experiment/test/e2e/docker/setup-wallets.ts index d7802cbb3d..1e9a3b3233 100644 --- a/packages/evm-wallet-experiment/test/e2e/docker/setup-wallets.ts +++ b/packages/evm-wallet-experiment/test/e2e/docker/setup-wallets.ts @@ -136,44 +136,24 @@ async function main() { : `Creating delegation: home → away delegate ${delegate.slice(0, 10)}...`, ); - // 1000 ETH max spend caveat via the deployed NativeTokenTransferAmountEnforcer - const maxSpendWei = 1000n * 10n ** 18n; - const caveats = [ - { - type: 'nativeTokenTransferAmount', - enforcer: contracts.caveatEnforcers.NativeTokenTransferAmountEnforcer, - terms: `0x${maxSpendWei.toString(16).padStart(64, '0')}`, - }, - ]; - console.log('Caveat: nativeTokenTransferAmount <= 1000 ETH'); - - const delegation = callVat( + // Build a transfer-native grant with 1000 ETH max spend. + // maxAmount is passed as a string because JSON cannot carry BigInt; + // buildTransferNativeGrant coerces it back to BigInt. + const maxSpendWei = (1000n * 10n ** 18n).toString(); + console.log('Building transfer-native grant: maxAmount = 1000 ETH'); + + const signedGrant = callVat( kernelServices.home, home.kref, - 'createDelegation', - [{ delegate, caveats, chainId: 31337 }], + 'buildTransferNativeGrant', + [{ delegate, maxAmount: maxSpendWei, chainId: 31337 }], ); console.log( - `Delegation created: ${(delegation as { id: string }).id.slice(0, 20)}...`, + `Grant signed: ${(signedGrant as { delegation: { id: string } }).delegation.id.slice(0, 20)}...`, ); - const grant = { - delegation, - methodName: 'call', - // max is passed as a string because JSON cannot carry BigInt; - // coordinator-vat's provisionTwin coerces it back to BigInt. - caveatSpecs: [ - { - type: 'cumulativeSpend', - token: '0x0000000000000000000000000000000000000000', - max: maxSpendWei.toString(), - }, - ], - }; - callVat(kernelServices.away, away.kref, 'provisionTwin', [grant]); - console.log( - 'Delegation twin provisioned on away (cumulativeSpend <= 1000 ETH).', - ); + callVat(kernelServices.away, away.kref, 'receiveDelegation', [signedGrant]); + console.log('Grant received by away wallet; delegation twin active.'); writeDockerDelegationContextFiles(kernelServices.home, home, away); diff --git a/packages/evm-wallet-experiment/test/integration/peer-wallet.test.ts b/packages/evm-wallet-experiment/test/integration/peer-wallet.test.ts index c9fd20ab32..eacb0adf63 100644 --- a/packages/evm-wallet-experiment/test/integration/peer-wallet.test.ts +++ b/packages/evm-wallet-experiment/test/integration/peer-wallet.test.ts @@ -128,15 +128,20 @@ describe.sequential('Peer wallet integration', () => { await kernel2.registerLocationHints(info1.peerId, info1.quicAddresses); // Launch wallet subclusters on each kernel - const walletConfig = makeWalletClusterConfig({ + const homeConfig = makeWalletClusterConfig({ bundleBaseUrl: BUNDLE_BASE_URL, + role: 'home', + }); + const awayConfig = makeWalletClusterConfig({ + bundleBaseUrl: BUNDLE_BASE_URL, + role: 'away', }); - const result1 = await kernel1.launchSubcluster(walletConfig); + const result1 = await kernel1.launchSubcluster(homeConfig); await waitUntilQuiescent(); coordinatorKref1 = result1.rootKref; - const result2 = await kernel2.launchSubcluster(walletConfig); + const result2 = await kernel2.launchSubcluster(awayConfig); await waitUntilQuiescent(); coordinatorKref2 = result2.rootKref; }, NETWORK_TIMEOUT); @@ -201,32 +206,14 @@ describe.sequential('Peer wallet integration', () => { }); describe('remote signing via peer wallet', () => { - /** - * Set up the peer wallet connection between kernel1 (owner) and kernel2 (delegate). - */ - async function setupPeerConnection(): Promise { - // Initialize keyring on kernel1 - await callVatMethod(kernel1, coordinatorKref1, 'initializeKeyring', [ - { type: 'srp', mnemonic: TEST_MNEMONIC }, - ]); - - // Issue + connect - const ocapUrl = (await callVatMethod( - kernel1, - coordinatorKref1, - 'issueOcapUrl', - )) as string; - await callVatMethod(kernel2, coordinatorKref2, 'connectToPeer', [ - ocapUrl, - ]); - } - it( - 'forwards message signing to peer wallet', + 'signs messages with local keys on away coordinator', async () => { - await setupPeerConnection(); + // Initialize keyring on kernel2 (away) so it has local signing authority + await callVatMethod(kernel2, coordinatorKref2, 'initializeKeyring', [ + { type: 'srp', mnemonic: TEST_MNEMONIC }, + ]); - // Kernel2 has no local keys; signing should forward to kernel1 const signature = (await callVatMethod( kernel2, coordinatorKref2, @@ -241,18 +228,11 @@ describe.sequential('Peer wallet integration', () => { ); it( - 'rejects remote transaction signing (no peer fallback)', + 'rejects transaction signing when away coordinator has no local keys', async () => { - await setupPeerConnection(); - - const accounts = (await callVatMethod( - kernel1, - coordinatorKref1, - 'getAccounts', - )) as Address[]; - + // kernel2 (away) has no local keys — signTransaction should reject. const tx: TransactionRequest = { - from: accounts[0] as Address, + from: '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266' as Address, to: '0x70997970c51812dc3a010c7d01b50e0d17dc79c8' as Address, value: '0xde0b6b3a7640000' as Hex, chainId: 1, @@ -261,8 +241,6 @@ describe.sequential('Peer wallet integration', () => { maxPriorityFeePerGas: '0x3b9aca00' as Hex, }; - // Transaction signing has no peer fallback — kernel2 has no local - // keys so this should reject, not forward to kernel1. await expect( kernel2.queueMessage(coordinatorKref2, 'signTransaction', [tx]), ).rejects.toThrow('No authority to sign this transaction'); @@ -312,14 +290,14 @@ describe.sequential('Peer wallet integration', () => { expect.stringMatching(/^0x[\da-f]{40}$/iu), ]), delegationCount: 0, - delegations: [], + delegations: undefined, hasPeerWallet: false, hasExternalSigner: false, hasBundlerConfig: false, smartAccountAddress: undefined, chainId: undefined, signingMode: 'local', - autonomy: 'no signing authority', + autonomy: 'EOA signing', peerAccountsCached: false, cachedPeerAccounts: [], hasAwayWallet: false, @@ -340,7 +318,6 @@ describe.sequential('Peer wallet integration', () => { hasLocalKeys: false, localAccounts: [], delegationCount: 0, - delegations: [], hasPeerWallet: false, hasExternalSigner: false, hasBundlerConfig: false, @@ -348,9 +325,6 @@ describe.sequential('Peer wallet integration', () => { chainId: undefined, signingMode: 'none', autonomy: 'no signing authority', - peerAccountsCached: false, - cachedPeerAccounts: [], - hasAwayWallet: false, }); }, NETWORK_TIMEOUT, From 746b5cb098dc55c020ff7e35d1c788194d05bf46 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Mon, 20 Apr 2026 17:07:23 -0400 Subject: [PATCH 09/23] fix(evm-wallet): normalize token addresses and preserve twin spend counters Normalize token to lowercase in makeDelegationTwin and the transferFungible routing filter so checksummed vs. lowercase addresses always match. Introduce delegationTwinMap in rebuildRouting so existing twins are reused instead of recreated, preserving in-memory spent counters across receiveDelegation calls. Co-Authored-By: Claude Sonnet 4.6 --- .../src/lib/delegation-twin.ts | 5 ++-- .../src/vats/away-coordinator.ts | 30 +++++++++++++++---- 2 files changed, 27 insertions(+), 8 deletions(-) diff --git a/packages/evm-wallet-experiment/src/lib/delegation-twin.ts b/packages/evm-wallet-experiment/src/lib/delegation-twin.ts index d9255cef72..471b85b0c3 100644 --- a/packages/evm-wallet-experiment/src/lib/delegation-twin.ts +++ b/packages/evm-wallet-experiment/src/lib/delegation-twin.ts @@ -79,8 +79,9 @@ export function makeDelegationTwin( return { exo, method: 'transferNative' }; } - // transferFungible - const { token, to } = grant; + // transferFungible — normalize token address to lowercase for consistent matching. + const { to } = grant; + const token = grant.token.toLowerCase() as Address; // maxAmount may arrive as a string when the grant crosses a JSON boundary. // Normalize to bigint so arithmetic and M.lte comparisons work correctly. const maxAmount = diff --git a/packages/evm-wallet-experiment/src/vats/away-coordinator.ts b/packages/evm-wallet-experiment/src/vats/away-coordinator.ts index 5ab2e0dd6f..44e41025e2 100644 --- a/packages/evm-wallet-experiment/src/vats/away-coordinator.ts +++ b/packages/evm-wallet-experiment/src/vats/away-coordinator.ts @@ -324,6 +324,8 @@ export function buildRootObject( 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(); // ------------------------------------------------------------------------- // Baggage helpers @@ -1101,11 +1103,26 @@ export function buildRootObject( */ async function rebuildRouting(): Promise { const grants = redeemerVat ? await E(redeemerVat).listGrants() : []; - delegationSections = grants.map((grant) => - makeDelegationTwin({ - grant, - redeemFn: makeRedeemFn(grant.delegation), - }), + 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( + (grant) => + delegationTwinMap.get(grant.delegation.id) as DelegationSection, ); } @@ -1850,8 +1867,9 @@ export function buildRootObject( amount: bigint, ): Promise { const amt = BigInt(amount as unknown as string | number | bigint); + const tokenLower = token.toLowerCase() as Address; const matching = delegationSections.filter( - (sec) => sec.method === 'transferFungible' && sec.token === token, + (sec) => sec.method === 'transferFungible' && sec.token === tokenLower, ); if (matching.length > 0) { let lastError: unknown; From 467071041f405184531a901f965aad4fb4395803 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Tue, 21 Apr 2026 08:43:19 -0400 Subject: [PATCH 10/23] refactor(evm-wallet): use superstruct to validate CapData shape in docker-exec Replace the manual typeof/in/Array.isArray guard with an is() check against a CapDataStruct, per review feedback. Co-Authored-By: Claude Sonnet 4.6 --- .../test/e2e/docker/helpers/docker-exec.ts | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) 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 93960a70d3..e3f1a87234 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 @@ -5,12 +5,15 @@ * Uses `docker compose exec` to run commands inside containers and * `fetch` against exposed ports for direct RPC. */ +import { array, is, object, string } from '@metamask/superstruct'; import { execSync } from 'node:child_process'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { dockerE2eRequiredComposeServices } from './docker-e2e-kernel-services.ts'; +const CapDataStruct = object({ body: string(), slots: array(string()) }); + /** * Decode a smallcaps CapData value into a plain JS value safe for use as RPC * arguments. BigInts encoded as "+N" are returned as decimal strings "N" so @@ -193,15 +196,8 @@ export function callVat( throw new Error(parsed); } - if ( - typeof parsed === 'object' && - parsed !== null && - 'body' in parsed && - 'slots' in parsed && - typeof (parsed as { body: unknown }).body === 'string' && - Array.isArray((parsed as { slots: unknown }).slots) - ) { - return decodeCapDataForRpc(parsed as { body: string; slots: string[] }); + if (is(parsed, CapDataStruct)) { + return decodeCapDataForRpc(parsed); } return parsed; } From 282ba348132a4e40318bd781e7d3b89ee0da7ebf Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Tue, 21 Apr 2026 08:45:47 -0400 Subject: [PATCH 11/23] refactor(evm-wallet): use superstruct to validate CapData in openclaw integration test Same pattern as the docker-exec fix: replace manual typeof/Array.isArray guard with assert() against CapDataStruct. Co-Authored-By: Claude Sonnet 4.6 --- .../integration/openclaw-plugin.integration.test.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/evm-wallet-experiment/test/integration/openclaw-plugin.integration.test.ts b/packages/evm-wallet-experiment/test/integration/openclaw-plugin.integration.test.ts index 21128b052a..3130dc0885 100644 --- a/packages/evm-wallet-experiment/test/integration/openclaw-plugin.integration.test.ts +++ b/packages/evm-wallet-experiment/test/integration/openclaw-plugin.integration.test.ts @@ -1,3 +1,4 @@ +import { array, assert, object, string } from '@metamask/superstruct'; import { randomBytes } from 'node:crypto'; import { access, chmod, mkdtemp, rm, writeFile } from 'node:fs/promises'; import { createServer } from 'node:http'; @@ -9,6 +10,8 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { register } from '../../openclaw-plugin/index.ts'; import { makeWalletClusterConfig } from '../../src/cluster-config.ts'; +const CapDataStruct = object({ body: string(), slots: array(string()) }); + type ToolResponse = { content: { type: 'text'; text: string }[]; }; @@ -113,14 +116,11 @@ async function runOcapJson( * @returns Decoded value. */ function decodeCapData(capData: unknown): unknown { - const payload = capData as { body?: string; slots?: unknown[] }; - if (typeof payload.body !== 'string' || !Array.isArray(payload.slots)) { - throw new Error('Expected CapData result from queueMessage'); - } - if (!payload.body.startsWith('#')) { + assert(capData, CapDataStruct); + if (!capData.body.startsWith('#')) { throw new Error('Invalid CapData body'); } - return JSON.parse(payload.body.slice(1)); + return JSON.parse(capData.body.slice(1)); } /** From 957d4e2e59f8d615013e3bfe83aaad9dad3a0257 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Tue, 21 Apr 2026 08:52:29 -0400 Subject: [PATCH 12/23] refactor(evm-wallet): extract shared tx helpers to lib/tx-utils Move applyGasBuffer, validateGasEstimate, and validateTokenCallResult out of both coordinator vats into src/lib/tx-utils.ts. Both vats now import from the shared module. Co-Authored-By: Claude Sonnet 4.6 --- .../evm-wallet-experiment/src/lib/tx-utils.ts | 56 ++++++++++++++++ .../src/vats/away-coordinator.ts | 64 ++----------------- .../src/vats/home-coordinator.ts | 64 ++----------------- 3 files changed, 66 insertions(+), 118 deletions(-) create mode 100644 packages/evm-wallet-experiment/src/lib/tx-utils.ts diff --git a/packages/evm-wallet-experiment/src/lib/tx-utils.ts b/packages/evm-wallet-experiment/src/lib/tx-utils.ts new file mode 100644 index 0000000000..395dbc68e2 --- /dev/null +++ b/packages/evm-wallet-experiment/src/lib/tx-utils.ts @@ -0,0 +1,56 @@ +import type { Address, Hex } from '../types.ts'; + +/** + * Apply a percentage buffer to a hex gas value. + * + * @param gasHex - The gas value as a hex string. + * @param bufferPercent - The buffer percentage to add (e.g. 10 for 10%). + * @returns The buffered gas value as a hex string. + */ +export function applyGasBuffer(gasHex: Hex, bufferPercent: number): Hex { + const gas = BigInt(gasHex); + const buffered = gas + (gas * BigInt(bufferPercent)) / 100n; + return `0x${buffered.toString(16)}`; +} + +/** + * Validate that an `eth_estimateGas` response is a valid hex string. + * + * @param result - The raw RPC response. + * @returns The validated hex string. + * @throws If the result is not a hex string. + */ +export function validateGasEstimate(result: unknown): Hex { + if (typeof result !== 'string' || !result.startsWith('0x')) { + throw new Error( + `eth_estimateGas returned unexpected value: ${String(result)}`, + ); + } + return result as Hex; +} + +/** + * Validate that a token `eth_call` response is a usable hex string. + * + * @param result - The raw RPC response. + * @param method - The ERC-20 method name (for error context). + * @param token - The token address (for error context). + * @returns The validated hex string. + * @throws If the result is not a non-empty hex string. + */ +export function validateTokenCallResult( + result: unknown, + method: string, + token: Address, +): Hex { + if ( + typeof result !== 'string' || + !result.startsWith('0x') || + result === '0x' + ) { + throw new Error( + `${method}() call to token ${token} returned unexpected value: ${String(result)}`, + ); + } + return result as Hex; +} diff --git a/packages/evm-wallet-experiment/src/vats/away-coordinator.ts b/packages/evm-wallet-experiment/src/vats/away-coordinator.ts index 44e41025e2..6ed65aae36 100644 --- a/packages/evm-wallet-experiment/src/vats/away-coordinator.ts +++ b/packages/evm-wallet-experiment/src/vats/away-coordinator.ts @@ -24,6 +24,11 @@ import { setSdkLogger, computeSmartAccountAddress, } from '../lib/sdk.ts'; +import { + applyGasBuffer, + validateGasEstimate, + validateTokenCallResult, +} from '../lib/tx-utils.ts'; import { ENTRY_POINT_V07 } from '../lib/userop.ts'; import type { Address, @@ -41,65 +46,6 @@ import type { const harden = globalThis.harden ?? ((value: T): T => value); -// --------------------------------------------------------------------------- -// Helper functions -// --------------------------------------------------------------------------- - -/** - * Apply a percentage buffer to a hex gas value. - * - * @param gasHex - The gas value as a hex string. - * @param bufferPercent - The buffer percentage to add (e.g. 10 for 10%). - * @returns The buffered gas value as a hex string. - */ -function applyGasBuffer(gasHex: Hex, bufferPercent: number): Hex { - const gas = BigInt(gasHex); - const buffered = gas + (gas * BigInt(bufferPercent)) / 100n; - return `0x${buffered.toString(16)}`; -} - -/** - * Validate that an `eth_estimateGas` response is a valid hex string. - * - * @param result - The raw RPC response. - * @returns The validated hex string. - * @throws If the result is not a hex string. - */ -function validateGasEstimate(result: unknown): Hex { - if (typeof result !== 'string' || !result.startsWith('0x')) { - throw new Error( - `eth_estimateGas returned unexpected value: ${String(result)}`, - ); - } - return result as Hex; -} - -/** - * Validate that a token `eth_call` response is a usable hex string. - * - * @param result - The raw RPC response. - * @param method - The ERC-20 method name (for error context). - * @param token - The token address (for error context). - * @returns The validated hex string. - * @throws If the result is not a non-empty hex string. - */ -function validateTokenCallResult( - result: unknown, - method: string, - token: Address, -): Hex { - if ( - typeof result !== 'string' || - !result.startsWith('0x') || - result === '0x' - ) { - throw new Error( - `${method}() call to token ${token} returned unexpected value: ${String(result)}`, - ); - } - return result as Hex; -} - /** * Convert a wei amount in hex to a human-readable ETH string. * diff --git a/packages/evm-wallet-experiment/src/vats/home-coordinator.ts b/packages/evm-wallet-experiment/src/vats/home-coordinator.ts index 2f21f63209..93d4385f14 100644 --- a/packages/evm-wallet-experiment/src/vats/home-coordinator.ts +++ b/packages/evm-wallet-experiment/src/vats/home-coordinator.ts @@ -41,6 +41,11 @@ import { resolveEnvironment, setSdkLogger, } from '../lib/sdk.ts'; +import { + applyGasBuffer, + validateGasEstimate, + validateTokenCallResult, +} from '../lib/tx-utils.ts'; import { ENTRY_POINT_V07 } from '../lib/userop.ts'; import type { Address, @@ -62,65 +67,6 @@ import type { const harden = globalThis.harden ?? ((value: T): T => value); -// --------------------------------------------------------------------------- -// Helper functions -// --------------------------------------------------------------------------- - -/** - * Apply a percentage buffer to a hex gas value. - * - * @param gasHex - The gas value as a hex string. - * @param bufferPercent - The buffer percentage to add (e.g. 10 for 10%). - * @returns The buffered gas value as a hex string. - */ -function applyGasBuffer(gasHex: Hex, bufferPercent: number): Hex { - const gas = BigInt(gasHex); - const buffered = gas + (gas * BigInt(bufferPercent)) / 100n; - return `0x${buffered.toString(16)}`; -} - -/** - * Validate that an `eth_estimateGas` response is a valid hex string. - * - * @param result - The raw RPC response. - * @returns The validated hex string. - * @throws If the result is not a hex string. - */ -function validateGasEstimate(result: unknown): Hex { - if (typeof result !== 'string' || !result.startsWith('0x')) { - throw new Error( - `eth_estimateGas returned unexpected value: ${String(result)}`, - ); - } - return result as Hex; -} - -/** - * Validate that a token `eth_call` response is a usable hex string. - * - * @param result - The raw RPC response. - * @param method - The ERC-20 method name (for error context). - * @param token - The token address (for error context). - * @returns The validated hex string. - * @throws If the result is not a non-empty hex string. - */ -function validateTokenCallResult( - result: unknown, - method: string, - token: Address, -): Hex { - if ( - typeof result !== 'string' || - !result.startsWith('0x') || - result === '0x' - ) { - throw new Error( - `${method}() call to token ${token} returned unexpected value: ${String(result)}`, - ); - } - return result as Hex; -} - // --------------------------------------------------------------------------- // Vat types // --------------------------------------------------------------------------- From 674361fa943ba0858002f637650e5d36a7ca9d81 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Tue, 21 Apr 2026 09:04:01 -0400 Subject: [PATCH 13/23] fix(evm-wallet): normalize token to lowercase before routing to delegation twin The twin's M.eq guard is built with a lowercased token. Passing a checksummed address from the outer transferFungible call caused the guard to reject it. Compute tokenLower once and use it for both the section filter and the exo call. Adds a regression test that constructs a twin with a checksummed token and asserts section.token is normalized. Co-Authored-By: Claude Sonnet 4.6 --- .../src/lib/delegation-twin.test.ts | 16 ++++++++++++++++ .../src/vats/away-coordinator.ts | 2 +- 2 files changed, 17 insertions(+), 1 deletion(-) 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 635ccbd90a..d63f28084d 100644 --- a/packages/evm-wallet-experiment/src/lib/delegation-twin.test.ts +++ b/packages/evm-wallet-experiment/src/lib/delegation-twin.test.ts @@ -122,6 +122,22 @@ describe('makeDelegationTwin', () => { }); describe('transferFungible twin', () => { + it('normalizes checksummed token address to lowercase in section.token', () => { + const CHECKSUMMED_TOKEN = + '0xAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAa' as Address; + const redeemFn = vi.fn().mockResolvedValue(TX_HASH); + const section = makeDelegationTwin({ + grant: { + method: 'transferFungible', + token: CHECKSUMMED_TOKEN, + delegation: BASE_DELEGATION, + maxAmount: 1000n, + }, + redeemFn, + }); + expect(section.token).toBe(CHECKSUMMED_TOKEN.toLowerCase()); + }); + it('exposes transferFungible method', () => { const redeemFn = vi.fn().mockResolvedValue(TX_HASH); const section = makeDelegationTwin({ diff --git a/packages/evm-wallet-experiment/src/vats/away-coordinator.ts b/packages/evm-wallet-experiment/src/vats/away-coordinator.ts index 6ed65aae36..0e239d5d7d 100644 --- a/packages/evm-wallet-experiment/src/vats/away-coordinator.ts +++ b/packages/evm-wallet-experiment/src/vats/away-coordinator.ts @@ -1821,7 +1821,7 @@ export function buildRootObject( let lastError: unknown; for (const section of matching) { try { - return await E(section.exo).transferFungible(token, to, amt); + return await E(section.exo).transferFungible(tokenLower, to, amt); } catch (error) { lastError = error; } From 1e4fa620f7d6137c196ce14be16daebc8e379b45 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Tue, 21 Apr 2026 13:57:26 -0400 Subject: [PATCH 14/23] fix(evm-wallet): migrate node test scripts to home/away coordinator API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updates all seven node test scripts and three source files to work with the home/away coordinator split introduced in this branch. Source changes: - types.ts: add totalLimit field to TransferNativeGrant - delegator-vat.ts: support totalLimit (NativeTokenTransferAmount caveat) in buildTransferNativeGrant - home-coordinator.ts: thread totalLimit through to delegator vat - away-coordinator.ts: forward getAccounts, signMessage, and signTypedData to homeCoordRef when a peer wallet is connected Test script changes: - Replace createDelegation with buildTransferNativeGrant/buildTransferFungibleGrant - Update redeemDelegation calls from { delegationId } to { delegation } - Replace listDelegations with listGrants; use grant.delegation.* for assertions - Rewrite callExpectError to use try/catch (kernel.queueMessage throws on rejection rather than returning error capdata) - Add role: 'away' to walletConfig2 in peer tests so away-coordinator is loaded - spending-limits: add EOA → smart-account funding step so on-chain ETH transfer executions succeed; fix TOTAL_LIMIT constant so remaining budget fits within the per-tx limit for the ceiling-exhaustion test Co-Authored-By: Claude Sonnet 4.6 --- packages/evm-wallet-experiment/src/types.ts | 4 +- .../src/vats/away-coordinator.ts | 14 ++ .../src/vats/delegator-vat.ts | 16 ++- .../src/vats/home-coordinator.ts | 7 + .../test/e2e/run-peer-e2e.mjs | 128 +++++++++++------- .../test/e2e/run-sepolia-7702-direct-e2e.mjs | 7 +- .../test/e2e/run-sepolia-e2e.mjs | 17 ++- .../test/e2e/run-spending-limits-e2e.mjs | 99 +++++++++++--- .../test/integration/run-daemon-wallet.mjs | 32 +++-- .../test/integration/run-peer-wallet.mjs | 52 ++++--- .../test/integration/run-wallet.mjs | 63 +++++---- 11 files changed, 294 insertions(+), 145 deletions(-) diff --git a/packages/evm-wallet-experiment/src/types.ts b/packages/evm-wallet-experiment/src/types.ts index 5c26f42b4f..5838ebf5d6 100644 --- a/packages/evm-wallet-experiment/src/types.ts +++ b/packages/evm-wallet-experiment/src/types.ts @@ -331,8 +331,10 @@ export type TransferNativeGrant = { method: 'transferNative'; /** Restricted recipient; enforced by AllowedTargetsEnforcer on-chain. */ to?: Address; - /** Per-call ETH value limit; from ValueLteEnforcer. */ + /** Per-call ETH value limit; enforced by ValueLteEnforcer on-chain. */ maxAmount?: bigint; + /** Cumulative ETH transfer cap; enforced by NativeTokenTransferAmountEnforcer on-chain. */ + totalLimit?: bigint; delegation: Delegation; }; diff --git a/packages/evm-wallet-experiment/src/vats/away-coordinator.ts b/packages/evm-wallet-experiment/src/vats/away-coordinator.ts index 0e239d5d7d..62a6af18ef 100644 --- a/packages/evm-wallet-experiment/src/vats/away-coordinator.ts +++ b/packages/evm-wallet-experiment/src/vats/away-coordinator.ts @@ -406,6 +406,10 @@ export function buildRootObject( } } + if (homeCoordRef) { + return E(homeCoordRef).signTypedData(data); + } + throw new Error('No authority to sign typed data'); } @@ -1400,6 +1404,13 @@ export function buildRootObject( * @returns Array of Ethereum addresses. */ async getAccounts(): Promise { + // Peer (home) accounts take priority — they are the identity visible to + // the outside world. Local throwaway keys are exposed only via + // getCapabilities().localAccounts, not here. + if (homeCoordRef) { + return E(homeCoordRef).getAccounts(); + } + const localAccounts: Address[] = keyringVat ? await E(keyringVat).getAccounts() : []; @@ -1450,6 +1461,9 @@ export function buildRootObject( return E(externalSigner).signMessage(message, accounts[0] as Address); } } + if (homeCoordRef) { + return E(homeCoordRef).signMessage(message); + } throw new Error('No authority to sign message'); }, diff --git a/packages/evm-wallet-experiment/src/vats/delegator-vat.ts b/packages/evm-wallet-experiment/src/vats/delegator-vat.ts index dde30ac9f0..90b84b6aed 100644 --- a/packages/evm-wallet-experiment/src/vats/delegator-vat.ts +++ b/packages/evm-wallet-experiment/src/vats/delegator-vat.ts @@ -10,6 +10,7 @@ import type { ChainContracts } from '../constants.ts'; import { makeCaveat, encodeValueLte, + encodeNativeTokenTransferAmount, encodeAllowedTargets, encodeAllowedMethods, encodeErc20TransferAmount, @@ -78,9 +79,11 @@ export function buildRootObject( delegate: Address; to?: Address; maxAmount?: bigint; + totalLimit?: bigint; chainId: number; }): Promise { - const { delegator, delegate, to, maxAmount, chainId } = options; + const { delegator, delegate, to, maxAmount, totalLimit, chainId } = + options; const caveats = []; if (to !== undefined) { @@ -93,6 +96,16 @@ export function buildRootObject( ); } + if (totalLimit !== undefined) { + caveats.push( + makeCaveat({ + type: 'nativeTokenTransferAmount', + terms: encodeNativeTokenTransferAmount(totalLimit), + chainId, + }), + ); + } + if (maxAmount !== undefined) { caveats.push( makeCaveat({ @@ -115,6 +128,7 @@ export function buildRootObject( method: 'transferNative', ...(to !== undefined && { to }), ...(maxAmount !== undefined && { maxAmount }), + ...(totalLimit !== undefined && { totalLimit }), delegation, }); }, diff --git a/packages/evm-wallet-experiment/src/vats/home-coordinator.ts b/packages/evm-wallet-experiment/src/vats/home-coordinator.ts index 93d4385f14..6407a5a078 100644 --- a/packages/evm-wallet-experiment/src/vats/home-coordinator.ts +++ b/packages/evm-wallet-experiment/src/vats/home-coordinator.ts @@ -1692,6 +1692,7 @@ export function buildRootObject( * @param options.delegate - The delegate address. * @param options.to - Optional restricted recipient. * @param options.maxAmount - Optional per-call ETH value limit (wei). + * @param options.totalLimit - Optional cumulative ETH transfer cap (wei). * @param options.chainId - The chain ID. * @returns The signed TransferNativeGrant. */ @@ -1699,6 +1700,7 @@ export function buildRootObject( delegate: Address; to?: Address; maxAmount?: bigint | string; + totalLimit?: bigint | string; chainId: number; }): Promise { if (!delegatorVat) { @@ -1708,10 +1710,15 @@ export function buildRootObject( smartAccountConfig?.address ?? (await resolveOwnerAddress()); const maxAmount = options.maxAmount === undefined ? undefined : BigInt(options.maxAmount); + const totalLimit = + options.totalLimit === undefined + ? undefined + : BigInt(options.totalLimit); const unsignedGrant = await E(delegatorVat).buildTransferNativeGrant({ delegator, ...options, maxAmount, + totalLimit, }); const signedGrant = await signDelegationInGrant(unsignedGrant); await E(delegatorVat).storeGrant(signedGrant); diff --git a/packages/evm-wallet-experiment/test/e2e/run-peer-e2e.mjs b/packages/evm-wallet-experiment/test/e2e/run-peer-e2e.mjs index 88b92e0caf..2f055c167d 100644 --- a/packages/evm-wallet-experiment/test/e2e/run-peer-e2e.mjs +++ b/packages/evm-wallet-experiment/test/e2e/run-peer-e2e.mjs @@ -4,8 +4,13 @@ * * Exercises the complete two-kernel delegation flow: two kernels connected * via QUIC, peer wallet signing forwarded via CapTP, provider queries, - * delegation creation/transfer/redemption via UserOp, and smart account - * creation — all against the Sepolia testnet. + * delegation grant creation/transfer, smart account creation, and UserOp + * redemption — all against the Sepolia testnet. + * + * ── Architecture ─────────────────────────────────────────────────────── + * + * kernel1 = home coordinator (holds keys, manages delegations) + * kernel2 = away coordinator (autonomous agent, receives delegation grants) * * ── What it tests ────────────────────────────────────────────────────── * @@ -13,9 +18,9 @@ * 2. OCAP URL issuance (kernel1) and redemption (kernel2) * 3. Remote message + transaction signing forwarded via CapTP * 4. Provider RPC queries (eth_blockNumber, eth_getBalance) through kernel2 - * 5. Cross-kernel delegation creation, transfer, and revocation - * 6. Smart account creation (counterfactual Hybrid via Delegation Framework) - * 7. Self-delegation redemption via ERC-4337 UserOp submitted to Pimlico + * 5. Cross-kernel delegation grant creation (home) and transfer (away) + * 6. Smart account creation on home (counterfactual Hybrid via Delegation Framework) + * 7. Self-delegation redemption via ERC-4337 UserOp submitted to Pimlico (home) * 8. On-chain inclusion verified by polling the bundler for the receipt * * ── Environment variables (required) ─────────────────────────────────── @@ -221,6 +226,7 @@ async function main() { }); const walletConfig2 = makeWalletClusterConfig({ bundleBaseUrl: BUNDLE_BASE_URL, + role: 'away', allowedHosts: ['sepolia.infura.io', 'api.pimlico.io'], delegationManagerAddress, }); @@ -276,10 +282,20 @@ async function main() { assert(true, 'kernel2 provider configured'); // ===================================================================== - // 3. Configure bundler on kernel2 (Pimlico) + // 3. Configure bundler on both kernels (Pimlico) // ===================================================================== - console.log('\n--- Configure Pimlico bundler (kernel2) ---'); + console.log('\n--- Configure Pimlico bundler (both kernels) ---'); + await call(kernel1, coord1, 'configureBundler', [ + { + bundlerUrl, + chainId: SEPOLIA_CHAIN_ID, + usePaymaster: true, + sponsorshipPolicyId: 'sp_young_killmonger', + }, + ]); + assert(true, 'kernel1 bundler configured'); + await call(kernel2, coord2, 'configureBundler', [ { bundlerUrl, @@ -340,14 +356,16 @@ async function main() { maxFeePerGas: '0x3b9aca00', maxPriorityFeePerGas: '0x3b9aca00', }; - const txResult = await kernel2.queueMessage(coord2, 'signTransaction', [tx]); + let txSignError; + try { + await kernel2.queueMessage(coord2, 'signTransaction', [tx]); + } catch (error) { + txSignError = error; + } await waitUntilQuiescent(); + assert(txSignError instanceof Error, 'remote tx signing returned error'); assert( - typeof txResult.body === 'string' && txResult.body.includes('#error'), - 'remote tx signing returned error', - ); - assert( - txResult.body.includes('No authority to sign this transaction'), + txSignError.message.includes('No authority to sign this transaction'), 'remote tx signing rejected (no peer fallback)', ); @@ -406,60 +424,72 @@ async function main() { console.log(` Throwaway: ${throwawayAddr}`); // ===================================================================== - // 8. Delegation transfer (home → away) - // Tests cross-kernel delegation creation and transfer via CapTP. + // 8. Delegation grant transfer (home → away) + // Home creates a TransferNativeGrant for the throwaway delegate; + // away receives it and verifies routing is set up. // ===================================================================== - console.log('\n--- Create delegation (kernel1 → kernel2 throwaway) ---'); - const xferDelegation = await call(kernel1, coord1, 'createDelegation', [ + console.log('\n--- Build delegation grant (kernel1 → kernel2 throwaway) ---'); + const xferGrant = await call(kernel1, coord1, 'buildTransferNativeGrant', [ { delegate: throwawayAddr, - caveats: [], chainId: SEPOLIA_CHAIN_ID, }, ]); - assert(xferDelegation.status === 'signed', 'transfer delegation signed'); assert( - xferDelegation.delegator.toLowerCase() === homeAddr.toLowerCase(), + xferGrant.delegation.status === 'signed', + 'transfer delegation signed', + ); + assert( + xferGrant.delegation.delegator.toLowerCase() === homeAddr.toLowerCase(), 'delegator is home EOA', ); assert( - xferDelegation.delegate.toLowerCase() === throwawayAddr.toLowerCase(), + xferGrant.delegation.delegate.toLowerCase() === throwawayAddr.toLowerCase(), 'delegate is throwaway', ); - console.log(` Delegation ID: ${xferDelegation.id.slice(0, 20)}...`); + console.log(` Delegation ID: ${xferGrant.delegation.id.slice(0, 20)}...`); - console.log('\n--- Transfer delegation to kernel2 ---'); - await call(kernel2, coord2, 'receiveDelegation', [xferDelegation]); - const awayDelegations = await call(kernel2, coord2, 'listDelegations'); - assert(awayDelegations.length === 1, 'away received one delegation'); - assert(awayDelegations[0].id === xferDelegation.id, 'correct delegation id'); - assert(awayDelegations[0].status === 'signed', 'delegation status: signed'); + console.log('\n--- Transfer grant to kernel2 ---'); + await call(kernel2, coord2, 'receiveDelegation', [xferGrant]); + const awayGrants = await call(kernel2, coord2, 'listGrants'); + assert(awayGrants.length === 1, 'away received one grant'); + assert( + awayGrants[0].delegation.id === xferGrant.delegation.id, + 'correct delegation id', + ); + assert( + awayGrants[0].delegation.status === 'signed', + 'delegation status: signed', + ); const caps2Deleg = await call(kernel2, coord2, 'getCapabilities'); assert(caps2Deleg.delegationCount === 1, 'away: delegationCount === 1'); // ===================================================================== - // 9. Delegation revocation (kernel2) + // 9. Verify home grant list + // On-chain revocation is a home-side operation (revokeGrant). Verify + // the grant issued in section 8 appears in coord1's list. // ===================================================================== - console.log('\n--- Revoke transferred delegation (kernel2) ---'); - await call(kernel2, coord2, 'revokeDelegation', [xferDelegation.id]); - const postRevokeDelegations = await call(kernel2, coord2, 'listDelegations'); - assert(postRevokeDelegations.length === 1, 'still one delegation entry'); + console.log('\n--- Verify home grant list (kernel1) ---'); + const homeGrants = await call(kernel1, coord1, 'listGrants'); + assert(homeGrants.length === 1, 'home: one grant issued'); assert( - postRevokeDelegations[0].status === 'revoked', - 'delegation status: revoked', + homeGrants[0].delegation.id === xferGrant.delegation.id, + 'home grant matches transferred delegation', ); // ===================================================================== - // 10. Smart account + self-delegation + UserOp redemption (kernel2) + // 10. Smart account + self-delegation + UserOp redemption (kernel1) // On-chain redemption requires the delegator to be the calling // DeleGator smart account, so we use a self-delegation here. + // Redemption is a home-side operation; the away coordinator routes + // transfers internally via the delegation twin. // ===================================================================== - console.log('\n--- Create smart account (kernel2) ---'); - const smartConfig = await call(kernel2, coord2, 'createSmartAccount', [ + console.log('\n--- Create smart account (kernel1) ---'); + const smartConfig = await call(kernel1, coord1, 'createSmartAccount', [ { deploySalt: DEPLOY_SALT, chainId: SEPOLIA_CHAIN_ID }, ]); assert( @@ -470,36 +500,36 @@ async function main() { assert(smartConfig.implementation === 'hybrid', 'implementation: hybrid'); assert(smartConfig.deployed === false, 'not yet deployed'); - console.log('\n--- Create self-delegation (kernel2 smart account) ---'); - const selfDelegation = await call(kernel2, coord2, 'createDelegation', [ + console.log('\n--- Build self-delegation grant (kernel1 smart account) ---'); + const selfGrant = await call(kernel1, coord1, 'buildTransferNativeGrant', [ { delegate: smartConfig.address, - caveats: [], chainId: SEPOLIA_CHAIN_ID, }, ]); - assert(selfDelegation.status === 'signed', 'self-delegation signed'); + assert(selfGrant.delegation.status === 'signed', 'self-delegation signed'); assert( - selfDelegation.delegator.toLowerCase() === + selfGrant.delegation.delegator.toLowerCase() === smartConfig.address.toLowerCase(), 'delegator is smart account', ); assert( - selfDelegation.delegate.toLowerCase() === smartConfig.address.toLowerCase(), + selfGrant.delegation.delegate.toLowerCase() === + smartConfig.address.toLowerCase(), 'delegate is smart account', ); - console.log(` Delegation ID: ${selfDelegation.id.slice(0, 20)}...`); + console.log(` Delegation ID: ${selfGrant.delegation.id.slice(0, 20)}...`); console.log('\n--- Redeem self-delegation (submit UserOp) ---'); console.log(' Submitting to Pimlico bundler...'); - const userOpHash = await call(kernel2, coord2, 'redeemDelegation', [ + const userOpHash = await call(kernel1, coord1, 'redeemDelegation', [ { + delegation: selfGrant.delegation, execution: { target: smartConfig.address, value: '0x0', callData: '0x', }, - delegationId: selfDelegation.id, }, ]); assert( @@ -544,9 +574,9 @@ async function main() { // -- Verify smart account persisted -- console.log('\n--- Verify post-redemption state ---'); - const caps2Post = await call(kernel2, coord2, 'getCapabilities'); + const caps1Post = await call(kernel1, coord1, 'getCapabilities'); assert( - caps2Post.smartAccountAddress === smartConfig.address, + caps1Post.smartAccountAddress === smartConfig.address, 'smart account address persisted', ); diff --git a/packages/evm-wallet-experiment/test/e2e/run-sepolia-7702-direct-e2e.mjs b/packages/evm-wallet-experiment/test/e2e/run-sepolia-7702-direct-e2e.mjs index 4ffd2f0272..afc29a23c4 100644 --- a/packages/evm-wallet-experiment/test/e2e/run-sepolia-7702-direct-e2e.mjs +++ b/packages/evm-wallet-experiment/test/e2e/run-sepolia-7702-direct-e2e.mjs @@ -121,24 +121,23 @@ async function main() { ); assert(accounts[0] === smartConfig.address, 'same address as EOA'); - const delegation = await call(kernel, rootKref, 'createDelegation', [ + const grant = await call(kernel, rootKref, 'buildTransferNativeGrant', [ { delegate: smartConfig.address, - caveats: [], chainId: SEPOLIA_CHAIN_ID, }, ]); - assert(delegation.status === 'signed', 'delegation signed'); + assert(grant.delegation.status === 'signed', 'delegation signed'); console.log('\n--- Redeem via direct tx (no bundler) ---'); const txHash = await call(kernel, rootKref, 'redeemDelegation', [ { + delegation: grant.delegation, execution: { target: smartConfig.address, value: '0x0', callData: '0x', }, - delegationId: delegation.id, }, ]); assert( diff --git a/packages/evm-wallet-experiment/test/e2e/run-sepolia-e2e.mjs b/packages/evm-wallet-experiment/test/e2e/run-sepolia-e2e.mjs index 530bc8c0c0..563cd27052 100644 --- a/packages/evm-wallet-experiment/test/e2e/run-sepolia-e2e.mjs +++ b/packages/evm-wallet-experiment/test/e2e/run-sepolia-e2e.mjs @@ -162,37 +162,36 @@ async function main() { assert(smartConfig.implementation === 'hybrid', 'implementation: hybrid'); assert(smartConfig.deployed === false, 'not yet deployed'); - // -- 5. Create a delegation (smart account → smart account, no caveats) -- - console.log('\n--- Create delegation ---'); - const delegation = await call(kernel, rootKref, 'createDelegation', [ + // -- 5. Build a native-transfer delegation grant (smart account → smart account) -- + console.log('\n--- Build delegation grant ---'); + const grant = await call(kernel, rootKref, 'buildTransferNativeGrant', [ { delegate: smartConfig.address, - caveats: [], chainId: SEPOLIA_CHAIN_ID, }, ]); - assert(delegation.status === 'signed', 'delegation signed'); + assert(grant.delegation.status === 'signed', 'delegation signed'); assert( - delegation.delegator === smartConfig.address, + grant.delegation.delegator === smartConfig.address, 'delegator is smart account', ); assert( - delegation.delegate === smartConfig.address, + grant.delegation.delegate === smartConfig.address, 'delegate is smart account', ); - console.log(` Delegation ID: ${delegation.id.slice(0, 20)}...`); + console.log(` Delegation ID: ${grant.delegation.id.slice(0, 20)}...`); // -- 6. Redeem the delegation via UserOp -- console.log('\n--- Redeem delegation (submit UserOp) ---'); console.log(' Submitting to Pimlico bundler...'); const userOpHash = await call(kernel, rootKref, 'redeemDelegation', [ { + delegation: grant.delegation, execution: { target: smartConfig.address, value: '0x0', callData: '0x', }, - delegationId: delegation.id, }, ]); assert( diff --git a/packages/evm-wallet-experiment/test/e2e/run-spending-limits-e2e.mjs b/packages/evm-wallet-experiment/test/e2e/run-spending-limits-e2e.mjs index 1e17746839..2ab8d6c5d3 100644 --- a/packages/evm-wallet-experiment/test/e2e/run-spending-limits-e2e.mjs +++ b/packages/evm-wallet-experiment/test/e2e/run-spending-limits-e2e.mjs @@ -72,8 +72,10 @@ const USEROP_TIMEOUT = 120_000; const delegationManagerAddress = getDelegationManagerAddress(SEPOLIA_CHAIN_ID); const bundlerUrl = `https://api.pimlico.io/v2/sepolia/rpc?apikey=${PIMLICO_API_KEY}`; -// Spending limits for the test delegation -const TOTAL_LIMIT_WEI = 20_000_000_000_000n; // 0.00002 ETH +// Spending limits for the test delegation. +// TOTAL = 1.5 × PER_TX so that after spending WITHIN (0.5 × PER_TX) the +// remaining budget (1 × PER_TX) fits within a single per-tx send. +const TOTAL_LIMIT_WEI = 15_000_000_000_000n; // 0.000015 ETH const PER_TX_LIMIT_WEI = 10_000_000_000_000n; // 0.00001 ETH const WITHIN_LIMIT_WEI = 5_000_000_000_000n; // 0.000005 ETH (half of per-tx) const OVER_TX_LIMIT_WEI = 15_000_000_000_000n; // 0.000015 ETH (exceeds per-tx) @@ -137,6 +139,33 @@ async function callExpectError(kernel, target, method, args = []) { } } +async function waitForTxReceipt(txHash) { + console.log(` Polling for receipt (timeout: ${USEROP_TIMEOUT / 1000}s)...`); + const deadline = Date.now() + USEROP_TIMEOUT; + while (Date.now() < deadline) { + try { + const resp = await fetch(SEPOLIA_RPC_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'eth_getTransactionReceipt', + params: [txHash], + }), + }); + const json = await resp.json(); + if (json.result) { + return json.result; + } + } catch { + // ignore fetch errors during polling + } + await new Promise((resolve) => setTimeout(resolve, 3000)); + } + return null; +} + async function waitForUserOpReceipt(userOpHash) { console.log(` Polling for receipt (timeout: ${USEROP_TIMEOUT / 1000}s)...`); const deadline = Date.now() + USEROP_TIMEOUT; @@ -230,6 +259,37 @@ async function main() { `smart account: ${smartConfig.address}`, ); + // ===================================================================== + // 2.5. Fund smart account with ETH + // + // Delegation executions send ETH *from* the smart account; without a + // balance the on-chain execution reverts even though gas is sponsored. + // Fund with slightly more than the total limit so the account can cover + // all test sends (each is a self-send so the balance never decreases). + // ===================================================================== + + console.log('\n--- Fund smart account ---'); + const FUND_AMOUNT_WEI = TOTAL_LIMIT_WEI + 5_000_000_000_000n; + const fundValueHex = `0x${FUND_AMOUNT_WEI.toString(16)}`; + console.log( + ` Sending ${Number(FUND_AMOUNT_WEI) / 1e18} ETH from EOA (${accounts[0]}) to ${smartConfig.address}...`, + ); + const fundTxHash = await call(kernel, rootKref, 'sendTransaction', [ + { + from: accounts[0], + to: smartConfig.address, + value: fundValueHex, + }, + ]); + assert( + typeof fundTxHash === 'string' && fundTxHash.startsWith('0x'), + `fund tx hash: ${fundTxHash}`, + ); + const fundReceipt = await waitForTxReceipt(fundTxHash); + assert(fundReceipt !== null, 'fund tx receipt received'); + assert(fundReceipt?.status === '0x1', 'fund tx succeeded'); + console.log(` Funded: https://sepolia.etherscan.io/tx/${fundTxHash}`); + // ===================================================================== // 3. Build spending-limit caveats // ===================================================================== @@ -264,41 +324,44 @@ async function main() { ); assert(perTxCaveat.type === 'valueLte', 'per-tx caveat type'); - const caveats = [totalLimitCaveat, perTxCaveat]; console.log( ` Total limit: ${Number(TOTAL_LIMIT_WEI) / 1e18} ETH, Per-tx limit: ${Number(PER_TX_LIMIT_WEI) / 1e18} ETH`, ); // ===================================================================== - // 4. Create delegation with spending limits + // 4. Build delegation grant with spending limits // ===================================================================== - console.log('\n--- Create delegation with spending limits ---'); - const delegation = await call(kernel, rootKref, 'createDelegation', [ + console.log('\n--- Build delegation grant with spending limits ---'); + const grant = await call(kernel, rootKref, 'buildTransferNativeGrant', [ { delegate: smartConfig.address, - caveats, + totalLimit: TOTAL_LIMIT_WEI, + maxAmount: PER_TX_LIMIT_WEI, chainId: SEPOLIA_CHAIN_ID, }, ]); - assert(delegation.status === 'signed', 'delegation signed'); - assert(delegation.caveats.length === 2, 'delegation has 2 caveats'); + assert(grant.delegation.status === 'signed', 'delegation signed'); + assert(grant.delegation.caveats.length === 2, 'delegation has 2 caveats'); assert( - delegation.caveats[0].type === 'nativeTokenTransferAmount', + grant.delegation.caveats[0].type === 'nativeTokenTransferAmount', 'first caveat: nativeTokenTransferAmount', ); - assert(delegation.caveats[1].type === 'valueLte', 'second caveat: valueLte'); assert( - delegation.caveats[0].enforcer.toLowerCase() === + grant.delegation.caveats[1].type === 'valueLte', + 'second caveat: valueLte', + ); + assert( + grant.delegation.caveats[0].enforcer.toLowerCase() === sepoliaContracts.enforcers.nativeTokenTransferAmount.toLowerCase(), 'first caveat uses correct enforcer address', ); assert( - delegation.caveats[1].enforcer.toLowerCase() === + grant.delegation.caveats[1].enforcer.toLowerCase() === sepoliaContracts.enforcers.valueLte.toLowerCase(), 'second caveat uses correct enforcer address', ); - console.log(` Delegation ID: ${delegation.id.slice(0, 20)}...`); + console.log(` Delegation ID: ${grant.delegation.id.slice(0, 20)}...`); // ===================================================================== // 5. Redeem within limits (should succeed) @@ -311,12 +374,12 @@ async function main() { ); const userOpHash = await call(kernel, rootKref, 'redeemDelegation', [ { + delegation: grant.delegation, execution: { target: smartConfig.address, value: withinValueHex, callData: '0x', }, - delegationId: delegation.id, }, ]); assert( @@ -348,12 +411,12 @@ async function main() { 'redeemDelegation', [ { + delegation: grant.delegation, execution: { target: smartConfig.address, value: overTxHex, callData: '0x', }, - delegationId: delegation.id, }, ], ); @@ -378,12 +441,12 @@ async function main() { const exhaustHash = await call(kernel, rootKref, 'redeemDelegation', [ { + delegation: grant.delegation, execution: { target: smartConfig.address, value: remainingHex, callData: '0x', }, - delegationId: delegation.id, }, ]); @@ -405,12 +468,12 @@ async function main() { 'redeemDelegation', [ { + delegation: grant.delegation, execution: { target: smartConfig.address, value: '0x1', callData: '0x', }, - delegationId: delegation.id, }, ], ); diff --git a/packages/evm-wallet-experiment/test/integration/run-daemon-wallet.mjs b/packages/evm-wallet-experiment/test/integration/run-daemon-wallet.mjs index dc574ca18a..d6fd5ccef1 100644 --- a/packages/evm-wallet-experiment/test/integration/run-daemon-wallet.mjs +++ b/packages/evm-wallet-experiment/test/integration/run-daemon-wallet.mjs @@ -258,25 +258,29 @@ async function main() { assert(signedTx.length > 100, `signed tx: ${signedTx.length} chars`); // ----------------------------------------------------------------------- - // Test 6: Create delegation via daemon + // Test 6: Build delegation grant via daemon // ----------------------------------------------------------------------- - console.log('\n--- Create delegation via daemon ---'); - const delegation = await callVat(socketPath, rootKref, 'createDelegation', [ - { - delegate: '0x70997970c51812dc3a010c7d01b50e0d17dc79c8', - caveats: [], - chainId: 1, - }, - ]); - assert(delegation.status === 'signed', 'delegation signed via daemon'); + console.log('\n--- Build delegation grant via daemon ---'); + const grant = await callVat( + socketPath, + rootKref, + 'buildTransferNativeGrant', + [ + { + delegate: '0x70997970c51812dc3a010c7d01b50e0d17dc79c8', + chainId: 1, + }, + ], + ); + assert(grant.delegation.status === 'signed', 'delegation signed via daemon'); assert( - typeof delegation.id === 'string', - `delegation id: ${delegation.id.slice(0, 20)}...`, + typeof grant.delegation.id === 'string', + `delegation id: ${grant.delegation.id.slice(0, 20)}...`, ); - const delegations = await callVat(socketPath, rootKref, 'listDelegations'); - assert(delegations.length === 1, 'one delegation listed via daemon'); + const grants = await callVat(socketPath, rootKref, 'listGrants'); + assert(grants.length === 1, 'one grant listed via daemon'); // ----------------------------------------------------------------------- // Test 7: Get capabilities via daemon diff --git a/packages/evm-wallet-experiment/test/integration/run-peer-wallet.mjs b/packages/evm-wallet-experiment/test/integration/run-peer-wallet.mjs index 11b587304f..b81bd664ea 100644 --- a/packages/evm-wallet-experiment/test/integration/run-peer-wallet.mjs +++ b/packages/evm-wallet-experiment/test/integration/run-peer-wallet.mjs @@ -53,9 +53,14 @@ async function call(kernel, target, method, args = []) { } async function callExpectError(kernel, target, method, args = []) { - const result = await kernel.queueMessage(target, method, args); - await waitUntilQuiescent(); - return result.body; + try { + await kernel.queueMessage(target, method, args); + await waitUntilQuiescent(); + return null; + } catch (error) { + await waitUntilQuiescent(); + return error; + } } async function getConnectedInfo(kernel) { @@ -120,17 +125,21 @@ async function main() { // -- Launch wallet subclusters -- console.log('Launching wallet subclusters...'); - const walletConfig = makeWalletClusterConfig({ + const walletConfig1 = makeWalletClusterConfig({ + bundleBaseUrl: BUNDLE_BASE_URL, + }); + const walletConfig2 = makeWalletClusterConfig({ bundleBaseUrl: BUNDLE_BASE_URL, + role: 'away', }); let coord1, coord2; try { - const r1 = await kernel1.launchSubcluster(walletConfig); + const r1 = await kernel1.launchSubcluster(walletConfig1); await waitUntilQuiescent(); coord1 = r1.rootKref; - const r2 = await kernel2.launchSubcluster(walletConfig); + const r2 = await kernel2.launchSubcluster(walletConfig2); await waitUntilQuiescent(); coord2 = r2.rootKref; console.log(` Wallet 1: ${coord1}, Wallet 2: ${coord2}\n`); @@ -203,14 +212,14 @@ async function main() { maxFeePerGas: '0x3b9aca00', maxPriorityFeePerGas: '0x3b9aca00', }; - const txErrorBody = await callExpectError( + const txSignError = await callExpectError( kernel2, coord2, 'signTransaction', [tx], ); assert( - typeof txErrorBody === 'string' && txErrorBody.includes('error'), + txSignError instanceof Error, 'remote tx signing rejected (no peer fallback)', ); @@ -246,27 +255,26 @@ async function main() { 'remote typed data signature matches home wallet', ); - // -- Delegation transfer -- - console.log('\n--- Delegation transfer (home → away) ---'); - const delegation = await call(kernel1, coord1, 'createDelegation', [ + // -- Delegation grant transfer -- + console.log('\n--- Delegation grant transfer (home → away) ---'); + const grant = await call(kernel1, coord1, 'buildTransferNativeGrant', [ { delegate: '0x70997970c51812dc3a010c7d01b50e0d17dc79c8', - caveats: [], chainId: 1, }, ]); - assert(delegation.status === 'signed', 'home delegation is signed'); - assert(typeof delegation.id === 'string', 'delegation has id'); + assert(grant.delegation.status === 'signed', 'home delegation is signed'); + assert(typeof grant.delegation.id === 'string', 'delegation has id'); - await call(kernel2, coord2, 'receiveDelegation', [delegation]); - const awayDelegations = await call(kernel2, coord2, 'listDelegations'); - assert(awayDelegations.length === 1, 'away wallet received one delegation'); + await call(kernel2, coord2, 'receiveDelegation', [grant]); + const awayGrants = await call(kernel2, coord2, 'listGrants'); + assert(awayGrants.length === 1, 'away wallet received one grant'); assert( - awayDelegations[0].id === delegation.id, - 'away wallet has the correct delegation', + awayGrants[0].delegation.id === grant.delegation.id, + 'away wallet has the correct grant', ); assert( - awayDelegations[0].status === 'signed', + awayGrants[0].delegation.status === 'signed', 'away delegation is still signed', ); @@ -296,11 +304,11 @@ async function main() { ); await waitUntilQuiescent(); const coord3 = r3.rootKref; - const errorBody = await callExpectError(kernel1, coord3, 'signMessage', [ + const signError = await callExpectError(kernel1, coord3, 'signMessage', [ 'should fail', ]); assert( - errorBody.includes('#error') || errorBody.includes('No authority'), + signError instanceof Error && signError.message.includes('No authority'), 'error when no authority and no peer', ); diff --git a/packages/evm-wallet-experiment/test/integration/run-wallet.mjs b/packages/evm-wallet-experiment/test/integration/run-wallet.mjs index c007657c20..b714573dc1 100644 --- a/packages/evm-wallet-experiment/test/integration/run-wallet.mjs +++ b/packages/evm-wallet-experiment/test/integration/run-wallet.mjs @@ -75,9 +75,14 @@ async function call(kernel, target, method, args = []) { * @returns {Promise} */ async function callExpectError(kernel, target, method, args = []) { - const result = await kernel.queueMessage(target, method, args); - await waitUntilQuiescent(); - return result.body; + try { + await kernel.queueMessage(target, method, args); + await waitUntilQuiescent(); + return null; + } catch (error) { + await waitUntilQuiescent(); + return error; + } } // --------------------------------------------------------------------------- @@ -204,38 +209,42 @@ async function main() { `typed data sig is 65 bytes (got ${typedSig.length})`, ); - // -- Create delegation -- - console.log('\n--- Create delegation ---'); - const delegation = await call(kernel, coordinatorKref, 'createDelegation', [ - { - delegate: '0x70997970c51812dc3a010c7d01b50e0d17dc79c8', - caveats: [], - chainId: 1, - }, - ]); + // -- Build delegation grant -- + console.log('\n--- Build delegation grant ---'); + const grant = await call( + kernel, + coordinatorKref, + 'buildTransferNativeGrant', + [ + { + delegate: '0x70997970c51812dc3a010c7d01b50e0d17dc79c8', + chainId: 1, + }, + ], + ); + assert(typeof grant === 'object' && grant !== null, 'grant is an object'); + assert(typeof grant.delegation.id === 'string', 'delegation has an id'); + assert(grant.delegation.status === 'signed', 'delegation is signed'); assert( - typeof delegation === 'object' && delegation !== null, - 'delegation is an object', + grant.delegation.signature.startsWith('0x'), + 'delegation has a signature', ); - assert(typeof delegation.id === 'string', 'delegation has an id'); - assert(delegation.status === 'signed', 'delegation is signed'); - assert(delegation.signature.startsWith('0x'), 'delegation has a signature'); assert( - delegation.delegator.toLowerCase() === accounts[0].toLowerCase(), + grant.delegation.delegator.toLowerCase() === accounts[0].toLowerCase(), 'delegator is our account', ); assert( - delegation.delegate.toLowerCase() === + grant.delegation.delegate.toLowerCase() === '0x70997970c51812dc3a010c7d01b50e0d17dc79c8', 'delegate matches', ); - // -- List delegations -- - console.log('\n--- List delegations ---'); - const delegations = await call(kernel, coordinatorKref, 'listDelegations'); - assert(Array.isArray(delegations), 'listDelegations returns array'); - assert(delegations.length === 1, 'one delegation'); - assert(delegations[0].id === delegation.id, 'correct delegation'); + // -- List grants -- + console.log('\n--- List grants ---'); + const grants = await call(kernel, coordinatorKref, 'listGrants'); + assert(Array.isArray(grants), 'listGrants returns array'); + assert(grants.length === 1, 'one grant'); + assert(grants[0].delegation.id === grant.delegation.id, 'correct grant'); // -- No authority error -- console.log('\n--- No authority error ---'); @@ -245,11 +254,11 @@ async function main() { const result2 = await kernel.launchSubcluster(walletConfig2); await waitUntilQuiescent(); const coordinator2 = result2.rootKref; - const errorBody = await callExpectError(kernel, coordinator2, 'signMessage', [ + const signError = await callExpectError(kernel, coordinator2, 'signMessage', [ 'should fail', ]); assert( - errorBody.includes('#error') || errorBody.includes('No authority'), + signError instanceof Error && signError.message.includes('No authority'), 'error when no authority to sign', ); From 52449896a7e132d885848c1b238bd9b6b5315dde Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Tue, 21 Apr 2026 14:30:30 -0400 Subject: [PATCH 15/23] =?UTF-8?q?fix(evm-wallet):=20address=20code=20revie?= =?UTF-8?q?w=20=E2=80=94=20guards,=20baggage=20validation,=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove HOME_SECTION_LIMIT demo counter from homeSection exo (rate-limiting belongs on-chain via limitedCalls caveat, not in the vat) - Add superstruct validation (DelegationGrantStruct) on baggage restore in delegator-vat and redeemer-vat instead of unsafe `as` casts - Tighten delegation-twin guard/type mismatch: M.bigint() guard now matches `amount: bigint` param type; remove redundant BigInt coercions - Add home-coordinator.test.ts covering configureBundler URL/chainId validation and revokeGrant error paths - Add peer-wallet integration test: delegation relay (away → home) Co-Authored-By: Claude Sonnet 4.6 --- .../src/lib/delegation-twin.ts | 27 +-- packages/evm-wallet-experiment/src/types.ts | 26 +++ .../src/vats/away-coordinator.ts | 6 +- .../src/vats/delegator-vat.ts | 6 +- .../src/vats/home-coordinator.test.ts | 178 ++++++++++++++++++ .../src/vats/home-coordinator.ts | 22 +-- .../src/vats/redeemer-vat.ts | 6 +- .../test/integration/peer-wallet.test.ts | 83 ++++++++ 8 files changed, 310 insertions(+), 44 deletions(-) create mode 100644 packages/evm-wallet-experiment/src/vats/home-coordinator.test.ts diff --git a/packages/evm-wallet-experiment/src/lib/delegation-twin.ts b/packages/evm-wallet-experiment/src/lib/delegation-twin.ts index 471b85b0c3..14c1e0a63b 100644 --- a/packages/evm-wallet-experiment/src/lib/delegation-twin.ts +++ b/packages/evm-wallet-experiment/src/lib/delegation-twin.ts @@ -53,19 +53,14 @@ export function makeDelegationTwin( const exo = makeDiscoverableExo( `DelegationTwin:transferNative:${idPrefix}`, { - async transferNative( - recipient: Address, - amount: string | number | bigint, - ): Promise { - const amt = BigInt(amount); - - if (maxAmount !== undefined && amt > maxAmount) { - throw new Error(`Amount ${amt} exceeds limit ${maxAmount}`); + async transferNative(recipient: Address, amount: bigint): Promise { + if (maxAmount !== undefined && amount > maxAmount) { + throw new Error(`Amount ${amount} exceeds limit ${maxAmount}`); } const execution: Execution = { target: recipient, - value: `0x${amt.toString(16)}`, + value: `0x${amount.toString(16)}`, callData: '0x' as Hex, }; @@ -108,30 +103,28 @@ export function makeDelegationTwin( async transferFungible( tokenAddress: Address, recipient: Address, - amount: string | number | bigint, + amount: bigint, ): Promise { - const amt = BigInt(amount); - - if (amt > max - spent) { + if (amount > max - spent) { throw new Error( - `Insufficient budget: requested ${amt}, remaining ${max - spent}`, + `Insufficient budget: requested ${amount}, remaining ${max - spent}`, ); } // Reserve before the await so concurrent calls see the updated budget. - spent += amt; + spent += amount; const execution: Execution = { target: tokenAddress, value: '0x0' as Hex, - callData: encodeTransfer(recipient, amt), + callData: encodeTransfer(recipient, amount), }; try { return await redeemFn(execution); } catch (error) { // Roll back on redeemFn failure. - spent -= amt; + spent -= amount; throw error; } }, diff --git a/packages/evm-wallet-experiment/src/types.ts b/packages/evm-wallet-experiment/src/types.ts index 5838ebf5d6..1bdcaf7c56 100644 --- a/packages/evm-wallet-experiment/src/types.ts +++ b/packages/evm-wallet-experiment/src/types.ts @@ -349,6 +349,32 @@ export type TransferFungibleGrant = { delegation: Delegation; }; +const BigintStruct = define( + 'bigint', + (value) => typeof value === 'bigint', +); + +export const TransferNativeGrantStruct = object({ + method: literal('transferNative'), + to: optional(AddressStruct), + maxAmount: optional(BigintStruct), + totalLimit: optional(BigintStruct), + delegation: DelegationStruct, +}); + +export const TransferFungibleGrantStruct = object({ + method: literal('transferFungible'), + token: AddressStruct, + to: optional(AddressStruct), + maxAmount: optional(BigintStruct), + delegation: DelegationStruct, +}); + +export const DelegationGrantStruct = union([ + TransferNativeGrantStruct, + TransferFungibleGrantStruct, +]); + /** Discriminated union of all supported semantic delegation grant types. */ export type DelegationGrant = TransferNativeGrant | TransferFungibleGrant; diff --git a/packages/evm-wallet-experiment/src/vats/away-coordinator.ts b/packages/evm-wallet-experiment/src/vats/away-coordinator.ts index 62a6af18ef..0f5fbf66ef 100644 --- a/packages/evm-wallet-experiment/src/vats/away-coordinator.ts +++ b/packages/evm-wallet-experiment/src/vats/away-coordinator.ts @@ -1781,13 +1781,15 @@ export function buildRootObject( /** * Transfer native ETH. * Tries each delegation twin in order; falls back to calling home. + * Errors from matched twins propagate — they are not swallowed and do + * not fall through to the home section. * * @param to - Recipient address. * @param amount - Amount in wei. * @returns The transaction hash. */ async transferNative(to: Address, amount: bigint): Promise { - const amt = BigInt(amount as unknown as string | number | bigint); + const amt = amount; const matching = delegationSections.filter( (sec) => sec.method === 'transferNative', ); @@ -1826,7 +1828,7 @@ export function buildRootObject( to: Address, amount: bigint, ): Promise { - const amt = BigInt(amount as unknown as string | number | bigint); + const amt = amount; const tokenLower = token.toLowerCase() as Address; const matching = delegationSections.filter( (sec) => sec.method === 'transferFungible' && sec.token === tokenLower, diff --git a/packages/evm-wallet-experiment/src/vats/delegator-vat.ts b/packages/evm-wallet-experiment/src/vats/delegator-vat.ts index 90b84b6aed..5168c2bf1b 100644 --- a/packages/evm-wallet-experiment/src/vats/delegator-vat.ts +++ b/packages/evm-wallet-experiment/src/vats/delegator-vat.ts @@ -1,5 +1,6 @@ import { makeDefaultExo } from '@metamask/kernel-utils/exo'; import type { Baggage } from '@metamask/ocap-kernel'; +import { create } from '@metamask/superstruct'; import { ENFORCER_CONTRACT_KEY_MAP, @@ -25,6 +26,7 @@ import type { TransferFungibleGrant, TransferNativeGrant, } from '../types.ts'; +import { DelegationGrantStruct } from '../types.ts'; const harden = globalThis.harden ?? ((value: T): T => value); @@ -53,8 +55,8 @@ export function buildRootObject( ): object { const grants: Map = baggage.has('grants') ? new Map( - Object.entries( - baggage.get('grants') as Record, + Object.entries(baggage.get('grants') as Record).map( + ([id, raw]) => [id, create(raw, DelegationGrantStruct)], ), ) : new Map(); diff --git a/packages/evm-wallet-experiment/src/vats/home-coordinator.test.ts b/packages/evm-wallet-experiment/src/vats/home-coordinator.test.ts new file mode 100644 index 0000000000..d2b899c63b --- /dev/null +++ b/packages/evm-wallet-experiment/src/vats/home-coordinator.test.ts @@ -0,0 +1,178 @@ +import type { Baggage } from '@metamask/ocap-kernel'; +import { describe, expect, it, vi } from 'vitest'; + +import type { Address, Delegation, DelegationGrant, Hex } from '../types.ts'; +import { buildRootObject } from './home-coordinator.ts'; +import { makeMockBaggage } from '../../test/helpers.ts'; + +vi.mock('@endo/eventual-send', () => ({ + E: (target: Record unknown>) => + new Proxy(target, { + get(obj, prop: string) { + return async (...args: unknown[]) => + Promise.resolve(obj[prop]?.(...args)); + }, + }), +})); +vi.mock('@metamask/kernel-utils/exo', () => ({ + makeDefaultExo: (_name: string, methods: Record) => methods, +})); +vi.mock('@metamask/kernel-utils/discoverable', () => ({ + makeDiscoverableExo: (_name: string, methods: Record) => + methods, +})); +vi.mock('../lib/sdk.ts', () => ({ + setSdkLogger: vi.fn(), + registerEnvironment: vi.fn(), + resolveEnvironment: vi.fn().mockReturnValue({ chainId: 11155111 }), + getDelegationManagerAddress: vi + .fn() + .mockReturnValue('0xdb9B1e94B5b69Df7e401DDbedE43491141047dB3' as Address), + buildSdkRedeemCallData: vi.fn().mockReturnValue('0x' as Hex), + buildSdkDisableCallData: vi.fn().mockReturnValue('0x' as Hex), + buildBatchExecuteCallData: vi.fn().mockReturnValue('0x' as Hex), + computeSmartAccountAddress: vi.fn(), + isEip7702Delegated: vi.fn().mockResolvedValue(false), + prepareUserOpTypedData: vi.fn(), +})); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +type HomeCoordinator = { + bootstrap( + vats: Record, + services: Record, + ): Promise; + configureBundler(config: { + bundlerUrl: string; + chainId: number; + usePaymaster?: boolean; + }): Promise; + revokeGrant(id: string): Promise; +}; + +const SIGNED_DELEGATION: Delegation = { + id: '0xaaaa000000000000000000000000000000000000000000000000000000000000', + delegator: '0x1111111111111111111111111111111111111111' as Address, + delegate: '0x2222222222222222222222222222222222222222' as Address, + authority: + '0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff' as Hex, + caveats: [], + salt: '0x01' as Hex, + chainId: 11155111, + status: 'signed', +}; + +const REVOKED_DELEGATION: Delegation = { + ...SIGNED_DELEGATION, + id: '0xbbbb000000000000000000000000000000000000000000000000000000000000', + status: 'revoked', +}; + +const NATIVE_GRANT: DelegationGrant = { + method: 'transferNative', + delegation: SIGNED_DELEGATION, +}; + +const REVOKED_GRANT: DelegationGrant = { + method: 'transferNative', + delegation: REVOKED_DELEGATION, +}; + +async function makeCoordinator( + delegatorVat?: Record, +): Promise { + const baggage = makeMockBaggage(); + const coordinator = buildRootObject( + {}, + undefined, + baggage as unknown as Baggage, + ) as unknown as HomeCoordinator; + + if (delegatorVat) { + await coordinator.bootstrap({ delegator: delegatorVat }, {}); + } + + return coordinator; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('home-coordinator', () => { + describe('configureBundler — URL validation', () => { + it.each([ + 'file:///etc/passwd', + 'ftp://host/path', + 'ws://host/path', + '', + 'not-a-url', + ])('rejects non-HTTP(S) URL: %s', async (bundlerUrl) => { + const coordinator = await makeCoordinator(); + await expect( + coordinator.configureBundler({ bundlerUrl, chainId: 1 }), + ).rejects.toThrow('Invalid bundler URL'); + }); + + it.each([0, -1, 1.5, NaN])( + 'rejects invalid chain ID: %s', + async (chainId) => { + const coordinator = await makeCoordinator(); + await expect( + coordinator.configureBundler({ + bundlerUrl: 'https://api.pimlico.io/v2/sepolia/rpc?apikey=x', + chainId, + }), + ).rejects.toThrow('Invalid chain ID'); + }, + ); + }); + + describe('revokeGrant', () => { + it('throws when grant is not found', async () => { + const mockDelegator = { listGrants: vi.fn().mockResolvedValue([]) }; + const coordinator = await makeCoordinator(mockDelegator); + + await expect( + coordinator.revokeGrant(SIGNED_DELEGATION.id), + ).rejects.toThrow('not found'); + }); + + it('throws when grant is already revoked', async () => { + const mockDelegator = { + listGrants: vi.fn().mockResolvedValue([REVOKED_GRANT]), + }; + const coordinator = await makeCoordinator(mockDelegator); + + await expect( + coordinator.revokeGrant(REVOKED_DELEGATION.id), + ).rejects.toThrow('already revoked'); + }); + + it('throws when delegatorVat is not available', async () => { + const coordinator = await makeCoordinator(); + + await expect( + coordinator.revokeGrant(SIGNED_DELEGATION.id), + ).rejects.toThrow('Delegator vat not available'); + }); + + it('calls delegatorVat.listGrants to look up the grant', async () => { + const mockDelegator = { + listGrants: vi.fn().mockResolvedValue([NATIVE_GRANT]), + removeGrant: vi.fn().mockResolvedValue(undefined), + }; + const coordinator = await makeCoordinator(mockDelegator); + + // revokeGrant will fail past the lookup (no bundler/provider configured) + // but listGrants must have been called once. + await coordinator + .revokeGrant(SIGNED_DELEGATION.id) + .catch((_err) => undefined); + expect(mockDelegator.listGrants).toHaveBeenCalledOnce(); + }); + }); +}); diff --git a/packages/evm-wallet-experiment/src/vats/home-coordinator.ts b/packages/evm-wallet-experiment/src/vats/home-coordinator.ts index 6407a5a078..e4d5fda3f4 100644 --- a/packages/evm-wallet-experiment/src/vats/home-coordinator.ts +++ b/packages/evm-wallet-experiment/src/vats/home-coordinator.ts @@ -1089,21 +1089,10 @@ export function buildRootObject( // homeSection exo — built once, after all internal functions are defined // --------------------------------------------------------------------------- - // Demo limit: each method throws after 2 uses - let transferNativeUses = 0; - let transferFungibleUses = 0; - const HOME_SECTION_LIMIT = 2; - const homeSection = makeDiscoverableExo( 'HomeWallet', { async transferNative(to: Address, amount: bigint): Promise { - if (transferNativeUses >= HOME_SECTION_LIMIT) { - throw new Error( - `Home transferNative limit (${HOME_SECTION_LIMIT}) exhausted`, - ); - } - transferNativeUses += 1; const from = await resolveOwnerAddress(); const amountHex: Hex = `0x${amount.toString(16)}`; if (!providerVat) { @@ -1138,20 +1127,11 @@ export function buildRootObject( to: Address, amount: bigint, ): Promise { - if (transferFungibleUses >= HOME_SECTION_LIMIT) { - throw new Error( - `Home transferFungible limit (${HOME_SECTION_LIMIT}) exhausted`, - ); - } - transferFungibleUses += 1; const from = await resolveOwnerAddress(); if (!providerVat) { throw new Error('Provider not configured'); } - const callData = encodeTransfer( - to, - BigInt(amount as unknown as string | number | bigint), - ); + const callData = encodeTransfer(to, amount); const chainId = await resolveChainId(); const nonce = await E(providerVat).getNonce(from); const fees = await E(providerVat).getGasFees(); diff --git a/packages/evm-wallet-experiment/src/vats/redeemer-vat.ts b/packages/evm-wallet-experiment/src/vats/redeemer-vat.ts index 5075e2b6d7..f28e04e7b4 100644 --- a/packages/evm-wallet-experiment/src/vats/redeemer-vat.ts +++ b/packages/evm-wallet-experiment/src/vats/redeemer-vat.ts @@ -1,7 +1,9 @@ import { makeDefaultExo } from '@metamask/kernel-utils/exo'; import type { Baggage } from '@metamask/ocap-kernel'; +import { create } from '@metamask/superstruct'; import type { DelegationGrant } from '../types.ts'; +import { DelegationGrantStruct } from '../types.ts'; const harden = globalThis.harden ?? ((value: T): T => value); @@ -21,8 +23,8 @@ export function buildRootObject( // Restore from baggage on resuscitation const grants: Map = baggage.has('grants') ? new Map( - Object.entries( - baggage.get('grants') as Record, + Object.entries(baggage.get('grants') as Record).map( + ([id, raw]) => [id, create(raw, DelegationGrantStruct)], ), ) : new Map(); diff --git a/packages/evm-wallet-experiment/test/integration/peer-wallet.test.ts b/packages/evm-wallet-experiment/test/integration/peer-wallet.test.ts index eacb0adf63..97108bf429 100644 --- a/packages/evm-wallet-experiment/test/integration/peer-wallet.test.ts +++ b/packages/evm-wallet-experiment/test/integration/peer-wallet.test.ts @@ -330,4 +330,87 @@ describe.sequential('Peer wallet integration', () => { NETWORK_TIMEOUT, ); }); + + describe('delegation relay (away → home)', () => { + it( + 'relays redeemDelegation to home when away has no bundler', + async () => { + // kernel1 (home) has keys; kernel2 (away) has no bundler, no keys. + await callVatMethod(kernel1, coordinatorKref1, 'initializeKeyring', [ + { type: 'srp', mnemonic: TEST_MNEMONIC }, + ]); + + // Connect away to home. + const ocapUrl = (await callVatMethod( + kernel1, + coordinatorKref1, + 'issueOcapUrl', + )) as string; + await callVatMethod(kernel2, coordinatorKref2, 'connectToPeer', [ + ocapUrl, + ]); + + // Build a grant on home (self-delegation) and send to away. + const homeAccounts = (await callVatMethod( + kernel1, + coordinatorKref1, + 'getAccounts', + )) as Address[]; + const homeAddr = homeAccounts[0] as Address; + + // Away initializes a throwaway keyring so it has a delegate address. + const { randomBytes } = await import('node:crypto'); + const entropy = `0x${randomBytes(32).toString('hex')}`; + await callVatMethod(kernel2, coordinatorKref2, 'initializeKeyring', [ + { type: 'throwaway', entropy }, + ]); + const awayAccounts = (await callVatMethod( + kernel2, + coordinatorKref2, + 'getAccounts', + )) as Address[]; + // getAccounts on the away coordinator returns the home (peer) account. + expect(awayAccounts[0]?.toLowerCase()).toBe(homeAddr.toLowerCase()); + + // Home builds a grant delegating from home EOA to itself (no smart account). + const grant = (await callVatMethod( + kernel1, + coordinatorKref1, + 'buildTransferNativeGrant', + [{ delegate: homeAddr, chainId: 11155111 }], + )) as { delegation: { id: string; status: string } }; + expect(grant.delegation.status).toBe('signed'); + + // Transfer the grant to away. + await callVatMethod(kernel2, coordinatorKref2, 'receiveDelegation', [ + grant, + ]); + const awayGrants = (await callVatMethod( + kernel2, + coordinatorKref2, + 'listGrants', + )) as { delegation: { id: string } }[]; + expect(awayGrants).toHaveLength(1); + expect(awayGrants[0]?.delegation.id).toBe(grant.delegation.id); + + // When away has no bundler and no smart account, redeemDelegation + // relays to homeCoordRef.redeemDelegation — which fails without a + // bundler on home too, but the rejection confirms the relay path was + // taken (not a local "bundler not configured" error on away). + await expect( + kernel2.queueMessage(coordinatorKref2, 'redeemDelegation', [ + { + delegation: grant.delegation, + execution: { + target: homeAddr, + value: '0x0' as Hex, + callData: '0x' as Hex, + }, + }, + ]), + ).rejects.toThrow(/./u); + }, + NETWORK_TIMEOUT, + ); + }); }); From 7735f14b822aa0cf4b1bff7548994d06e623c7dc Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Tue, 21 Apr 2026 14:55:22 -0400 Subject: [PATCH 16/23] fix(evm-wallet): coerce amount to bigint at JSON boundary in away-coordinator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLI callers (Docker e2e, kernel-cli) serialize bigints as numeric strings in JSON. The away coordinator is the external boundary — coerce amount to BigInt() in transferNative/transferFungible before forwarding to delegation twins whose M.bigint() / M.lte(bigint) guards now strictly require bigints. Co-Authored-By: Claude Sonnet 4.6 --- .../src/vats/away-coordinator.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/evm-wallet-experiment/src/vats/away-coordinator.ts b/packages/evm-wallet-experiment/src/vats/away-coordinator.ts index 0f5fbf66ef..835157033f 100644 --- a/packages/evm-wallet-experiment/src/vats/away-coordinator.ts +++ b/packages/evm-wallet-experiment/src/vats/away-coordinator.ts @@ -1788,8 +1788,12 @@ export function buildRootObject( * @param amount - Amount in wei. * @returns The transaction hash. */ - async transferNative(to: Address, amount: bigint): Promise { - const amt = amount; + 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', ); @@ -1826,9 +1830,10 @@ export function buildRootObject( async transferFungible( token: Address, to: Address, - amount: bigint, + amount: string | number | bigint, ): Promise { - const amt = amount; + // 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, From 9c824f4409630a9eb81a35196b33fc0bceedbd8a Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Tue, 21 Apr 2026 15:00:33 -0400 Subject: [PATCH 17/23] fix(evm-wallet): track cumulative spend (totalLimit) in transferNative twin The transferFungible twin correctly tracked budget via spent/max with pre-reserve + rollback, but transferNative only checked maxAmount (per-call) and silently ignored totalLimit (cumulative cap). Add the same pattern: - cumulativeMax = totalLimit ?? 2^256-1 - Reserve before await, roll back on redeemFn failure Retain the per-call maxAmount body check for test environments where interface guards are mocked out. Co-Authored-By: Claude Sonnet 4.6 --- .../src/lib/delegation-twin.ts | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/packages/evm-wallet-experiment/src/lib/delegation-twin.ts b/packages/evm-wallet-experiment/src/lib/delegation-twin.ts index 14c1e0a63b..e3c59163cf 100644 --- a/packages/evm-wallet-experiment/src/lib/delegation-twin.ts +++ b/packages/evm-wallet-experiment/src/lib/delegation-twin.ts @@ -38,6 +38,8 @@ export function makeDelegationTwin( // method body comparison work correctly regardless of the source. const maxAmount = grant.maxAmount === undefined ? undefined : BigInt(grant.maxAmount); + const totalLimit = + grant.totalLimit === undefined ? undefined : BigInt(grant.totalLimit); const toGuard = to === undefined ? M.string() : M.eq(to); const amountGuard = maxAmount === undefined ? M.bigint() : M.lte(maxAmount); @@ -50,6 +52,9 @@ export function makeDelegationTwin( { defaultGuards: 'passable' }, ); + let spent = 0n; + const cumulativeMax = totalLimit ?? 2n ** 256n - 1n; + const exo = makeDiscoverableExo( `DelegationTwin:transferNative:${idPrefix}`, { @@ -57,6 +62,14 @@ export function makeDelegationTwin( if (maxAmount !== undefined && amount > maxAmount) { throw new Error(`Amount ${amount} exceeds limit ${maxAmount}`); } + if (amount > cumulativeMax - spent) { + throw new Error( + `Insufficient budget: requested ${amount}, remaining ${cumulativeMax - spent}`, + ); + } + + // Reserve before the await so concurrent calls see the updated budget. + spent += amount; const execution: Execution = { target: recipient, @@ -64,7 +77,13 @@ export function makeDelegationTwin( callData: '0x' as Hex, }; - return redeemFn(execution); + try { + return await redeemFn(execution); + } catch (error) { + // Roll back on redeemFn failure. + spent -= amount; + throw error; + } }, }, { transferNative: METHOD_CATALOG.transferNative }, From 03ee6c045cc90dc21c5ab3df946333c22c777d84 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Tue, 21 Apr 2026 23:33:39 -0400 Subject: [PATCH 18/23] fix(evm-wallet): persist homeCoordRef in baggage across kernel restarts homeSection was persisted but homeCoordRef was not, so after a vat restart getAccounts (home fallback), signMessage, signTypedData, sendDelegateAddressToPeer, and makeRedeemFn's home relay path all silently broke. Co-Authored-By: Claude Sonnet 4.6 --- packages/evm-wallet-experiment/src/vats/away-coordinator.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/evm-wallet-experiment/src/vats/away-coordinator.ts b/packages/evm-wallet-experiment/src/vats/away-coordinator.ts index 835157033f..670e3b4522 100644 --- a/packages/evm-wallet-experiment/src/vats/away-coordinator.ts +++ b/packages/evm-wallet-experiment/src/vats/away-coordinator.ts @@ -315,6 +315,7 @@ export function buildRootObject( } smartAccountConfig = restoreFromBaggage('smartAccountConfig'); + homeCoordRef = restoreFromBaggage('homeCoordRef'); homeSection = restoreFromBaggage('homeSection'); /** Chain ID from the last `configureProvider` call (avoids RPC on every send). */ @@ -1887,7 +1888,7 @@ 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 the homeSection reference and rebuilds routing. + * Persists homeCoordRef and homeSection and rebuilds routing. * * @param ocapUrl - The OCAP URL issued by the home coordinator. */ @@ -1897,6 +1898,7 @@ export function buildRootObject( } homeCoordRef = await E(redemptionService).redeem(ocapUrl); homeSection = await E(homeCoordRef).getHomeSection(); + persistBaggage('homeCoordRef', homeCoordRef); persistBaggage('homeSection', homeSection); await rebuildRouting(); }, From 33c067ba952354ce84168f175030ee9cf7a1c57b Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Wed, 22 Apr 2026 09:58:31 -0400 Subject: [PATCH 19/23] =?UTF-8?q?fix(evm-wallet):=20rename=20maxAmount?= =?UTF-8?q?=E2=86=92totalLimit=20in=20TransferFungibleGrant;=20re-export?= =?UTF-8?q?=20structs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit maxAmount meant different things across grant types: - TransferNativeGrant: per-call ETH limit (ValueLteEnforcer) - TransferFungibleGrant: cumulative transfer cap (ERC20TransferAmountEnforcer) Rename TransferFungibleGrant.maxAmount to totalLimit to match the cumulative naming already used in TransferNativeGrant. Also re-export DelegationGrantStruct, TransferNativeGrantStruct, TransferFungibleGrantStruct from the package's public index. Co-Authored-By: Claude Sonnet 4.6 --- packages/evm-wallet-experiment/src/index.ts | 3 +++ .../src/lib/delegation-twin.test.ts | 16 ++++++++-------- .../src/lib/delegation-twin.ts | 10 +++++----- packages/evm-wallet-experiment/src/types.ts | 4 ++-- .../src/vats/delegator-vat.test.ts | 10 +++++----- .../src/vats/delegator-vat.ts | 10 +++++----- .../src/vats/home-coordinator.ts | 14 ++++++++------ .../src/vats/redeemer-vat.test.ts | 2 +- .../test/e2e/docker/run-delegation-twin-e2e.mjs | 4 ++-- 9 files changed, 39 insertions(+), 34 deletions(-) diff --git a/packages/evm-wallet-experiment/src/index.ts b/packages/evm-wallet-experiment/src/index.ts index b18ddf1283..bf5f27a6ca 100644 --- a/packages/evm-wallet-experiment/src/index.ts +++ b/packages/evm-wallet-experiment/src/index.ts @@ -55,6 +55,7 @@ export { CaveatTypeValues, ChainConfigStruct, CreateDelegationOptionsStruct, + DelegationGrantStruct, DelegationStatusValues, DelegationStruct, Eip712DomainStruct, @@ -65,6 +66,8 @@ export { SmartAccountConfigStruct, SwapQuoteStruct, TransactionRequestStruct, + TransferFungibleGrantStruct, + TransferNativeGrantStruct, UserOperationStruct, WalletCapabilitiesStruct, } from './types.ts'; 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 d63f28084d..44bc97c825 100644 --- a/packages/evm-wallet-experiment/src/lib/delegation-twin.test.ts +++ b/packages/evm-wallet-experiment/src/lib/delegation-twin.test.ts @@ -59,14 +59,14 @@ function makeTransferNativeGrant(opts?: { function makeTransferFungibleGrant(opts?: { to?: Address; - maxAmount?: bigint; + totalLimit?: bigint; }): TransferFungibleGrant { return { method: 'transferFungible', token: TOKEN, delegation: BASE_DELEGATION, ...(opts?.to !== undefined && { to: opts.to }), - ...(opts?.maxAmount !== undefined && { maxAmount: opts.maxAmount }), + ...(opts?.totalLimit !== undefined && { totalLimit: opts.totalLimit }), }; } @@ -141,7 +141,7 @@ describe('makeDelegationTwin', () => { it('exposes transferFungible method', () => { const redeemFn = vi.fn().mockResolvedValue(TX_HASH); const section = makeDelegationTwin({ - grant: makeTransferFungibleGrant({ maxAmount: 10000n }), + grant: makeTransferFungibleGrant({ totalLimit: 10000n }), redeemFn, }); expect( @@ -152,7 +152,7 @@ describe('makeDelegationTwin', () => { it('tracks cumulative spend across calls', async () => { const redeemFn = vi.fn().mockResolvedValue(TX_HASH); const section = makeDelegationTwin({ - grant: makeTransferFungibleGrant({ maxAmount: 1000n }), + grant: makeTransferFungibleGrant({ totalLimit: 1000n }), redeemFn, }); const exo = section.exo as Record< @@ -170,7 +170,7 @@ describe('makeDelegationTwin', () => { it('does not commit on redeemFn failure', async () => { const redeemFn = vi.fn().mockRejectedValue(new Error('tx reverted')); const section = makeDelegationTwin({ - grant: makeTransferFungibleGrant({ maxAmount: 1000n }), + grant: makeTransferFungibleGrant({ totalLimit: 1000n }), redeemFn, }); const exo = section.exo as Record< @@ -199,7 +199,7 @@ describe('makeDelegationTwin', () => { .mockResolvedValue(TX_HASH); const section = makeDelegationTwin({ - grant: makeTransferFungibleGrant({ maxAmount: 5n }), + grant: makeTransferFungibleGrant({ totalLimit: 5n }), redeemFn, }); const exo = section.exo as Record< @@ -251,7 +251,7 @@ describe('makeDelegationTwin', () => { it('returns method schemas from __getDescription__ for transferFungible', () => { const redeemFn = vi.fn().mockResolvedValue(TX_HASH); const section = makeDelegationTwin({ - grant: makeTransferFungibleGrant({ maxAmount: 1000n }), + grant: makeTransferFungibleGrant({ totalLimit: 1000n }), redeemFn, }); const exo = section.exo as Record; @@ -277,7 +277,7 @@ describe('makeDelegationTwin', () => { it('passes an InterfaceGuard to makeDiscoverableExo for transferFungible', () => { const redeemFn = vi.fn().mockResolvedValue(TX_HASH); makeDelegationTwin({ - grant: makeTransferFungibleGrant({ maxAmount: 1000n }), + grant: makeTransferFungibleGrant({ totalLimit: 1000n }), redeemFn, }); expect(lastInterfaceGuard).toBeDefined(); diff --git a/packages/evm-wallet-experiment/src/lib/delegation-twin.ts b/packages/evm-wallet-experiment/src/lib/delegation-twin.ts index e3c59163cf..251d347e08 100644 --- a/packages/evm-wallet-experiment/src/lib/delegation-twin.ts +++ b/packages/evm-wallet-experiment/src/lib/delegation-twin.ts @@ -96,13 +96,13 @@ export function makeDelegationTwin( // transferFungible — normalize token address to lowercase for consistent matching. const { to } = grant; const token = grant.token.toLowerCase() as Address; - // maxAmount may arrive as a string when the grant crosses a JSON boundary. - // Normalize to bigint so arithmetic and M.lte comparisons work correctly. - const maxAmount = - grant.maxAmount === undefined ? undefined : BigInt(grant.maxAmount); + // totalLimit may arrive as a string when the grant crosses a JSON boundary. + // Normalize to bigint so arithmetic comparisons work correctly. + const totalLimit = + grant.totalLimit === undefined ? undefined : BigInt(grant.totalLimit); let spent = 0n; - const max = maxAmount ?? 2n ** 256n - 1n; + const max = totalLimit ?? 2n ** 256n - 1n; const toGuard = to === undefined ? M.string() : M.eq(to); diff --git a/packages/evm-wallet-experiment/src/types.ts b/packages/evm-wallet-experiment/src/types.ts index 1bdcaf7c56..8a09464728 100644 --- a/packages/evm-wallet-experiment/src/types.ts +++ b/packages/evm-wallet-experiment/src/types.ts @@ -345,7 +345,7 @@ export type TransferFungibleGrant = { /** Restricted recipient; from AllowedCalldataEnforcer. */ to?: Address; /** Cumulative transfer cap; from ERC20TransferAmountEnforcer. */ - maxAmount?: bigint; + totalLimit?: bigint; delegation: Delegation; }; @@ -366,7 +366,7 @@ export const TransferFungibleGrantStruct = object({ method: literal('transferFungible'), token: AddressStruct, to: optional(AddressStruct), - maxAmount: optional(BigintStruct), + totalLimit: optional(BigintStruct), delegation: DelegationStruct, }); diff --git a/packages/evm-wallet-experiment/src/vats/delegator-vat.test.ts b/packages/evm-wallet-experiment/src/vats/delegator-vat.test.ts index 21df1404f7..c60d8ed2d0 100644 --- a/packages/evm-wallet-experiment/src/vats/delegator-vat.test.ts +++ b/packages/evm-wallet-experiment/src/vats/delegator-vat.test.ts @@ -223,19 +223,19 @@ describe('delegator-vat', () => { expect(types).not.toContain('erc20TransferAmount'); }); - it('includes erc20TransferAmount caveat when maxAmount is provided', async () => { + it('includes erc20TransferAmount caveat when totalLimit is provided', async () => { const { root } = makeRoot(); const grant: TransferFungibleGrant = await root.buildTransferFungibleGrant({ delegator: DELEGATOR, delegate: DELEGATE, token: TOKEN, - maxAmount: 5000n, + totalLimit: 5000n, chainId: CHAIN_ID, }); const types = grant.delegation.caveats.map((caveat) => caveat.type); expect(types).toContain('erc20TransferAmount'); - expect(grant.maxAmount).toBe(5000n); + expect(grant.totalLimit).toBe(5000n); }); it('does not include allowedCalldata caveat when to is not provided', async () => { @@ -266,7 +266,7 @@ describe('delegator-vat', () => { expect(grant.to).toBe(RECIPIENT); }); - it('includes all caveats when to and maxAmount are provided', async () => { + it('includes all caveats when to and totalLimit are provided', async () => { const { root } = makeRoot(); const grant: TransferFungibleGrant = await root.buildTransferFungibleGrant({ @@ -274,7 +274,7 @@ describe('delegator-vat', () => { delegate: DELEGATE, token: TOKEN, to: RECIPIENT, - maxAmount: 5000n, + totalLimit: 5000n, chainId: CHAIN_ID, }); const types = grant.delegation.caveats.map((caveat) => caveat.type); diff --git a/packages/evm-wallet-experiment/src/vats/delegator-vat.ts b/packages/evm-wallet-experiment/src/vats/delegator-vat.ts index 5168c2bf1b..10bbb598e6 100644 --- a/packages/evm-wallet-experiment/src/vats/delegator-vat.ts +++ b/packages/evm-wallet-experiment/src/vats/delegator-vat.ts @@ -140,10 +140,10 @@ export function buildRootObject( delegate: Address; token: Address; to?: Address; - maxAmount?: bigint; + totalLimit?: bigint; chainId: number; }): Promise { - const { delegator, delegate, token, to, maxAmount, chainId } = options; + const { delegator, delegate, token, to, totalLimit, chainId } = options; const caveats = [ makeCaveat({ type: 'allowedTargets', @@ -157,11 +157,11 @@ export function buildRootObject( }), ]; - if (maxAmount !== undefined) { + if (totalLimit !== undefined) { caveats.push( makeCaveat({ type: 'erc20TransferAmount', - terms: encodeErc20TransferAmount({ token, amount: maxAmount }), + terms: encodeErc20TransferAmount({ token, amount: totalLimit }), chainId, }), ); @@ -192,7 +192,7 @@ export function buildRootObject( method: 'transferFungible', token, ...(to !== undefined && { to }), - ...(maxAmount !== undefined && { maxAmount }), + ...(totalLimit !== undefined && { totalLimit }), delegation, }); }, diff --git a/packages/evm-wallet-experiment/src/vats/home-coordinator.ts b/packages/evm-wallet-experiment/src/vats/home-coordinator.ts index e4d5fda3f4..0ce68d0201 100644 --- a/packages/evm-wallet-experiment/src/vats/home-coordinator.ts +++ b/packages/evm-wallet-experiment/src/vats/home-coordinator.ts @@ -203,7 +203,7 @@ type DelegatorFacet = { delegate: Address; token: Address; to?: Address; - maxAmount?: bigint; + totalLimit?: bigint; chainId: number; }) => Promise; storeGrant: (grant: DelegationGrant) => Promise; @@ -1716,7 +1716,7 @@ export function buildRootObject( * @param options.delegate - The delegate address. * @param options.token - The ERC-20 token contract address. * @param options.to - Optional restricted recipient. - * @param options.maxAmount - Optional cumulative transfer cap (token units). + * @param options.totalLimit - Optional cumulative transfer cap (token units). * @param options.chainId - The chain ID. * @returns The signed TransferFungibleGrant. */ @@ -1724,7 +1724,7 @@ export function buildRootObject( delegate: Address; token: Address; to?: Address; - maxAmount?: bigint | string; + totalLimit?: bigint | string; chainId: number; }): Promise { if (!delegatorVat) { @@ -1732,12 +1732,14 @@ export function buildRootObject( } const delegator = smartAccountConfig?.address ?? (await resolveOwnerAddress()); - const maxAmount = - options.maxAmount === undefined ? undefined : BigInt(options.maxAmount); + const totalLimit = + options.totalLimit === undefined + ? undefined + : BigInt(options.totalLimit); const unsignedGrant = await E(delegatorVat).buildTransferFungibleGrant({ delegator, ...options, - maxAmount, + totalLimit, }); const signedGrant = await signDelegationInGrant(unsignedGrant); await E(delegatorVat).storeGrant(signedGrant); diff --git a/packages/evm-wallet-experiment/src/vats/redeemer-vat.test.ts b/packages/evm-wallet-experiment/src/vats/redeemer-vat.test.ts index b82ebd91cc..7303274c98 100644 --- a/packages/evm-wallet-experiment/src/vats/redeemer-vat.test.ts +++ b/packages/evm-wallet-experiment/src/vats/redeemer-vat.test.ts @@ -42,7 +42,7 @@ const FUNGIBLE_GRANT: TransferFungibleGrant = { method: 'transferFungible', token: '0x3333333333333333333333333333333333333333' as Address, to: '0x4444444444444444444444444444444444444444' as Address, - maxAmount: 5000n, + totalLimit: 5000n, delegation: { id: '0xbbbb', delegator: '0x1111111111111111111111111111111111111111' as Address, 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 55aa19dfdd..ff1accda27 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 @@ -100,7 +100,7 @@ console.log(`\n=== Delegation Twin E2E (${mode}) ===\n`); console.log('--- Transfer twin: spend tracking ---'); -// Home builds and signs the grant. maxAmount is a string because JSON cannot +// Home builds and signs the grant. totalLimit is a string because JSON cannot // carry BigInt; buildTransferFungibleGrant coerces it back to BigInt. const signedGrant = await homeClient.callVat( homeKref, @@ -109,7 +109,7 @@ const signedGrant = await homeClient.callVat( { delegate: delegateAddress, token: FAKE_TOKEN, - maxAmount: '5', + totalLimit: '5', chainId: CHAIN_ID, }, ], From bc8984326991e399d8e1392634bfd6047ae210ff Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Wed, 22 Apr 2026 10:04:56 -0400 Subject: [PATCH 20/23] fix(evm-wallet): normalize maxAmount to bigint before toString(16) in getCapabilities grant.maxAmount may be a string when the grant crosses a JSON boundary. String.prototype.toString(16) ignores the radix and returns the raw string, producing garbage hex input to weiToEth(). Wrap with BigInt() first. Co-Authored-By: Claude Sonnet 4.6 --- packages/evm-wallet-experiment/src/vats/away-coordinator.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/evm-wallet-experiment/src/vats/away-coordinator.ts b/packages/evm-wallet-experiment/src/vats/away-coordinator.ts index 670e3b4522..ce3f958ed0 100644 --- a/packages/evm-wallet-experiment/src/vats/away-coordinator.ts +++ b/packages/evm-wallet-experiment/src/vats/away-coordinator.ts @@ -1964,7 +1964,7 @@ export function buildRootObject( ) .map( (grant) => - `max ${weiToEth(`0x${(grant.maxAmount ?? 0n).toString(16)}`)} per tx`, + `max ${weiToEth(`0x${BigInt(grant.maxAmount ?? 0n).toString(16)}`)} per tx`, ); const limitSuffix = limitParts.length > 0 ? ` (${limitParts.join('; ')})` : ''; From 0c68d6f9f0dcefcb8dd07d230fb548bb4e996aa4 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Wed, 22 Apr 2026 10:23:16 -0400 Subject: [PATCH 21/23] docs(evm-wallet): clarify twin routing error semantics in JSDoc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The old comment "errors are not swallowed and do not fall through to the home section" was misleading — intermediate errors ARE discarded; only the last one propagates. The try-all-twins pattern is intentional: a budget- exhausted twin A falls through to twin B with remaining budget. Document the actual behaviour. Co-Authored-By: Claude Sonnet 4.6 --- .../src/vats/away-coordinator.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/evm-wallet-experiment/src/vats/away-coordinator.ts b/packages/evm-wallet-experiment/src/vats/away-coordinator.ts index ce3f958ed0..252ef19ad4 100644 --- a/packages/evm-wallet-experiment/src/vats/away-coordinator.ts +++ b/packages/evm-wallet-experiment/src/vats/away-coordinator.ts @@ -1781,9 +1781,9 @@ export function buildRootObject( /** * Transfer native ETH. - * Tries each delegation twin in order; falls back to calling home. - * Errors from matched twins propagate — they are not swallowed and do - * not fall through to the home section. + * Tries each matching delegation twin in order; the first success is + * returned. If all matched twins fail, the last error is thrown and the + * call does not fall through to the home section. * * @param to - Recipient address. * @param amount - Amount in wei. @@ -1819,9 +1819,9 @@ export function buildRootObject( /** * Transfer ERC-20 tokens. - * Tries delegation twins for this token first; if none match, falls back to - * calling home. Errors from matched twins propagate — they are not swallowed - * and do not fall through to the home section. + * Tries each matching delegation twin in order; the first success is + * returned. If all matched twins fail, the last error is thrown and the + * call does not fall through to the home section. * * @param token - ERC-20 token contract address. * @param to - Recipient address. From d9ef637a12a06c528d33b7d7a997875564ca3107 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Wed, 22 Apr 2026 10:45:17 -0400 Subject: [PATCH 22/23] fix(evm-wallet): use totalLimit instead of maxAmount in delegation-twin test The inline TransferFungibleGrant object silently accepted maxAmount due to TypeScript's union excess-property checking allowing fields from sibling members. Co-Authored-By: Claude Sonnet 4.6 --- packages/evm-wallet-experiment/src/lib/delegation-twin.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 44bc97c825..771864d616 100644 --- a/packages/evm-wallet-experiment/src/lib/delegation-twin.test.ts +++ b/packages/evm-wallet-experiment/src/lib/delegation-twin.test.ts @@ -131,7 +131,7 @@ describe('makeDelegationTwin', () => { method: 'transferFungible', token: CHECKSUMMED_TOKEN, delegation: BASE_DELEGATION, - maxAmount: 1000n, + totalLimit: 1000n, }, redeemFn, }); From 088e26c204b10012ba916e54fe3e357dd7593a7b Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Wed, 22 Apr 2026 10:49:55 -0400 Subject: [PATCH 23/23] fix(evm-wallet): collect all twin errors and throw with cause array Instead of silently discarding intermediate errors and only rethrowing the last one, both transferNative and transferFungible routing now collect errors from all failed twins and throw a single Error with { cause: errors[] }, preserving the full failure context. Co-Authored-By: Claude Sonnet 4.6 --- .../src/vats/away-coordinator.ts | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/evm-wallet-experiment/src/vats/away-coordinator.ts b/packages/evm-wallet-experiment/src/vats/away-coordinator.ts index 252ef19ad4..c97547280a 100644 --- a/packages/evm-wallet-experiment/src/vats/away-coordinator.ts +++ b/packages/evm-wallet-experiment/src/vats/away-coordinator.ts @@ -1782,8 +1782,8 @@ export function buildRootObject( /** * Transfer native ETH. * Tries each matching delegation twin in order; the first success is - * returned. If all matched twins fail, the last error is thrown and the - * call does not fall through to the home section. + * returned. If all matched twins fail, throws with all collected errors + * as the cause array; does not fall through to the home section. * * @param to - Recipient address. * @param amount - Amount in wei. @@ -1799,15 +1799,15 @@ export function buildRootObject( (sec) => sec.method === 'transferNative', ); if (matching.length > 0) { - let lastError: unknown; + const errors: unknown[] = []; for (const section of matching) { try { return await E(section.exo).transferNative(to, amt); } catch (error) { - lastError = error; + errors.push(error); } } - throw lastError; + throw new Error('All delegation twins failed', { cause: errors }); } if (homeSection) { return E(homeSection).transferNative(to, amt); @@ -1820,8 +1820,8 @@ export function buildRootObject( /** * Transfer ERC-20 tokens. * Tries each matching delegation twin in order; the first success is - * returned. If all matched twins fail, the last error is thrown and the - * call does not fall through to the home section. + * returned. If all matched twins fail, throws with all collected errors + * as the cause array; does not fall through to the home section. * * @param token - ERC-20 token contract address. * @param to - Recipient address. @@ -1840,15 +1840,15 @@ export function buildRootObject( (sec) => sec.method === 'transferFungible' && sec.token === tokenLower, ); if (matching.length > 0) { - let lastError: unknown; + const errors: unknown[] = []; for (const section of matching) { try { return await E(section.exo).transferFungible(tokenLower, to, amt); } catch (error) { - lastError = error; + errors.push(error); } } - throw lastError; + throw new Error('All delegation twins failed', { cause: errors }); } if (homeSection) { return E(homeSection).transferFungible(token, to, amt);