diff --git a/packages/evm-wallet-experiment/package.json b/packages/evm-wallet-experiment/package.json index 8da7b67a63..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/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 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/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..bf5f27a6ca 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,13 +43,14 @@ export type { SwapQuote, SwapResult, TransactionRequest, + TransferFungibleGrant, + TransferNativeGrant, UserOperation, WalletCapabilities, } from './types.ts'; export { ActionStruct, - CaveatSpecStruct, CaveatStruct, CaveatTypeValues, ChainConfigStruct, @@ -66,6 +66,8 @@ export { SmartAccountConfigStruct, SwapQuoteStruct, TransactionRequestStruct, + TransferFungibleGrantStruct, + TransferNativeGrantStruct, UserOperationStruct, WalletCapabilitiesStruct, } from './types.ts'; @@ -182,11 +184,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/lib/delegation-twin.test.ts b/packages/evm-wallet-experiment/src/lib/delegation-twin.test.ts index 184485c712..771864d616 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; @@ -27,127 +33,160 @@ 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; + totalLimit?: 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?.totalLimit !== undefined && { totalLimit: opts.totalLimit }), }; } 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 () => { + 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 twin = makeDelegationTwin({ - grant: makeTransferGrant(1000n), + const section = makeDelegationTwin({ + grant: { + method: 'transferFungible', + token: CHECKSUMMED_TOKEN, + delegation: BASE_DELEGATION, + totalLimit: 1000n, + }, 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.token).toBe(CHECKSUMMED_TOKEN.toLowerCase()); }); - it('rejects call when budget exhausted', async () => { + it('exposes transferFungible method', () => { const redeemFn = vi.fn().mockResolvedValue(TX_HASH); - const twin = makeDelegationTwin({ - grant: makeTransferGrant(100n), + const section = makeDelegationTwin({ + grant: makeTransferFungibleGrant({ totalLimit: 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({ totalLimit: 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({ totalLimit: 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 +198,109 @@ describe('makeDelegationTwin', () => { ) .mockResolvedValue(TX_HASH); - const twin = makeDelegationTwin({ - grant: makeTransferGrant(5n), + const section = makeDelegationTwin({ + grant: makeTransferFungibleGrant({ totalLimit: 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({ totalLimit: 1000n }), redeemFn, - readFn, - }) as Record Promise>; - - const balance = await twin.getBalance(); - expect(balance).toBe(1000000n); - expect(readFn).toHaveBeenCalledWith({ - to: TOKEN, - data: encodeBalanceOf(ALICE), }); - }); - }); - - 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>; + const exo = section.exo as Record; - 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({ totalLimit: 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..251d347e08 100644 --- a/packages/evm-wallet-experiment/src/lib/delegation-twin.ts +++ b/packages/evm-wallet-experiment/src/lib/delegation-twin.ts @@ -1,240 +1,156 @@ 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 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); + + const interfaceGuard = M.interface( + `DelegationTwin:transferNative:${idPrefix}`, + { + transferNative: M.callWhen(toGuard, amountGuard).returns(M.string()), + }, + { defaultGuards: 'passable' }, + ); + + let spent = 0n; + const cumulativeMax = totalLimit ?? 2n ** 256n - 1n; + + const exo = makeDiscoverableExo( + `DelegationTwin:transferNative:${idPrefix}`, + { + async transferNative(recipient: Address, amount: bigint): Promise { + 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, + value: `0x${amount.toString(16)}`, + callData: '0x' as Hex, + }; + + try { + return await redeemFn(execution); + } catch (error) { + // Roll back on redeemFn failure. + spent -= amount; + throw error; + } + }, + }, + { 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 — normalize token address to lowercase for consistent matching. + const { to } = grant; + const token = grant.token.toLowerCase() as Address; + // 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 = totalLimit ?? 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: bigint, + ): Promise { + if (amount > max - spent) { + throw new Error( + `Insufficient budget: requested ${amount}, remaining ${max - spent}`, + ); + } + + // Reserve before the await so concurrent calls see the updated budget. + spent += amount; + + const execution: Execution = { + target: tokenAddress, + value: '0x0' as Hex, + callData: encodeTransfer(recipient, amount), + }; + + try { + return await redeemFn(execution); + } catch (error) { + // Roll back on redeemFn failure. + spent -= amount; + throw error; + } + }, + }, + { transferFungible: METHOD_CATALOG.transferFungible }, interfaceGuard, ); + + return { exo, method: 'transferFungible', token }; } 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/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/types.ts b/packages/evm-wallet-experiment/src/types.ts index 68ce3f4d80..8a09464728 100644 --- a/packages/evm-wallet-experiment/src/types.ts +++ b/packages/evm-wallet-experiment/src/types.ts @@ -324,46 +324,59 @@ export type DelegationMatchResult = { }; // --------------------------------------------------------------------------- -// Delegation grant (twin construction input) -// --------------------------------------------------------------------------- +// Semantic delegation grants (twin construction input — post-decoded) +// --------------------------------------------------------------------------- + +export type TransferNativeGrant = { + method: 'transferNative'; + /** Restricted recipient; enforced by AllowedTargetsEnforcer on-chain. */ + to?: Address; + /** 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; +}; -const BigIntStruct = define( - 'BigInt', +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. */ + totalLimit?: bigint; + delegation: Delegation; +}; + +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 const TransferNativeGrantStruct = object({ + method: literal('transferNative'), + to: optional(AddressStruct), + maxAmount: optional(BigintStruct), + totalLimit: optional(BigintStruct), + delegation: DelegationStruct, +}); -export const DelegationGrantStruct = object({ +export const TransferFungibleGrantStruct = object({ + method: literal('transferFungible'), + token: AddressStruct, + to: optional(AddressStruct), + totalLimit: optional(BigintStruct), delegation: DelegationStruct, - methodName: string(), - caveatSpecs: array(CaveatSpecStruct), - token: optional(AddressStruct), }); -export type DelegationGrant = Infer; +export const DelegationGrantStruct = union([ + TransferNativeGrantStruct, + TransferFungibleGrantStruct, +]); + +/** Discriminated union of all supported semantic delegation grant types. */ +export type DelegationGrant = TransferNativeGrant | TransferFungibleGrant; // --------------------------------------------------------------------------- // Swap types (MetaSwap API) 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..c97547280a --- /dev/null +++ b/packages/evm-wallet-experiment/src/vats/away-coordinator.ts @@ -0,0 +1,1999 @@ +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 { + applyGasBuffer, + validateGasEstimate, + validateTokenCallResult, +} from '../lib/tx-utils.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); + +/** + * 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[] = []; + // Keyed by delegation.id so rebuildRouting preserves in-memory spend counters. + const delegationTwinMap = new Map(); + + // ------------------------------------------------------------------------- + // 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'); + homeCoordRef = restoreFromBaggage('homeCoordRef'); + 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); + } + } + + if (homeCoordRef) { + return E(homeCoordRef).signTypedData(data); + } + + 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() : []; + 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, + ); + } + + // ------------------------------------------------------------------------- + // 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 { + // 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() + : []; + + 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); + } + } + if (homeCoordRef) { + return E(homeCoordRef).signMessage(message); + } + 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 matching delegation twin in order; the first success is + * returned. If all matched twins fail, throws with all collected errors + * as the cause array; does not fall through to the home section. + * + * @param to - Recipient address. + * @param amount - Amount in wei. + * @returns The transaction hash. + */ + async transferNative( + to: Address, + amount: string | number | bigint, + ): Promise { + // Coerce at the JSON boundary — CLI callers pass numeric strings. + const amt = BigInt(amount); + const matching = delegationSections.filter( + (sec) => sec.method === 'transferNative', + ); + if (matching.length > 0) { + const errors: unknown[] = []; + for (const section of matching) { + try { + return await E(section.exo).transferNative(to, amt); + } catch (error) { + errors.push(error); + } + } + throw new Error('All delegation twins failed', { cause: errors }); + } + 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 each matching delegation twin in order; the first success is + * returned. If all matched twins fail, throws with all collected errors + * as the cause array; does not fall through to the home section. + * + * @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: string | number | bigint, + ): Promise { + // Coerce at the JSON boundary — CLI callers pass numeric strings. + const amt = BigInt(amount); + const tokenLower = token.toLowerCase() as Address; + const matching = delegationSections.filter( + (sec) => sec.method === 'transferFungible' && sec.token === tokenLower, + ); + if (matching.length > 0) { + const errors: unknown[] = []; + for (const section of matching) { + try { + return await E(section.exo).transferFungible(tokenLower, to, amt); + } catch (error) { + errors.push(error); + } + } + throw new Error('All delegation twins failed', { cause: errors }); + } + 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 homeCoordRef and homeSection 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('homeCoordRef', homeCoordRef); + 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${BigInt(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/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/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(); - }, - }); -} 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..c60d8ed2d0 --- /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 totalLimit is provided', async () => { + const { root } = makeRoot(); + const grant: TransferFungibleGrant = + await root.buildTransferFungibleGrant({ + delegator: DELEGATOR, + delegate: DELEGATE, + token: TOKEN, + totalLimit: 5000n, + chainId: CHAIN_ID, + }); + const types = grant.delegation.caveats.map((caveat) => caveat.type); + expect(types).toContain('erc20TransferAmount'); + expect(grant.totalLimit).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 totalLimit are provided', async () => { + const { root } = makeRoot(); + const grant: TransferFungibleGrant = + await root.buildTransferFungibleGrant({ + delegator: DELEGATOR, + delegate: DELEGATE, + token: TOKEN, + to: RECIPIENT, + totalLimit: 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..10bbb598e6 --- /dev/null +++ b/packages/evm-wallet-experiment/src/vats/delegator-vat.ts @@ -0,0 +1,245 @@ +import { makeDefaultExo } from '@metamask/kernel-utils/exo'; +import type { Baggage } from '@metamask/ocap-kernel'; +import { create } from '@metamask/superstruct'; + +import { + ENFORCER_CONTRACT_KEY_MAP, + PLACEHOLDER_CONTRACTS, + registerChainContracts, +} from '../constants.ts'; +import type { ChainContracts } from '../constants.ts'; +import { + makeCaveat, + encodeValueLte, + encodeNativeTokenTransferAmount, + 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'; +import { DelegationGrantStruct } 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).map( + ([id, raw]) => [id, create(raw, DelegationGrantStruct)], + ), + ) + : 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; + totalLimit?: bigint; + chainId: number; + }): Promise { + const { delegator, delegate, to, maxAmount, totalLimit, chainId } = + options; + const caveats = []; + + if (to !== undefined) { + caveats.push( + makeCaveat({ + type: 'allowedTargets', + terms: encodeAllowedTargets([to]), + chainId, + }), + ); + } + + if (totalLimit !== undefined) { + caveats.push( + makeCaveat({ + type: 'nativeTokenTransferAmount', + terms: encodeNativeTokenTransferAmount(totalLimit), + 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 }), + ...(totalLimit !== undefined && { totalLimit }), + delegation, + }); + }, + + async buildTransferFungibleGrant(options: { + delegator: Address; + delegate: Address; + token: Address; + to?: Address; + totalLimit?: bigint; + chainId: number; + }): Promise { + const { delegator, delegate, token, to, totalLimit, chainId } = options; + const caveats = [ + makeCaveat({ + type: 'allowedTargets', + terms: encodeAllowedTargets([token]), + chainId, + }), + makeCaveat({ + type: 'allowedMethods', + terms: encodeAllowedMethods([ERC20_TRANSFER_SELECTOR]), + chainId, + }), + ]; + + if (totalLimit !== undefined) { + caveats.push( + makeCaveat({ + type: 'erc20TransferAmount', + terms: encodeErc20TransferAmount({ token, amount: totalLimit }), + 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 }), + ...(totalLimit !== undefined && { totalLimit }), + delegation, + }); + }, + + /** + * 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(); + }, + + async removeGrant(id: string): Promise { + grants.delete(id); + persistGrants(); + }, + + async listGrants(): Promise { + return harden([...grants.values()]); + }, + }); +} 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/coordinator-vat.ts b/packages/evm-wallet-experiment/src/vats/home-coordinator.ts similarity index 55% rename from packages/evm-wallet-experiment/src/vats/coordinator-vat.ts rename to packages/evm-wallet-experiment/src/vats/home-coordinator.ts index 870b344e5e..0ce68d0201 100644 --- a/packages/evm-wallet-experiment/src/vats/coordinator-vat.ts +++ b/packages/evm-wallet-experiment/src/vats/home-coordinator.ts @@ -1,4 +1,6 @@ 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'; @@ -6,15 +8,14 @@ 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 { - buildDelegationGrant, - makeDelegationGrantBuilder, -} from '../lib/delegation-grant.ts'; -import { makeDelegationTwin } from '../lib/delegation-twin.ts'; -import { makeSaltGenerator } from '../lib/delegation.ts'; + prepareDelegationTypedData, + finalizeDelegation, +} from '../lib/delegation.ts'; import { decodeAllowanceResult, decodeBalanceOfResult, @@ -28,10 +29,9 @@ import { encodeSymbol, encodeTransfer, } from '../lib/erc20.ts'; -import type { CatalogMethodName } from '../lib/method-catalog.ts'; +import { METHOD_CATALOG } from '../lib/method-catalog.ts'; import { buildBatchExecuteCallData, - buildSdkBatchRedeemCallData, buildSdkDisableCallData, buildSdkRedeemCallData, computeSmartAccountAddress, @@ -41,16 +41,17 @@ 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 { - Action, Address, - Caveat, ChainConfig, - CreateDelegationOptions, Delegation, DelegationGrant, - DelegationMatchResult, Eip712TypedData, Execution, Hex, @@ -58,148 +59,46 @@ import type { SwapQuote, SwapResult, TransactionRequest, + TransferFungibleGrant, + TransferNativeGrant, 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 types +// --------------------------------------------------------------------------- /** - * Vat powers for the coordinator vat. + * Vat powers for the home coordinator vat. */ type VatPowers = { logger?: Logger; }; /** - * Vat references available in the wallet subcluster. + * Vat references available in the home wallet subcluster. */ type WalletVats = { keyring?: unknown; provider?: unknown; - delegation?: unknown; + delegator?: unknown; }; /** - * Services available to the wallet subcluster. + * Services available to the home wallet subcluster. */ type WalletServices = { ocapURLIssuerService?: unknown; ocapURLRedemptionService?: unknown; }; -// Typed facets for E() calls (avoid `any` by using explicit method signatures) +// --------------------------------------------------------------------------- +// Facet types (typed remote references for E() calls) +// --------------------------------------------------------------------------- + type KeyringFacet = { initialize: ( options: { type: string; mnemonic?: string }, @@ -280,50 +179,6 @@ type ProviderFacet = { }>; }; -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; @@ -331,30 +186,53 @@ type ExternalSignerFacet = { 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; +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; + totalLimit?: 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 coordinator vat (bootstrap vat). + * Build the root object for the home coordinator vat. * - * The coordinator orchestrates signing strategy resolution, delegation - * management, and peer wallet communication. It is the public API of - * the wallet subcluster. + * 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. + * @param _parameters - Initialization parameters (role: 'home'). * @param baggage - Root of vat's persistent state. - * @returns The root object for the coordinator vat. + * @returns The root object for the home coordinator vat. */ export function buildRootObject( vatPowers: VatPowers, @@ -362,7 +240,7 @@ export function buildRootObject( baggage: Baggage, ): object { const logger = (vatPowers.logger ?? new Logger()).subLogger({ - tags: ['coordinator-vat'], + tags: ['home-coordinator-vat'], }); // Wire SDK logger so resolveEnvironment/registerEnvironment are visible @@ -374,34 +252,10 @@ export function buildRootObject( } }); - // 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; + let delegatorVat: DelegatorFacet | undefined; // External signer reference (e.g. MetaMask). // Note: external signers are transient — they must be reconnected after @@ -430,20 +284,11 @@ export function buildRootObject( // 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; + // OcapURL service references + let issuerService: OcapURLIssuerFacet | undefined; - // Cached peer (home) accounts for offline autonomy - let cachedPeerAccounts: Address[] = []; - // Cached peer signing mode for offline autonomy - let cachedPeerSigningMode: string | undefined; + // Away wallet's delegate address, registered via registerDelegateAddress + let delegateAddress: string | undefined; /** * Typed helper for restoring values from baggage (resuscitation). @@ -458,22 +303,29 @@ export function buildRootObject( // Restore vat references from baggage if available (resuscitation) keyringVat = restoreFromBaggage('keyringVat'); providerVat = restoreFromBaggage('providerVat'); - delegationVat = restoreFromBaggage('delegationVat'); - peerWallet = restoreFromBaggage('peerWallet'); + 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'); - awayWallet = restoreFromBaggage('awayWallet'); - pendingDelegateAddress = restoreFromBaggage
( - 'pendingDelegateAddress', - ); - cachedPeerAccounts = - restoreFromBaggage('cachedPeerAccounts') ?? []; - cachedPeerSigningMode = restoreFromBaggage('cachedPeerSigningMode'); + delegateAddress = restoreFromBaggage('delegateAddress'); /** Chain ID from the last `configureProvider` call (avoids RPC on every send). */ let cachedProviderChainId: number | undefined = restoreFromBaggage( @@ -481,7 +333,21 @@ export function buildRootObject( ); /** - * Resolve the wallet chain ID for delegation matching, SDK addresses, and txs. + * 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. * @@ -656,38 +522,6 @@ export function buildRootObject( ); } - /** - * 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. * @@ -710,75 +544,9 @@ export function buildRootObject( 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 + * Priority: keyring → external signer → error * * @param data - The EIP-712 typed data to sign. * @param from - Optional sender address. @@ -788,18 +556,6 @@ export function buildRootObject( 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) { @@ -814,18 +570,12 @@ export function buildRootObject( } } - 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 + * Priority: keyring → external signer → error * * @param message - The message to sign. * @param from - Optional sender address. @@ -835,20 +585,6 @@ export function buildRootObject( 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) { @@ -863,14 +599,6 @@ export function buildRootObject( } } - if (peerWallet) { - return E(peerWallet).handleSigningRequest({ - type: 'message', - message, - ...(from ? { account: from } : {}), - }); - } - throw new Error('No authority to sign message'); } @@ -904,8 +632,8 @@ export function buildRootObject( } /** - * Build, sign, and submit a UserOp. Shared pipeline for both delegation - * redemption and on-chain delegation revocation. + * 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. @@ -1088,157 +816,6 @@ export function buildRootObject( }); } - /** - * 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 @@ -1473,7 +1050,135 @@ export function buildRootObject( return smartAccountConfig; } - const coordinator = makeDefaultExo('walletCoordinator', { + /** + * 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 + // --------------------------------------------------------------------------- + + const homeSection = makeDiscoverableExo( + 'HomeWallet', + { + async transferNative(to: Address, amount: bigint): Promise { + 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 { + const from = await resolveOwnerAddress(); + if (!providerVat) { + throw new Error('Provider not configured'); + } + const callData = encodeTransfer(to, amount); + 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 // ------------------------------------------------------------------ @@ -1481,13 +1186,10 @@ export function buildRootObject( 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; + delegatorVat = vats.delegator as DelegatorFacet | undefined; issuerService = services.ocapURLIssuerService as | OcapURLIssuerFacet | undefined; - redemptionService = services.ocapURLRedemptionService as - | OcapURLRedemptionFacet - | undefined; if (keyringVat) { persistBaggage('keyringVat', keyringVat); @@ -1495,13 +1197,22 @@ export function buildRootObject( if (providerVat) { persistBaggage('providerVat', providerVat); } - if (delegationVat) { - persistBaggage('delegationVat', delegationVat); + 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), - hasDelegation: Boolean(delegationVat), + hasDelegator: Boolean(delegatorVat), }); }, @@ -1623,7 +1334,7 @@ export function buildRootObject( registerEnvironment(config.chainId, config.environment); // Also register in our own getChainContracts() registry so that - // makeDelegationGrant() can build caveats for this chain. + // 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)) { @@ -1636,6 +1347,15 @@ export function buildRootObject( 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({ @@ -1647,6 +1367,7 @@ export function buildRootObject( environment: config.environment, }); persistBaggage('bundlerConfig', bundlerConfig); + logger.info('bundler configured', { bundlerUrl: config.bundlerUrl, chainId: config.chainId, @@ -1734,32 +1455,6 @@ export function buildRootObject( // ------------------------------------------------------------------ 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() : []; @@ -1792,100 +1487,12 @@ export function buildRootObject( 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})`, - ), - ); - } - } + // Home sends direct transactions only — no delegation routing. + logger.debug('sendTransaction: using direct send'); - logger.debug('sendTransaction: no delegation path, using direct send'); // Estimate missing gas fields for direct (non-delegation) sends const filledTx = { ...tx }; @@ -1922,7 +1529,7 @@ export function buildRootObject( } if (txs.length === 1) { - return coordinator.sendTransaction(txs[0]); + return homeCoordinator.sendTransaction(txs[0]); } if (!providerVat) { @@ -1930,21 +1537,19 @@ export function buildRootObject( } const batchSender = - smartAccountConfig?.address ?? (await coordinator.getAccounts())[0]; + 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 (see revokeDelegation). + // 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 || - (peerWallet !== undefined && delegationVat !== undefined); + bundlerConfig !== undefined || isDirect7702Batch; - // Smart account path: single UserOp or direct 7702 self-call if (useSmartAccountBatchPath) { const executions: Execution[] = txs.map((tx) => ({ target: tx.to, @@ -1952,67 +1557,6 @@ export function buildRootObject( 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'); @@ -2024,8 +1568,7 @@ export function buildRootObject( } if (!bundlerConfig) { throw new Error( - 'Non-delegation batch execution requires a bundler or direct 7702; ' + - 'peer relay is only available for delegation redemptions', + 'Non-delegation batch execution requires a bundler or direct 7702', ); } return buildAndSubmitUserOp({ sender, callData }); @@ -2034,7 +1577,7 @@ export function buildRootObject( // EOA fallback: execute sequentially const hashes: Hex[] = []; for (const tx of txs) { - hashes.push(await coordinator.sendTransaction(tx)); + hashes.push(await homeCoordinator.sendTransaction(tx)); } return hashes; }, @@ -2056,7 +1599,7 @@ export function buildRootObject( /** * Look up a transaction by hash. Tries the bundler first (in case the - * hash is a UserOp hash from delegation redemption), then falls back + * 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. @@ -2115,141 +1658,135 @@ export function buildRootObject( }, // ------------------------------------------------------------------ - // Delegation management + // Delegation grant management (via delegator vat) // ------------------------------------------------------------------ - 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'); + /** + * 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.totalLimit - Optional cumulative ETH transfer cap (wei). + * @param options.chainId - The chain ID. + * @returns The signed TransferNativeGrant. + */ + async buildTransferNativeGrant(options: { + delegate: Address; + to?: Address; + maxAmount?: bigint | string; + totalLimit?: bigint | string; + chainId: number; + }): Promise { + if (!delegatorVat) { + throw new Error('Delegator vat not available'); } - - 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, + 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).buildTransferNativeGrant({ delegator, - delegate: opts.delegate, - caveats: opts.caveats?.length ?? 0, + ...options, + maxAmount, + totalLimit, }); - - return E(delegationVat).getDelegation(delegation.id); + const signedGrant = await signDelegationInGrant(unsignedGrant); + await E(delegatorVat).storeGrant(signedGrant); + return signedGrant; }, - async receiveDelegation(delegation: Delegation): Promise { - if (!delegationVat) { - throw new Error('Delegation vat not available'); + /** + * 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.totalLimit - 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; + totalLimit?: bigint | string; + chainId: number; + }): Promise { + if (!delegatorVat) { + throw new Error('Delegator vat not available'); } - await E(delegationVat).receiveDelegation(delegation); + const delegator = + smartAccountConfig?.address ?? (await resolveOwnerAddress()); + const totalLimit = + options.totalLimit === undefined + ? undefined + : BigInt(options.totalLimit); + const unsignedGrant = await E(delegatorVat).buildTransferFungibleGrant({ + delegator, + ...options, + totalLimit, + }); + const signedGrant = await signDelegationInGrant(unsignedGrant); + await E(delegatorVat).storeGrant(signedGrant); + return signedGrant; }, /** - * 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. + * List all delegation grants stored in the delegator vat. * - * @param id - The delegation identifier. + * @returns An array of all DelegationGrant objects. */ - 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); + async listGrants(): Promise { + if (!delegatorVat) { + throw new Error('Delegator vat not available'); } + return E(delegatorVat).listGrants(); }, /** - * 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. + * Revoke a delegation grant on-chain and remove it from the delegator vat. * - * Hybrid accounts require a configured bundler (paymaster optional). + * 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 identifier. - * @returns The UserOp hash or transaction hash of the on-chain revocation. + * @param id - The delegation ID to revoke. + * @returns The transaction or UserOp hash of the on-chain revocation. */ - async revokeDelegation(id: string): Promise { - if (!delegationVat) { - throw new Error('Delegation vat not available'); + 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 = await E(delegationVat).getDelegation(id); + const { delegation } = grant; if (delegation.status === 'revoked') { - throw new Error(`Delegation ${id} is already revoked`); + throw new Error(`Grant ${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`, + `Grant ${id} has status '${delegation.status}', expected 'signed'`, ); } @@ -2265,13 +1802,13 @@ export function buildRootObject( }); if (!receipt.success) { throw new Error( - `On-chain revocation reverted for delegation ${id} (tx: ${submissionHash})`, + `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 coordinator.waitForUserOpReceipt({ + const rawReceipt = await homeCoordinator.waitForUserOpReceipt({ userOpHash: submissionHash, }); const receipt = rawReceipt as { success?: boolean } | undefined; @@ -2281,264 +1818,53 @@ export function buildRootObject( !('success' in receipt) ) { throw new Error( - `Unexpected UserOp receipt format for delegation ${id} ` + + `Unexpected UserOp receipt format for grant ${id} ` + `(userOpHash: ${submissionHash})`, ); } if (!receipt.success) { throw new Error( - `On-chain revocation reverted for delegation ${id} (userOpHash: ${submissionHash})`, + `On-chain revocation reverted for grant ${id} (userOpHash: ${submissionHash})`, ); } } - // Update local status after on-chain confirmation - await E(delegationVat).revokeDelegation(id); + // Remove local grant record after on-chain confirmation + await E(delegatorVat).removeGrant(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) - // ------------------------------------------------------------------ - + /** + * 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; - delegations?: Delegation[]; - delegationId?: string; - action?: Action; - maxFeePerGas?: Hex; - maxPriorityFeePerGas?: Hex; }): Promise { - if (!delegationVat) { - throw new Error('Delegation vat not available'); + 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 }); } - - // 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 (!bundlerConfig) { + throw new Error( + 'Bundler not configured — cannot relay delegation redemption', ); - 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, - }); + return buildAndSubmitUserOp({ sender, callData: sdkCallData }); }, // ------------------------------------------------------------------ @@ -2644,7 +1970,7 @@ export function buildRootObject( amount: bigint | Hex; from?: Address; }): Promise { - const accounts = await coordinator.getAccounts(); + const accounts = await homeCoordinator.getAccounts(); const from = options.from ?? accounts[0]; if (!from) { throw new Error('No accounts available'); @@ -2654,7 +1980,7 @@ export function buildRootObject( ? options.amount : BigInt(options.amount); const callData = encodeTransfer(options.to, rawAmount); - return coordinator.sendTransaction({ + return homeCoordinator.sendTransaction({ from, to: options.token, data: callData, @@ -2682,7 +2008,7 @@ export function buildRootObject( } const walletAddress = - options.walletAddress ?? (await coordinator.getAccounts())[0]; + options.walletAddress ?? (await homeCoordinator.getAccounts())[0]; if (!walletAddress) { throw new Error('No accounts available'); } @@ -2755,14 +2081,14 @@ export function buildRootObject( const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000' as Address; - const accounts = await coordinator.getAccounts(); + 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 coordinator.getSwapQuote({ + const quote = await homeCoordinator.getSwapQuote({ ...options, walletAddress: from, }); @@ -2811,7 +2137,7 @@ export function buildRootObject( value: (approvalInfo.value ?? '0x0') as Hex, }; - const batchResult = await coordinator.sendBatchTransaction([ + const batchResult = await homeCoordinator.sendBatchTransaction([ approvalTx, swapTx, ]); @@ -2834,7 +2160,7 @@ export function buildRootObject( // Sequential path: approve then swap (EOA or no approval needed) let approvalTxHash: Hex | undefined; if (approvalNeeded && approvalInfo) { - approvalTxHash = await coordinator.sendTransaction({ + approvalTxHash = await homeCoordinator.sendTransaction({ from, to: options.srcToken, data: approvalInfo.data as Hex, @@ -2843,7 +2169,7 @@ export function buildRootObject( } try { - const swapTxHash = await coordinator.sendTransaction(swapTx); + const swapTxHash = await homeCoordinator.sendTransaction(swapTx); return harden({ approvalTxHash, @@ -2922,242 +2248,73 @@ export function buildRootObject( }, // ------------------------------------------------------------------ - // Peer wallet connectivity + // Peer delegate address registration // ------------------------------------------------------------------ - 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); - }, - + /** + * 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 { - 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); + delegateAddress = address; + persistBaggage('delegateAddress', delegateAddress); }, - 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}`); - } + /** + * Return the registered away wallet delegate address, if any. + * + * @returns The delegate address, or undefined if not yet registered. + */ + async getDelegateAddress(): Promise { + return delegateAddress; }, // ------------------------------------------------------------------ - // Peer delegation redemption relay + // OcapURL and homeSection // ------------------------------------------------------------------ - 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, - }); + /** + * 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); + }, - throw new Error( - `Unknown redemption request type: ${String(request.type)}`, - ); + /** + * 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; @@ -3165,82 +2322,38 @@ export function buildRootObject( ? 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) { + // Fetch grant count from delegator vat if available + let grantsCount = 0; + if (delegatorVat) { try { - const peerCaps = await raceWithTimeout( - E(peerWallet).getCapabilities(), - PEER_TIMEOUT_MS, - ); - signingMode = `peer:${peerCaps.signingMode ?? 'unknown'}`; - cachedPeerSigningMode = signingMode; - persistBaggage('cachedPeerSigningMode', cachedPeerSigningMode); + const grants = await E(delegatorVat).listGrants(); + grantsCount = grants.length; } catch (error) { - logger.warn('peer getCapabilities failed, using cache', error); - signingMode = cachedPeerSigningMode ?? 'peer:unknown'; + logger.warn('Failed to fetch grants from delegator vat', error); } - } else if (externalSigner) { + } + + // Resolve signing mode based on available authorities + let signingMode: string = 'none'; + 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. + // Determine autonomy level based on smart account and bundler config let autonomy: string; - const canRedeemLocally = + const canSendDirectly = 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'; + 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'; } @@ -3255,20 +2368,21 @@ export function buildRootObject( return harden({ hasLocalKeys, localAccounts, - delegationCount: activeDelegations.length, - delegations: delegationInfos, - hasPeerWallet: peerWallet !== undefined, + delegationCount: grantsCount, + delegations: undefined, + hasPeerWallet: false, hasExternalSigner: externalSigner !== undefined, hasBundlerConfig: bundlerConfig !== undefined, smartAccountAddress: smartAccountConfig?.address, chainId: capabilityChainId, signingMode, autonomy, - peerAccountsCached: cachedPeerAccounts.length > 0, - cachedPeerAccounts, - hasAwayWallet: awayWallet !== undefined, + peerAccountsCached: false, + cachedPeerAccounts: [], + hasAwayWallet: false, }); }, }); - return coordinator; + + return homeCoordinator; } 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..7303274c98 --- /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, + totalLimit: 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..f28e04e7b4 --- /dev/null +++ b/packages/evm-wallet-experiment/src/vats/redeemer-vat.ts @@ -0,0 +1,57 @@ +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); + +/** + * 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).map( + ([id, raw]) => [id, create(raw, DelegationGrantStruct)], + ), + ) + : 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()]); + }, + }); +} 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..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,76 @@ * 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 + * 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 +177,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 +196,9 @@ export function callVat( throw new Error(parsed); } + if (is(parsed, CapDataStruct)) { + return decodeCapDataForRpc(parsed); + } 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..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 @@ -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. totalLimit 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', + totalLimit: '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/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/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)); } /** 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..97108bf429 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,12 +325,92 @@ describe.sequential('Peer wallet integration', () => { chainId: undefined, signingMode: 'none', autonomy: 'no signing authority', - peerAccountsCached: false, - cachedPeerAccounts: [], - hasAwayWallet: false, }); }, 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, + ); + }); }); 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', );