From 1e909e3330add11c40cd6c15d8aaeb661a67a1a9 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Thu, 9 Apr 2026 17:04:46 -0400 Subject: [PATCH 01/31] fix(evm-wallet-experiment): wire build as prerequisite to docker:build Ensure workspace dist files are current via Turbo cache before image assembly, so stale build artifacts don't end up in the Docker image. --- packages/evm-wallet-experiment/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/evm-wallet-experiment/package.json b/packages/evm-wallet-experiment/package.json index 7036dd9234..be23fae9d3 100644 --- a/packages/evm-wallet-experiment/package.json +++ b/packages/evm-wallet-experiment/package.json @@ -58,7 +58,7 @@ "test:node:spending-limits": "yarn build && node --conditions development test/e2e/run-spending-limits-e2e.mjs", "docker:compose": "docker compose -f docker/docker-compose.yml --profile 7702 --profile 4337 --profile relay", "docker:compose:interactive": "node docker/run-interactive-compose.mjs", - "docker:build": "yarn docker:compose build", + "docker:build": "yarn workspace @ocap/monorepo build && yarn docker:compose build", "docker:build:force": "yarn docker:compose build --no-cache", "docker:ensure-logs": "mkdir -p logs", "docker:up": "yarn docker:ensure-logs && yarn docker:compose up", From c068d623b20a2af7bfb11fb0479484dab311ccb3 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Wed, 8 Apr 2026 15:48:18 -0400 Subject: [PATCH 02/31] fix(evm-wallet-experiment): serialize BigInt values in daemon-client JSON requests JSON.stringify drops BigInt fields silently; replace with a replacer that coerces BigInt to string so numeric values survive the wire. Co-Authored-By: Claude Sonnet 4.6 --- .../test/e2e/docker/helpers/daemon-client.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/evm-wallet-experiment/test/e2e/docker/helpers/daemon-client.mjs b/packages/evm-wallet-experiment/test/e2e/docker/helpers/daemon-client.mjs index 85e27b750e..0251252a35 100644 --- a/packages/evm-wallet-experiment/test/e2e/docker/helpers/daemon-client.mjs +++ b/packages/evm-wallet-experiment/test/e2e/docker/helpers/daemon-client.mjs @@ -26,7 +26,7 @@ async function rpc(socketPath, method, params) { method, ...(params === undefined ? {} : { params }), }; - await writeLine(socket, JSON.stringify(request)); + await writeLine(socket, JSON.stringify(request, (_k, v) => typeof v === 'bigint' ? String(v) : v)); const responseLine = await readLine(socket); return JSON.parse(responseLine); } finally { From 56bbb64595ebc8d0a1f367b03e6fc67e7014bf87 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Wed, 8 Apr 2026 15:47:11 -0400 Subject: [PATCH 03/31] fix(evm-wallet-experiment): use packed encoding for AllowedTargets, AllowedMethods, and ERC20TransferAmount These enforcers expect tightly-packed bytes, not ABI head/tail encoding. Switch encodeAllowedTargets/encodeAllowedMethods to encodePacked and encodeErc20TransferAmount likewise. Update explainDelegationMatch to decode packed terms the same way, and update the caveat encoding tests to assert packed layout directly. Co-Authored-By: Claude Sonnet 4.6 --- .../src/lib/caveats.test.ts | 35 ++++++------- .../evm-wallet-experiment/src/lib/caveats.ts | 21 +++++--- .../src/lib/delegation.ts | 51 ++++++++++++------- 3 files changed, 65 insertions(+), 42 deletions(-) diff --git a/packages/evm-wallet-experiment/src/lib/caveats.test.ts b/packages/evm-wallet-experiment/src/lib/caveats.test.ts index 2fa13d9489..a23bc9fe6e 100644 --- a/packages/evm-wallet-experiment/src/lib/caveats.test.ts +++ b/packages/evm-wallet-experiment/src/lib/caveats.test.ts @@ -21,11 +21,11 @@ describe('lib/caveats', () => { const encoded = encodeAllowedTargets([target]); expect(encoded).toMatch(/^0x/u); - const [decoded] = decodeAbiParameters( - parseAbiParameters('address[]'), - encoded, + // Packed: 20 bytes per address + expect(encoded).toHaveLength(2 + 40); + expect(encoded.slice(2).toLowerCase()).toBe( + target.slice(2).toLowerCase(), ); - expect(decoded.map((a) => a.toLowerCase())).toStrictEqual([target]); }); it('encodes multiple target addresses', () => { @@ -35,10 +35,12 @@ describe('lib/caveats', () => { ]; const encoded = encodeAllowedTargets(targets); - const [decoded] = decodeAbiParameters( - parseAbiParameters('address[]'), - encoded, - ); + // Packed: 20 bytes × 2 addresses + expect(encoded).toHaveLength(2 + 80); + const decoded = [ + `0x${encoded.slice(2, 42)}`, + `0x${encoded.slice(42, 82)}`, + ]; expect(decoded.map((a) => a.toLowerCase())).toStrictEqual(targets); }); }); @@ -49,11 +51,10 @@ describe('lib/caveats', () => { const encoded = encodeAllowedMethods(selectors); expect(encoded).toMatch(/^0x/u); - const [decoded] = decodeAbiParameters( - parseAbiParameters('bytes4[]'), - encoded, - ); - expect(decoded).toHaveLength(2); + // Packed: 4 bytes per selector + expect(encoded).toHaveLength(2 + 16); + expect(encoded.slice(2, 10).toLowerCase()).toBe('a9059cbb'); + expect(encoded.slice(10, 18).toLowerCase()).toBe('095ea7b3'); }); }); @@ -89,10 +90,10 @@ describe('lib/caveats', () => { const amount = 1000000n; // 1 USDC (6 decimals) const encoded = encodeErc20TransferAmount({ token, amount }); - const [decodedToken, decodedAmount] = decodeAbiParameters( - parseAbiParameters('address, uint256'), - encoded, - ); + // Packed: 20-byte address (40 hex chars) + 32-byte uint256 (64 hex chars) = 52 bytes + expect(encoded).toHaveLength(2 + 104); + const decodedToken = `0x${encoded.slice(2, 42)}`; + const decodedAmount = BigInt(`0x${encoded.slice(42, 106)}`); expect(decodedToken.toLowerCase()).toBe(token); expect(decodedAmount).toBe(amount); }); diff --git a/packages/evm-wallet-experiment/src/lib/caveats.ts b/packages/evm-wallet-experiment/src/lib/caveats.ts index 1f002a3eea..a3c26fedd9 100644 --- a/packages/evm-wallet-experiment/src/lib/caveats.ts +++ b/packages/evm-wallet-experiment/src/lib/caveats.ts @@ -1,4 +1,4 @@ -import { encodeAbiParameters, parseAbiParameters } from 'viem'; +import { encodeAbiParameters, encodePacked, parseAbiParameters } from 'viem'; import { getChainContracts } from '../constants.ts'; import type { Address, Caveat, CaveatType, Hex } from '../types.ts'; @@ -11,7 +11,11 @@ import type { Address, Caveat, CaveatType, Hex } from '../types.ts'; * @returns The ABI-encoded terms. */ export function encodeAllowedTargets(targets: Address[]): Hex { - return encodeAbiParameters(parseAbiParameters('address[]'), [targets]); + // AllowedTargetsEnforcer expects packed 20-byte addresses (not ABI-encoded). + return encodePacked( + targets.map(() => 'address' as const), + targets, + ); } /** @@ -22,7 +26,11 @@ export function encodeAllowedTargets(targets: Address[]): Hex { * @returns The ABI-encoded terms. */ export function encodeAllowedMethods(selectors: Hex[]): Hex { - return encodeAbiParameters(parseAbiParameters('bytes4[]'), [selectors]); + // AllowedMethodsEnforcer expects packed 4-byte selectors (not ABI-encoded). + return encodePacked( + selectors.map(() => 'bytes4' as const), + selectors, + ); } /** @@ -61,10 +69,9 @@ export function encodeErc20TransferAmount(options: { token: Address; amount: bigint; }): Hex { - return encodeAbiParameters(parseAbiParameters('address, uint256'), [ - options.token, - options.amount, - ]); + // ERC20TransferAmountEnforcer expects packed address (20 bytes) + uint256 + // (32 bytes) = 52 bytes total. + return encodePacked(['address', 'uint256'], [options.token, options.amount]); } /** diff --git a/packages/evm-wallet-experiment/src/lib/delegation.ts b/packages/evm-wallet-experiment/src/lib/delegation.ts index 56ead1f863..72fe088afe 100644 --- a/packages/evm-wallet-experiment/src/lib/delegation.ts +++ b/packages/evm-wallet-experiment/src/lib/delegation.ts @@ -59,11 +59,15 @@ let saltCounter = 0; * Generate a random salt for delegation uniqueness. * * Prefers crypto.getRandomValues when available. In SES compartments - * where crypto is not endowed, falls back to keccak256(counter). + * where crypto is not endowed, falls back to keccak256(entropy + counter) + * when entropy is provided, or keccak256(counter) otherwise. * + * @param entropy - Optional caller-supplied entropy hex string. When provided + * and crypto is unavailable, mixed into the counter hash so that separate + * vat instances (each with counter starting at 0) produce distinct salts. * @returns A hex-encoded random salt. */ -export function generateSalt(): Hex { +export function generateSalt(entropy?: Hex): Hex { // eslint-disable-next-line n/no-unsupported-features/node-builtins if (globalThis.crypto?.getRandomValues) { const bytes = new Uint8Array(32); @@ -72,10 +76,14 @@ export function generateSalt(): Hex { return toHex(bytes); } - // SES fallback: keccak256(counter). Unique per vat lifetime but not - // cryptographically random. The salt only needs uniqueness, not - // unpredictability. + // SES fallback: unique per vat lifetime but not cryptographically random. + // The salt only needs uniqueness, not unpredictability. saltCounter += 1; + if (entropy !== undefined) { + return keccak256( + encodePacked(['bytes', 'uint256'], [entropy, BigInt(saltCounter)]), + ); + } return keccak256(encodePacked(['uint256'], [BigInt(saltCounter)])); } @@ -88,6 +96,8 @@ export function generateSalt(): Hex { * @param options.caveats - The caveats restricting the delegation. * @param options.chainId - The chain ID. * @param options.salt - Optional salt (generated if omitted). + * @param options.entropy - Optional entropy hex string forwarded to + * {@link generateSalt} when no explicit salt is provided. * @param options.authority - Optional parent delegation hash (root if omitted). * @returns The unsigned Delegation struct. */ @@ -97,9 +107,10 @@ export function makeDelegation(options: { caveats: Caveat[]; chainId: number; salt?: Hex; + entropy?: Hex; authority?: Hex; }): Delegation { - const salt = options.salt ?? generateSalt(); + const salt = options.salt ?? generateSalt(options.entropy); const authority = options.authority ?? ROOT_AUTHORITY; const id = computeDelegationId({ @@ -192,10 +203,12 @@ export function explainDelegationMatch( // Check each caveat - all must pass for the delegation to match for (const caveat of delegation.caveats) { if (caveat.type === 'allowedTargets') { - const [targets] = decodeAbiParameters( - parseAbiParameters('address[]'), - caveat.terms, - ); + // Packed 20-byte addresses (40 hex chars each, after '0x' prefix). + const termsBody = caveat.terms.slice(2); + const targets: string[] = []; + for (let i = 0; i < termsBody.length; i += 40) { + targets.push(`0x${termsBody.slice(i, i + 40)}`); + } const match = targets.some( (target) => target.toLowerCase() === action.to.toLowerCase(), ); @@ -210,10 +223,12 @@ export function explainDelegationMatch( if (caveat.type === 'allowedMethods' && action.data) { const selector = action.data.slice(0, 10).toLowerCase() as Hex; - const [methods] = decodeAbiParameters( - parseAbiParameters('bytes4[]'), - caveat.terms, - ); + // Packed 4-byte selectors (8 hex chars each, after '0x' prefix). + const termsBody = caveat.terms.slice(2); + const methods: string[] = []; + for (let i = 0; i < termsBody.length; i += 8) { + methods.push(`0x${termsBody.slice(i, i + 8)}`); + } const match = methods.some((method) => method.toLowerCase() === selector); if (!match) { return { @@ -273,10 +288,10 @@ export function explainDelegationMatch( } if (caveat.type === 'erc20TransferAmount') { - const [token, maxAmount] = decodeAbiParameters( - parseAbiParameters('address, uint256'), - caveat.terms, - ); + // Packed: 20-byte address (40 hex chars) + 32-byte uint256 (64 hex chars). + const erc20Hex = caveat.terms.slice(2); + const token = `0x${erc20Hex.slice(0, 40)}`; + const maxAmount = BigInt(`0x${erc20Hex.slice(40, 104)}`); if (action.to.toLowerCase() !== token.toLowerCase()) { return { matches: false, From b63ef6d3ced5e39c8bb686fec5046d6828783514 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Wed, 8 Apr 2026 15:47:43 -0400 Subject: [PATCH 04/31] feat(evm-wallet-experiment): add runtime chain contract registration and enforcer key map Add registerChainContracts() / customChainContracts so devnet chains (e.g. Anvil at 31337) can be registered at runtime without touching the static CHAIN_CONTRACTS table. Add ENFORCER_CONTRACT_KEY_MAP to translate PascalCase enforcer keys in contracts.json to camelCase CaveatType names. Co-Authored-By: Claude Sonnet 4.6 --- .../evm-wallet-experiment/src/constants.ts | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/packages/evm-wallet-experiment/src/constants.ts b/packages/evm-wallet-experiment/src/constants.ts index dd3f66badb..b548b9d778 100644 --- a/packages/evm-wallet-experiment/src/constants.ts +++ b/packages/evm-wallet-experiment/src/constants.ts @@ -160,6 +160,39 @@ export const CHAIN_CONTRACTS: Readonly> = harden( }, ); +/** + * Maps the PascalCase enforcer contract keys from a deployed `contracts.json` + * environment to our camelCase `CaveatType` names. + */ +export const ENFORCER_CONTRACT_KEY_MAP: Readonly> = + harden({ + AllowedCalldataEnforcer: 'allowedCalldata', + AllowedMethodsEnforcer: 'allowedMethods', + AllowedTargetsEnforcer: 'allowedTargets', + ERC20TransferAmountEnforcer: 'erc20TransferAmount', + LimitedCallsEnforcer: 'limitedCalls', + TimestampEnforcer: 'timestamp', + ValueLteEnforcer: 'valueLte', + NativeTokenTransferAmountEnforcer: 'nativeTokenTransferAmount', + }); + +/** Dynamic registry for chains not listed in {@link CHAIN_CONTRACTS}. */ +const customChainContracts = new Map(); + +/** + * Register contract addresses for a chain at runtime. Used to add local + * devnet chains (e.g. Anvil at 31337) that are not in the static registry. + * + * @param chainId - The chain ID to register. + * @param contracts - The contract addresses for that chain. + */ +export function registerChainContracts( + chainId: number, + contracts: ChainContracts, +): void { + customChainContracts.set(chainId, contracts); +} + /** * Get the contract addresses for a chain, falling back to placeholders. * @@ -168,6 +201,10 @@ export const CHAIN_CONTRACTS: Readonly> = harden( */ export function getChainContracts(chainId?: number): ChainContracts { if (chainId !== undefined) { + const custom = customChainContracts.get(chainId); + if (custom !== undefined) { + return custom; + } const entry = CHAIN_CONTRACTS[chainId]; if (entry !== undefined) { return entry; From 088aad67f5da1a2969992cee713c0d93e3b47972 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Thu, 19 Mar 2026 10:08:43 -0400 Subject: [PATCH 05/31] feat(evm-wallet-experiment): add delegation twins Represent delegations as discoverable exo objects ("twins") so an agent can call E(twin).transfer(to, amount) instead of manually building Execution structs. Adds a method catalog, grant builder, twin factory with cumulative spend tracking, and wires them into the delegation and coordinator vats. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/evm-wallet-experiment/src/index.ts | 14 ++ .../src/lib/delegation-grant.test.ts | 155 ++++++++++++ .../src/lib/delegation-grant.ts | 213 +++++++++++++++++ .../src/lib/delegation-twin.test.ts | 220 ++++++++++++++++++ .../src/lib/delegation-twin.ts | 134 +++++++++++ .../src/lib/method-catalog.test.ts | 102 ++++++++ .../src/lib/method-catalog.ts | 91 ++++++++ packages/evm-wallet-experiment/src/types.ts | 33 +++ .../src/vats/coordinator-vat.ts | 111 +++++++++ .../src/vats/delegation-vat.ts | 30 +++ 10 files changed, 1103 insertions(+) create mode 100644 packages/evm-wallet-experiment/src/lib/delegation-grant.test.ts create mode 100644 packages/evm-wallet-experiment/src/lib/delegation-grant.ts create mode 100644 packages/evm-wallet-experiment/src/lib/delegation-twin.test.ts create mode 100644 packages/evm-wallet-experiment/src/lib/delegation-twin.ts create mode 100644 packages/evm-wallet-experiment/src/lib/method-catalog.test.ts create mode 100644 packages/evm-wallet-experiment/src/lib/method-catalog.ts diff --git a/packages/evm-wallet-experiment/src/index.ts b/packages/evm-wallet-experiment/src/index.ts index d5f78d5771..62e4ab1b8f 100644 --- a/packages/evm-wallet-experiment/src/index.ts +++ b/packages/evm-wallet-experiment/src/index.ts @@ -27,10 +27,12 @@ export type { Address, Action, Caveat, + CaveatSpec, CaveatType, ChainConfig, CreateDelegationOptions, Delegation, + DelegationGrant, DelegationMatchResult, DelegationStatus, Eip712Domain, @@ -48,10 +50,12 @@ export type { export { ActionStruct, + CaveatSpecStruct, CaveatStruct, CaveatTypeValues, ChainConfigStruct, CreateDelegationOptionsStruct, + DelegationGrantStruct, DelegationStatusValues, DelegationStruct, Eip712DomainStruct, @@ -170,3 +174,13 @@ export type { MetaMaskSigner, MetaMaskSignerOptions, } from './lib/metamask-signer.ts'; + +// Method catalog +export { METHOD_CATALOG } from './lib/method-catalog.ts'; +export type { CatalogMethodName } from './lib/method-catalog.ts'; + +// Grant builder +export { buildDelegationGrant } 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 new file mode 100644 index 0000000000..e33f508c64 --- /dev/null +++ b/packages/evm-wallet-experiment/src/lib/delegation-grant.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, it } from 'vitest'; + +import type { Address } from '../types.ts'; +import { buildDelegationGrant } from './delegation-grant.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'); + }); + }); +}); diff --git a/packages/evm-wallet-experiment/src/lib/delegation-grant.ts b/packages/evm-wallet-experiment/src/lib/delegation-grant.ts new file mode 100644 index 0000000000..20993c1025 --- /dev/null +++ b/packages/evm-wallet-experiment/src/lib/delegation-grant.ts @@ -0,0 +1,213 @@ +import { + encodeAllowedMethods, + encodeAllowedTargets, + encodeErc20TransferAmount, + encodeTimestamp, + encodeValueLte, + makeCaveat, +} from './caveats.ts'; +import { makeDelegation } from './delegation.ts'; +import { ERC20_APPROVE_SELECTOR, ERC20_TRANSFER_SELECTOR } from './erc20.ts'; +import type { Address, Caveat, CaveatSpec, DelegationGrant } from '../types.ts'; + +const harden = globalThis.harden ?? ((value: T): T => value); + +type TransferOptions = { + delegator: Address; + delegate: Address; + token: Address; + max: bigint; + chainId: number; + validUntil?: number; +}; + +type ApproveOptions = { + delegator: Address; + delegate: Address; + token: Address; + max: bigint; + chainId: number; + validUntil?: number; +}; + +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. + * + * @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 { + switch (method) { + case 'transfer': + return buildTransferGrant(options as TransferOptions); + case 'approve': + return buildApproveGrant(options as ApproveOptions); + case 'call': + return buildCallGrant(options as CallOptions); + default: + throw new Error(`Unknown method: ${String(method)}`); + } +} + +/** + * Build a transfer delegation grant. + * + * @param options - Transfer grant options. + * @returns An unsigned DelegationGrant for ERC-20 transfers. + */ +function buildTransferGrant(options: TransferOptions): DelegationGrant { + const { delegator, delegate, token, max, chainId, validUntil } = options; + const caveats: Caveat[] = [ + makeCaveat({ + type: 'allowedTargets', + terms: encodeAllowedTargets([token]), + chainId, + }), + makeCaveat({ + type: 'allowedMethods', + terms: encodeAllowedMethods([ERC20_TRANSFER_SELECTOR]), + chainId, + }), + makeCaveat({ + type: 'erc20TransferAmount', + terms: encodeErc20TransferAmount({ token, amount: max }), + chainId, + }), + ]; + + const caveatSpecs: CaveatSpec[] = [{ type: 'cumulativeSpend', token, max }]; + + 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 }); + + return harden({ delegation, methodName: 'transfer', caveatSpecs, token }); +} + +/** + * Build an approve delegation grant. + * + * @param options - Approve grant options. + * @returns An unsigned DelegationGrant for ERC-20 approvals. + */ +function buildApproveGrant(options: ApproveOptions): DelegationGrant { + const { delegator, delegate, token, max, chainId, validUntil } = options; + const caveats: Caveat[] = [ + makeCaveat({ + type: 'allowedTargets', + terms: encodeAllowedTargets([token]), + chainId, + }), + makeCaveat({ + type: 'allowedMethods', + terms: encodeAllowedMethods([ERC20_APPROVE_SELECTOR]), + chainId, + }), + makeCaveat({ + type: 'erc20TransferAmount', + terms: encodeErc20TransferAmount({ token, amount: max }), + chainId, + }), + ]; + + const caveatSpecs: CaveatSpec[] = [{ type: 'cumulativeSpend', token, max }]; + + 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 }); + + return harden({ delegation, methodName: 'approve', caveatSpecs, token }); +} + +/** + * Build a raw call delegation grant. + * + * @param options - Call grant options. + * @returns An unsigned DelegationGrant for raw calls. + */ +function buildCallGrant(options: CallOptions): DelegationGrant { + const { delegator, delegate, targets, chainId, maxValue, validUntil } = + options; + const caveats: Caveat[] = [ + makeCaveat({ + type: 'allowedTargets', + terms: encodeAllowedTargets(targets), + chainId, + }), + ]; + + if (maxValue !== undefined) { + caveats.push( + makeCaveat({ + type: 'valueLte', + terms: encodeValueLte(maxValue), + chainId, + }), + ); + } + + if (validUntil !== undefined) { + caveats.push( + makeCaveat({ + type: 'timestamp', + terms: encodeTimestamp({ after: 0, before: validUntil }), + chainId, + }), + ); + } + + const delegation = makeDelegation({ delegator, delegate, caveats, chainId }); + + 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 new file mode 100644 index 0000000000..60695f1d93 --- /dev/null +++ b/packages/evm-wallet-experiment/src/lib/delegation-twin.test.ts @@ -0,0 +1,220 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { Address, DelegationGrant, Execution, Hex } from '../types.ts'; +import { makeDelegationTwin } from './delegation-twin.ts'; +import { encodeBalanceOf } from './erc20.ts'; + +vi.mock('@metamask/kernel-utils/discoverable', () => ({ + makeDiscoverableExo: ( + _name: string, + methods: Record unknown>, + methodSchema: Record, + ) => ({ + ...methods, + __getDescription__: () => methodSchema, + }), +})); + +const ALICE = '0x1111111111111111111111111111111111111111' as Address; +const BOB = '0x2222222222222222222222222222222222222222' as Address; +const TOKEN = '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as Address; +const TX_HASH = + '0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef' as Hex; + +function makeTransferGrant(max: bigint): DelegationGrant { + 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, + }; +} + +function makeCallGrant(): DelegationGrant { + return { + delegation: { + id: '0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890', + delegator: ALICE, + delegate: BOB, + authority: + '0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff' as Hex, + caveats: [], + salt: '0x01' as Hex, + chainId: 11155111, + status: 'signed', + }, + methodName: 'call', + caveatSpecs: [], + }; +} + +describe('makeDelegationTwin', () => { + describe('transfer twin', () => { + it('exposes transfer method', () => { + const redeemFn = vi.fn().mockResolvedValue(TX_HASH); + const twin = makeDelegationTwin({ + grant: makeTransferGrant(10000n), + redeemFn, + }) as Record; + expect(twin).toHaveProperty('transfer'); + expect(typeof twin.transfer).toBe('function'); + }); + + it('builds correct Execution and calls redeemFn', async () => { + const redeemFn = vi.fn().mockResolvedValue(TX_HASH); + const twin = makeDelegationTwin({ + grant: makeTransferGrant(10000n), + redeemFn, + }) as Record Promise>; + + const result = await twin.transfer(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'); + }); + + it('returns tx hash from redeemFn', async () => { + const redeemFn = vi.fn().mockResolvedValue(TX_HASH); + const twin = makeDelegationTwin({ + grant: makeTransferGrant(10000n), + redeemFn, + }) as Record Promise>; + + const hash = await twin.transfer(BOB, 50n); + expect(hash).toBe(TX_HASH); + }); + + it('tracks cumulative spend across calls', async () => { + const redeemFn = vi.fn().mockResolvedValue(TX_HASH); + const twin = makeDelegationTwin({ + grant: makeTransferGrant(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, + ); + }); + + it('rejects call when budget exhausted', async () => { + const redeemFn = vi.fn().mockResolvedValue(TX_HASH); + const twin = makeDelegationTwin({ + grant: makeTransferGrant(100n), + redeemFn, + }) as Record Promise>; + + await twin.transfer(BOB, 100n); + await expect(twin.transfer(BOB, 1n)).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), + redeemFn, + }) as Record Promise>; + + await expect(twin.transfer(BOB, 500n)).rejects.toThrow('tx reverted'); + redeemFn.mockResolvedValue(TX_HASH); + const result = await twin.transfer(BOB, 1000n); + expect(result).toBe(TX_HASH); + }); + }); + + describe('discoverability', () => { + it('returns method schemas from __getDescription__', () => { + const redeemFn = vi.fn().mockResolvedValue(TX_HASH); + const twin = makeDelegationTwin({ + grant: makeTransferGrant(1000n), + redeemFn, + }) as Record; + + const desc = (twin.__getDescription__ as () => Record)(); + expect(desc).toHaveProperty('transfer'); + expect( + (desc.transfer as Record).description, + ).toBeDefined(); + }); + }); + + describe('getBalance', () => { + it('is present when readFn provided', () => { + const redeemFn = vi.fn().mockResolvedValue(TX_HASH); + const readFn = vi + .fn() + .mockResolvedValue( + '0x00000000000000000000000000000000000000000000000000000000000f4240' as Hex, + ); + const twin = makeDelegationTwin({ + grant: makeTransferGrant(1000n), + redeemFn, + readFn, + }) as Record; + expect(twin).toHaveProperty('getBalance'); + }); + + 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'); + }); + + it('calls readFn with correct args and decodes result', async () => { + const redeemFn = vi.fn().mockResolvedValue(TX_HASH); + const readFn = vi + .fn() + .mockResolvedValue( + '0x00000000000000000000000000000000000000000000000000000000000f4240' as Hex, + ); + const twin = makeDelegationTwin({ + grant: makeTransferGrant(1000n), + redeemFn, + readFn, + }) as Record Promise>; + + const balance = await twin.getBalance(); + expect(balance).toBe(1000000n); + expect(readFn).toHaveBeenCalledWith({ + to: TOKEN, + data: encodeBalanceOf(BOB), + }); + }); + }); + + describe('call twin', () => { + it('builds raw execution from args', async () => { + const redeemFn = vi.fn().mockResolvedValue(TX_HASH); + const target = '0x3333333333333333333333333333333333333333' as Address; + const twin = makeDelegationTwin({ + grant: makeCallGrant(), + redeemFn, + }) as Record Promise>; + + await twin.call(target, 0n, '0xdeadbeef' as Hex); + expect(redeemFn).toHaveBeenCalledOnce(); + const execution = redeemFn.mock.calls[0]?.[0] as Execution; + expect(execution.target).toBe(target); + expect(execution.callData).toBe('0xdeadbeef'); + }); + }); +}); diff --git a/packages/evm-wallet-experiment/src/lib/delegation-twin.ts b/packages/evm-wallet-experiment/src/lib/delegation-twin.ts new file mode 100644 index 0000000000..2099a6cca4 --- /dev/null +++ b/packages/evm-wallet-experiment/src/lib/delegation-twin.ts @@ -0,0 +1,134 @@ +import type { MethodSchema } from '@metamask/kernel-utils'; +import { makeDiscoverableExo } from '@metamask/kernel-utils/discoverable'; + +import { decodeBalanceOfResult, encodeBalanceOf } from './erc20.ts'; +import { GET_BALANCE_SCHEMA, METHOD_CATALOG } from './method-catalog.ts'; +import type { + Address, + CaveatSpec, + DelegationGrant, + Execution, + Hex, +} from '../types.ts'; + +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; +} + +type DelegationTwinOptions = { + grant: DelegationGrant; + redeemFn: (execution: Execution) => Promise; + readFn?: (opts: { to: Address; data: Hex }) => Promise; +}; + +/** + * Create a discoverable exo twin for a delegation grant. + * + * @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. + */ +export function makeDelegationTwin( + options: DelegationTwinOptions, +): ReturnType { + const { grant, redeemFn, readFn } = options; + const { methodName, caveatSpecs, delegation } = grant; + + const entry = METHOD_CATALOG[methodName as keyof typeof METHOD_CATALOG]; + if (!entry) { + throw new Error(`Unknown method in grant: ${methodName}`); + } + + const tracker = findSpendTracker(caveatSpecs); + const { token } = grant; + const idPrefix = delegation.id.slice(0, 12); + + const primaryMethod = async (...args: unknown[]): Promise => { + let trackAmount: bigint | undefined; + if (tracker) { + trackAmount = args[1] as bigint; + if (trackAmount > tracker.remaining()) { + throw new Error( + `Insufficient budget: requested ${trackAmount}, remaining ${tracker.remaining()}`, + ); + } + } + + const execution = entry.buildExecution(token ?? ('' as Address), args); + + const txHash = await redeemFn(execution); + if (tracker && trackAmount !== undefined) { + tracker.commit(trackAmount); + } + return txHash; + }; + + const methods: Record unknown> = { + [methodName]: primaryMethod, + }; + const schema: Record = { + [methodName]: entry.schema, + }; + + if (readFn && token) { + methods.getBalance = async (): Promise => { + const result = await readFn({ + to: token, + data: encodeBalanceOf(delegation.delegate), + }); + return decodeBalanceOfResult(result); + }; + schema.getBalance = GET_BALANCE_SCHEMA; + } + + return makeDiscoverableExo( + `DelegationTwin:${methodName}:${idPrefix}`, + methods, + schema, + ); +} diff --git a/packages/evm-wallet-experiment/src/lib/method-catalog.test.ts b/packages/evm-wallet-experiment/src/lib/method-catalog.test.ts new file mode 100644 index 0000000000..5ba75e8049 --- /dev/null +++ b/packages/evm-wallet-experiment/src/lib/method-catalog.test.ts @@ -0,0 +1,102 @@ +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; + +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(); + }); + }); + + 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)); + }); + + 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'); + }); + }); + + 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); + }); + + 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(); + }); + }); +}); diff --git a/packages/evm-wallet-experiment/src/lib/method-catalog.ts b/packages/evm-wallet-experiment/src/lib/method-catalog.ts new file mode 100644 index 0000000000..1c930be818 --- /dev/null +++ b/packages/evm-wallet-experiment/src/lib/method-catalog.ts @@ -0,0 +1,91 @@ +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).', + }, + }, + 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).', + }, + }, + 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.' }, + }, + }, +}); + +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).' }, +}); diff --git a/packages/evm-wallet-experiment/src/types.ts b/packages/evm-wallet-experiment/src/types.ts index 3e313cb5cc..ea90776e49 100644 --- a/packages/evm-wallet-experiment/src/types.ts +++ b/packages/evm-wallet-experiment/src/types.ts @@ -322,6 +322,39 @@ export type DelegationMatchResult = { reason?: string; }; +// --------------------------------------------------------------------------- +// Delegation grant (twin construction input) +// --------------------------------------------------------------------------- + +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, + }), +]); + +export type CaveatSpec = Infer; + +export const DelegationGrantStruct = object({ + delegation: DelegationStruct, + methodName: string(), + caveatSpecs: array(CaveatSpecStruct), + token: optional(AddressStruct), +}); + +export type DelegationGrant = Infer; + // --------------------------------------------------------------------------- // Swap types (MetaSwap API) // --------------------------------------------------------------------------- diff --git a/packages/evm-wallet-experiment/src/vats/coordinator-vat.ts b/packages/evm-wallet-experiment/src/vats/coordinator-vat.ts index e30ea984bb..9edddbb9b0 100644 --- a/packages/evm-wallet-experiment/src/vats/coordinator-vat.ts +++ b/packages/evm-wallet-experiment/src/vats/coordinator-vat.ts @@ -3,6 +3,7 @@ import { makeDefaultExo } from '@metamask/kernel-utils/exo'; import { Logger } from '@metamask/logger'; import type { Baggage } from '@metamask/ocap-kernel'; +import { buildDelegationGrant } from '../lib/delegation-grant.ts'; import { decodeAllowanceResult, decodeBalanceOfResult, @@ -16,6 +17,7 @@ import { encodeSymbol, encodeTransfer, } from '../lib/erc20.ts'; +import type { CatalogMethodName } from '../lib/method-catalog.ts'; import { buildBatchExecuteCallData, buildSdkBatchRedeemCallData, @@ -36,6 +38,7 @@ import type { ChainConfig, CreateDelegationOptions, Delegation, + DelegationGrant, DelegationMatchResult, Eip712TypedData, Execution, @@ -2230,6 +2233,114 @@ export function buildRootObject( 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()); + + const grant = buildDelegationGrant(method, { + delegator, + ...options, + } 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'); + } + + const { delegation } = grant; + + // 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; + }; + } + + return E(delegationVat).addDelegation(grant, redeemFn, readFn); + }, + // ------------------------------------------------------------------ // Delegation redemption (ERC-4337) // ------------------------------------------------------------------ diff --git a/packages/evm-wallet-experiment/src/vats/delegation-vat.ts b/packages/evm-wallet-experiment/src/vats/delegation-vat.ts index 0d71a43bd7..f5d45b3b68 100644 --- a/packages/evm-wallet-experiment/src/vats/delegation-vat.ts +++ b/packages/evm-wallet-experiment/src/vats/delegation-vat.ts @@ -3,6 +3,7 @@ import type { Logger } from '@metamask/logger'; import type { Baggage } from '@metamask/ocap-kernel'; import { DEFAULT_DELEGATION_MANAGER } from '../constants.ts'; +import { makeDelegationTwin } from '../lib/delegation-twin.ts'; import { computeDelegationId, makeDelegation, @@ -16,8 +17,10 @@ import type { Address, CreateDelegationOptions, Delegation, + DelegationGrant, DelegationMatchResult, Eip712TypedData, + Execution, Hex, } from '../types.ts'; @@ -59,6 +62,9 @@ export function buildRootObject( ) : new Map(); + // Twin exo references, keyed by delegation ID + const twins: Map> = new Map(); + /** * Persist the current delegations map to baggage. */ @@ -200,5 +206,29 @@ export function buildRootObject( ); persistDelegations(); }, + + async addDelegation( + grant: DelegationGrant, + redeemFn: (execution: Execution) => Promise, + readFn?: (opts: { to: Address; data: Hex }) => Promise, + ): Promise> { + const { delegation } = grant; + delegations.set(delegation.id, delegation); + persistDelegations(); + + const twin = makeDelegationTwin({ grant, redeemFn, readFn }); + twins.set(delegation.id, twin); + return twin; + }, + + async getTwin( + delegationId: string, + ): Promise> { + const twin = twins.get(delegationId); + if (!twin) { + throw new Error(`Twin not found for delegation: ${delegationId}`); + } + return twin; + }, }); } From 501770bb7a8923472297e6d498de86518664f9a0 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Thu, 19 Mar 2026 11:18:41 -0400 Subject: [PATCH 06/31] feat(evm-wallet-experiment): thread InterfaceGuards into delegation twins Build typed method guards from METHOD_CATALOG entries and pass them to makeDiscoverableExo, enabling arg-count/type validation at the exo boundary. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/evm-wallet-experiment/package.json | 1 + .../src/lib/delegation-twin.test.ts | 68 +++++++++++++++++-- .../src/lib/delegation-twin.ts | 23 +++++++ yarn.lock | 1 + 4 files changed, 89 insertions(+), 4 deletions(-) diff --git a/packages/evm-wallet-experiment/package.json b/packages/evm-wallet-experiment/package.json index be23fae9d3..a2e30d5bbe 100644 --- a/packages/evm-wallet-experiment/package.json +++ b/packages/evm-wallet-experiment/package.json @@ -74,6 +74,7 @@ }, "dependencies": { "@endo/eventual-send": "^1.3.4", + "@endo/patterns": "^1.7.0", "@metamask/kernel-utils": "workspace:^", "@metamask/logger": "workspace:^", "@metamask/ocap-kernel": "workspace:^", 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 60695f1d93..bca9e61e77 100644 --- a/packages/evm-wallet-experiment/src/lib/delegation-twin.test.ts +++ b/packages/evm-wallet-experiment/src/lib/delegation-twin.test.ts @@ -4,15 +4,21 @@ import type { Address, DelegationGrant, Execution, Hex } from '../types.ts'; import { makeDelegationTwin } from './delegation-twin.ts'; import { encodeBalanceOf } from './erc20.ts'; +let lastInterfaceGuard: unknown; + vi.mock('@metamask/kernel-utils/discoverable', () => ({ makeDiscoverableExo: ( _name: string, methods: Record unknown>, methodSchema: Record, - ) => ({ - ...methods, - __getDescription__: () => methodSchema, - }), + interfaceGuard?: unknown, + ) => { + lastInterfaceGuard = interfaceGuard; + return { + ...methods, + __getDescription__: () => methodSchema, + }; + }, })); const ALICE = '0x1111111111111111111111111111111111111111' as Address; @@ -217,4 +223,58 @@ describe('makeDelegationTwin', () => { expect(execution.callData).toBe('0xdeadbeef'); }); }); + + describe('interfaceGuard', () => { + it('passes an InterfaceGuard to makeDiscoverableExo', () => { + const redeemFn = vi.fn().mockResolvedValue(TX_HASH); + makeDelegationTwin({ + grant: makeTransferGrant(1000n), + redeemFn, + }); + expect(lastInterfaceGuard).toBeDefined(); + }); + + it('guard covers the primary method', () => { + const redeemFn = vi.fn().mockResolvedValue(TX_HASH); + makeDelegationTwin({ + grant: makeTransferGrant(1000n), + redeemFn, + }); + const guard = lastInterfaceGuard as { + payload: { methodGuards: Record }; + }; + expect(guard.payload.methodGuards).toHaveProperty('transfer'); + }); + + it('guard includes getBalance when readFn provided', () => { + const redeemFn = vi.fn().mockResolvedValue(TX_HASH); + const readFn = vi + .fn() + .mockResolvedValue( + '0x00000000000000000000000000000000000000000000000000000000000f4240' as Hex, + ); + 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'); + }); + }); }); diff --git a/packages/evm-wallet-experiment/src/lib/delegation-twin.ts b/packages/evm-wallet-experiment/src/lib/delegation-twin.ts index 2099a6cca4..ac3456352e 100644 --- a/packages/evm-wallet-experiment/src/lib/delegation-twin.ts +++ b/packages/evm-wallet-experiment/src/lib/delegation-twin.ts @@ -1,8 +1,10 @@ +import { M } from '@endo/patterns'; import type { MethodSchema } from '@metamask/kernel-utils'; import { makeDiscoverableExo } from '@metamask/kernel-utils/discoverable'; import { decodeBalanceOfResult, encodeBalanceOf } from './erc20.ts'; import { GET_BALANCE_SCHEMA, METHOD_CATALOG } from './method-catalog.ts'; +import type { CatalogMethodName } from './method-catalog.ts'; import type { Address, CaveatSpec, @@ -11,6 +13,15 @@ import type { Hex, } from '../types.ts'; +const METHOD_GUARDS: Record< + CatalogMethodName, + ReturnType +> = { + transfer: M.callWhen(M.string(), M.scalar()).returns(M.any()), + approve: M.callWhen(M.string(), M.scalar()).returns(M.any()), + call: M.callWhen(M.string(), M.scalar(), M.string()).returns(M.any()), +}; + type SpendTracker = { spent: bigint; max: bigint; @@ -115,6 +126,10 @@ export function makeDelegationTwin( [methodName]: entry.schema, }; + const methodGuards: Record> = { + [methodName]: METHOD_GUARDS[methodName as CatalogMethodName], + }; + if (readFn && token) { methods.getBalance = async (): Promise => { const result = await readFn({ @@ -124,11 +139,19 @@ export function makeDelegationTwin( return decodeBalanceOfResult(result); }; schema.getBalance = GET_BALANCE_SCHEMA; + methodGuards.getBalance = M.callWhen().returns(M.any()); } + const interfaceGuard = M.interface( + `DelegationTwin:${methodName}:${idPrefix}`, + methodGuards, + { defaultGuards: 'passable' }, + ); + return makeDiscoverableExo( `DelegationTwin:${methodName}:${idPrefix}`, methods, schema, + interfaceGuard, ); } diff --git a/yarn.lock b/yarn.lock index 9dc8547bee..d9d7a64d34 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3587,6 +3587,7 @@ __metadata: dependencies: "@arethetypeswrong/cli": "npm:^0.17.4" "@endo/eventual-send": "npm:^1.3.4" + "@endo/patterns": "npm:^1.7.0" "@metamask/auto-changelog": "npm:^5.3.0" "@metamask/eslint-config": "npm:^15.0.0" "@metamask/eslint-config-nodejs": "npm:^15.0.0" From 123e86fc3447f676b297a9dcb282fa139a0de4c1 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Wed, 8 Apr 2026 15:47:33 -0400 Subject: [PATCH 07/31] feat(evm-wallet-experiment): add valueLte caveat spec and normalize twin method args Add valueLte to CaveatSpecStruct so call twins can carry a per-call ETH value limit that is checked locally before submission. In makeDelegationTwin, normalize args[1] to BigInt on entry (required when values arrive as hex strings over the daemon JSON-RPC boundary), apply the valueLte check, and pass normalizedArgs to buildExecution. Co-Authored-By: Claude Sonnet 4.6 --- .../src/lib/delegation-twin.ts | 29 +++++++++++++++++-- packages/evm-wallet-experiment/src/types.ts | 4 +++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/packages/evm-wallet-experiment/src/lib/delegation-twin.ts b/packages/evm-wallet-experiment/src/lib/delegation-twin.ts index ac3456352e..95b78fe22d 100644 --- a/packages/evm-wallet-experiment/src/lib/delegation-twin.ts +++ b/packages/evm-wallet-experiment/src/lib/delegation-twin.ts @@ -96,13 +96,35 @@ export function makeDelegationTwin( } const tracker = findSpendTracker(caveatSpecs); + const valueLteSpec = caveatSpecs.find( + (cs): cs is CaveatSpec & { type: 'valueLte' } => cs.type === 'valueLte', + ); const { token } = 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 = args[1] as bigint; + trackAmount = normalizedArgs[1] as bigint; if (trackAmount > tracker.remaining()) { throw new Error( `Insufficient budget: requested ${trackAmount}, remaining ${tracker.remaining()}`, @@ -110,7 +132,10 @@ export function makeDelegationTwin( } } - const execution = entry.buildExecution(token ?? ('' as Address), args); + const execution = entry.buildExecution( + token ?? ('' as Address), + normalizedArgs, + ); const txHash = await redeemFn(execution); if (tracker && trackAmount !== undefined) { diff --git a/packages/evm-wallet-experiment/src/types.ts b/packages/evm-wallet-experiment/src/types.ts index ea90776e49..1122684f92 100644 --- a/packages/evm-wallet-experiment/src/types.ts +++ b/packages/evm-wallet-experiment/src/types.ts @@ -342,6 +342,10 @@ export const CaveatSpecStruct = union([ after: BigIntStruct, before: BigIntStruct, }), + object({ + type: literal('valueLte'), + max: BigIntStruct, + }), ]); export type CaveatSpec = Infer; From 087a00021c9e3e586e112fd0677ce92796a15f0a Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Wed, 8 Apr 2026 15:47:21 -0400 Subject: [PATCH 08/31] feat(evm-wallet-experiment): add entropy parameter to delegation salt generation generateSalt accepts an optional entropy hex string that is mixed into the counter hash when crypto.getRandomValues is unavailable. Thread the same entropy option through makeDelegation and all three buildDelegationGrant overloads (transfer, approve, call) so callers can supply per-run entropy to avoid salt collisions across fresh vat instances. Co-Authored-By: Claude Sonnet 4.6 --- .../src/lib/delegation-grant.ts | 57 ++++++++++++++++--- 1 file changed, 48 insertions(+), 9 deletions(-) diff --git a/packages/evm-wallet-experiment/src/lib/delegation-grant.ts b/packages/evm-wallet-experiment/src/lib/delegation-grant.ts index 20993c1025..71a73e5e95 100644 --- a/packages/evm-wallet-experiment/src/lib/delegation-grant.ts +++ b/packages/evm-wallet-experiment/src/lib/delegation-grant.ts @@ -8,7 +8,13 @@ import { } from './caveats.ts'; import { makeDelegation } from './delegation.ts'; import { ERC20_APPROVE_SELECTOR, ERC20_TRANSFER_SELECTOR } from './erc20.ts'; -import type { Address, Caveat, CaveatSpec, DelegationGrant } from '../types.ts'; +import type { + Address, + Caveat, + CaveatSpec, + DelegationGrant, + Hex, +} from '../types.ts'; const harden = globalThis.harden ?? ((value: T): T => value); @@ -19,6 +25,7 @@ type TransferOptions = { max: bigint; chainId: number; validUntil?: number; + entropy?: Hex; }; type ApproveOptions = { @@ -28,6 +35,7 @@ type ApproveOptions = { max: bigint; chainId: number; validUntil?: number; + entropy?: Hex; }; type CallOptions = { @@ -37,6 +45,7 @@ type CallOptions = { chainId: number; maxValue?: bigint; validUntil?: number; + entropy?: Hex; }; export function buildDelegationGrant( @@ -81,7 +90,8 @@ export function buildDelegationGrant( * @returns An unsigned DelegationGrant for ERC-20 transfers. */ function buildTransferGrant(options: TransferOptions): DelegationGrant { - const { delegator, delegate, token, max, chainId, validUntil } = options; + const { delegator, delegate, token, max, chainId, validUntil, entropy } = + options; const caveats: Caveat[] = [ makeCaveat({ type: 'allowedTargets', @@ -117,7 +127,13 @@ function buildTransferGrant(options: TransferOptions): DelegationGrant { }); } - const delegation = makeDelegation({ delegator, delegate, caveats, chainId }); + const delegation = makeDelegation({ + delegator, + delegate, + caveats, + chainId, + entropy, + }); return harden({ delegation, methodName: 'transfer', caveatSpecs, token }); } @@ -129,7 +145,8 @@ function buildTransferGrant(options: TransferOptions): DelegationGrant { * @returns An unsigned DelegationGrant for ERC-20 approvals. */ function buildApproveGrant(options: ApproveOptions): DelegationGrant { - const { delegator, delegate, token, max, chainId, validUntil } = options; + const { delegator, delegate, token, max, chainId, validUntil, entropy } = + options; const caveats: Caveat[] = [ makeCaveat({ type: 'allowedTargets', @@ -165,7 +182,13 @@ function buildApproveGrant(options: ApproveOptions): DelegationGrant { }); } - const delegation = makeDelegation({ delegator, delegate, caveats, chainId }); + const delegation = makeDelegation({ + delegator, + delegate, + caveats, + chainId, + entropy, + }); return harden({ delegation, methodName: 'approve', caveatSpecs, token }); } @@ -177,8 +200,15 @@ function buildApproveGrant(options: ApproveOptions): DelegationGrant { * @returns An unsigned DelegationGrant for raw calls. */ function buildCallGrant(options: CallOptions): DelegationGrant { - const { delegator, delegate, targets, chainId, maxValue, validUntil } = - options; + const { + delegator, + delegate, + targets, + chainId, + maxValue, + validUntil, + entropy, + } = options; const caveats: Caveat[] = [ makeCaveat({ type: 'allowedTargets', @@ -187,6 +217,8 @@ function buildCallGrant(options: CallOptions): DelegationGrant { }), ]; + const caveatSpecs: CaveatSpec[] = []; + if (maxValue !== undefined) { caveats.push( makeCaveat({ @@ -195,6 +227,7 @@ function buildCallGrant(options: CallOptions): DelegationGrant { chainId, }), ); + caveatSpecs.push({ type: 'valueLte', max: maxValue }); } if (validUntil !== undefined) { @@ -207,7 +240,13 @@ function buildCallGrant(options: CallOptions): DelegationGrant { ); } - const delegation = makeDelegation({ delegator, delegate, caveats, chainId }); + const delegation = makeDelegation({ + delegator, + delegate, + caveats, + chainId, + entropy, + }); - return harden({ delegation, methodName: 'call', caveatSpecs: [] }); + return harden({ delegation, methodName: 'call', caveatSpecs }); } From 5459c9a55dfd6677654d121c7e5238546ec6ff00 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Thu, 19 Mar 2026 11:22:37 -0400 Subject: [PATCH 09/31] feat(evm-wallet-experiment): restrict address arg via AllowedCalldata caveat Integrate with the delegation framework's AllowedCalldataEnforcer to pin the first argument (recipient/spender) of transfer/approve at both the on-chain enforcer level and the local exo interface guard. - Add `allowedCalldata` to CaveatTypeValues and CaveatSpec - Add `encodeAllowedCalldata` helper and deployed enforcer address - Wire optional `recipient`/`spender` through grant builders - Twin derives address restriction from caveatSpecs, not a standalone field Co-Authored-By: Claude Opus 4.6 (1M context) --- .../evm-wallet-experiment/src/constants.ts | 2 + packages/evm-wallet-experiment/src/index.ts | 1 + .../src/lib/caveats.test.ts | 16 ++++ .../evm-wallet-experiment/src/lib/caveats.ts | 19 +++++ .../src/lib/delegation-grant.ts | 75 ++++++++++++++++++- .../src/lib/delegation-twin.test.ts | 51 +++++++++++++ .../src/lib/delegation-twin.ts | 73 +++++++++++++++--- packages/evm-wallet-experiment/src/types.ts | 6 ++ 8 files changed, 230 insertions(+), 13 deletions(-) diff --git a/packages/evm-wallet-experiment/src/constants.ts b/packages/evm-wallet-experiment/src/constants.ts index b548b9d778..19ce7e35bf 100644 --- a/packages/evm-wallet-experiment/src/constants.ts +++ b/packages/evm-wallet-experiment/src/constants.ts @@ -77,6 +77,7 @@ export const PLACEHOLDER_CONTRACTS: ChainContracts = harden({ enforcers: { allowedTargets: '0x0000000000000000000000000000000000000001' as Address, allowedMethods: '0x0000000000000000000000000000000000000002' as Address, + allowedCalldata: '0x0000000000000000000000000000000000000008' as Address, valueLte: '0x0000000000000000000000000000000000000003' as Address, nativeTokenTransferAmount: '0x0000000000000000000000000000000000000007' as Address, @@ -101,6 +102,7 @@ const SHARED_DELEGATION_MANAGER: Address = const SHARED_ENFORCERS: Record = harden({ allowedTargets: '0x7F20f61b1f09b08D970938F6fa563634d65c4EeB' as Address, allowedMethods: '0x2c21fD0Cb9DC8445CB3fb0DC5E7Bb0Aca01842B5' as Address, + allowedCalldata: '0xc2b0d624c1c4319760c96503ba27c347f3260f55' as Address, valueLte: '0x92Bf12322527cAA612fd31a0e810472BBB106A8F' as Address, nativeTokenTransferAmount: '0xF71af580b9c3078fbc2BBF16FbB8EEd82b330320' as Address, diff --git a/packages/evm-wallet-experiment/src/index.ts b/packages/evm-wallet-experiment/src/index.ts index 62e4ab1b8f..2caf06f422 100644 --- a/packages/evm-wallet-experiment/src/index.ts +++ b/packages/evm-wallet-experiment/src/index.ts @@ -73,6 +73,7 @@ export { // Caveat utilities (for creating delegations externally) export { encodeAllowedTargets, + encodeAllowedCalldata, encodeAllowedMethods, encodeValueLte, encodeNativeTokenTransferAmount, diff --git a/packages/evm-wallet-experiment/src/lib/caveats.test.ts b/packages/evm-wallet-experiment/src/lib/caveats.test.ts index a23bc9fe6e..634bd23b8c 100644 --- a/packages/evm-wallet-experiment/src/lib/caveats.test.ts +++ b/packages/evm-wallet-experiment/src/lib/caveats.test.ts @@ -3,6 +3,7 @@ import { describe, it, expect } from 'vitest'; import { encodeAllowedTargets, + encodeAllowedCalldata, encodeAllowedMethods, encodeValueLte, encodeNativeTokenTransferAmount, @@ -45,6 +46,20 @@ describe('lib/caveats', () => { }); }); + describe('encodeAllowedCalldata', () => { + it('encodes offset and expected value', () => { + const value = + '0x0000000000000000000000001234567890abcdef1234567890abcdef12345678' as Hex; + const encoded = encodeAllowedCalldata({ dataStart: 4, value }); + + // First 32 bytes = offset (4), remainder = the value bytes + expect(encoded.slice(0, 66)).toBe( + `0x${(4).toString(16).padStart(64, '0')}`, + ); + expect(encoded.slice(66)).toBe(value.slice(2)); + }); + }); + describe('encodeAllowedMethods', () => { it('encodes function selectors', () => { const selectors: Hex[] = ['0xa9059cbb', '0x095ea7b3']; @@ -159,6 +174,7 @@ describe('lib/caveats', () => { const types = [ 'allowedTargets', 'allowedMethods', + 'allowedCalldata', 'valueLte', 'nativeTokenTransferAmount', 'erc20TransferAmount', diff --git a/packages/evm-wallet-experiment/src/lib/caveats.ts b/packages/evm-wallet-experiment/src/lib/caveats.ts index a3c26fedd9..b395b6f204 100644 --- a/packages/evm-wallet-experiment/src/lib/caveats.ts +++ b/packages/evm-wallet-experiment/src/lib/caveats.ts @@ -18,6 +18,25 @@ export function encodeAllowedTargets(targets: Address[]): Hex { ); } +/** + * Encode caveat terms for the AllowedCalldata enforcer. + * Restricts a byte range of the execution calldata to an expected value. + * Commonly used to pin a function argument (e.g. a recipient address). + * + * @param options - Options for the caveat. + * @param options.dataStart - Byte offset into calldata where the check begins. + * @param options.value - The expected byte value at that offset. + * @returns The packed terms (32-byte offset ++ value bytes). + */ +export function encodeAllowedCalldata(options: { + dataStart: number; + value: Hex; +}): Hex { + const startHex = options.dataStart.toString(16).padStart(64, '0'); + // Strip the 0x prefix from value and concatenate + return `0x${startHex}${options.value.slice(2)}`; +} + /** * Encode caveat terms for the AllowedMethods enforcer. * Restricts delegation to only call specific function selectors. diff --git a/packages/evm-wallet-experiment/src/lib/delegation-grant.ts b/packages/evm-wallet-experiment/src/lib/delegation-grant.ts index 71a73e5e95..4123d2341f 100644 --- a/packages/evm-wallet-experiment/src/lib/delegation-grant.ts +++ b/packages/evm-wallet-experiment/src/lib/delegation-grant.ts @@ -1,4 +1,5 @@ import { + encodeAllowedCalldata, encodeAllowedMethods, encodeAllowedTargets, encodeErc20TransferAmount, @@ -18,6 +19,22 @@ import type { const harden = globalThis.harden ?? ((value: T): T => value); +/** + * Byte offset of the first argument in ABI-encoded calldata (after the + * 4-byte function selector). + */ +const FIRST_ARG_OFFSET = 4; + +/** + * 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; @@ -25,6 +42,7 @@ type TransferOptions = { max: bigint; chainId: number; validUntil?: number; + recipient?: Address; entropy?: Hex; }; @@ -35,6 +53,7 @@ type ApproveOptions = { max: bigint; chainId: number; validUntil?: number; + spender?: Address; entropy?: Hex; }; @@ -90,8 +109,16 @@ export function buildDelegationGrant( * @returns An unsigned DelegationGrant for ERC-20 transfers. */ function buildTransferGrant(options: TransferOptions): DelegationGrant { - const { delegator, delegate, token, max, chainId, validUntil, entropy } = - options; + const { + delegator, + delegate, + token, + max, + chainId, + validUntil, + recipient, + entropy, + } = options; const caveats: Caveat[] = [ makeCaveat({ type: 'allowedTargets', @@ -112,6 +139,22 @@ function buildTransferGrant(options: TransferOptions): DelegationGrant { const caveatSpecs: CaveatSpec[] = [{ type: 'cumulativeSpend', token, max }]; + if (recipient !== undefined) { + const value = abiEncodeAddress(recipient); + 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({ @@ -145,8 +188,16 @@ function buildTransferGrant(options: TransferOptions): DelegationGrant { * @returns An unsigned DelegationGrant for ERC-20 approvals. */ function buildApproveGrant(options: ApproveOptions): DelegationGrant { - const { delegator, delegate, token, max, chainId, validUntil, entropy } = - options; + const { + delegator, + delegate, + token, + max, + chainId, + validUntil, + spender, + entropy, + } = options; const caveats: Caveat[] = [ makeCaveat({ type: 'allowedTargets', @@ -167,6 +218,22 @@ function buildApproveGrant(options: ApproveOptions): DelegationGrant { const caveatSpecs: CaveatSpec[] = [{ type: 'cumulativeSpend', token, max }]; + if (spender !== undefined) { + const value = abiEncodeAddress(spender); + 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({ 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 bca9e61e77..8b07f90a54 100644 --- a/packages/evm-wallet-experiment/src/lib/delegation-twin.test.ts +++ b/packages/evm-wallet-experiment/src/lib/delegation-twin.test.ts @@ -276,5 +276,56 @@ describe('makeDelegationTwin', () => { }; 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 95b78fe22d..dd8e094a77 100644 --- a/packages/evm-wallet-experiment/src/lib/delegation-twin.ts +++ b/packages/evm-wallet-experiment/src/lib/delegation-twin.ts @@ -13,14 +13,64 @@ import type { Hex, } from '../types.ts'; -const METHOD_GUARDS: Record< - CatalogMethodName, - ReturnType -> = { - transfer: M.callWhen(M.string(), M.scalar()).returns(M.any()), - approve: M.callWhen(M.string(), M.scalar()).returns(M.any()), - call: M.callWhen(M.string(), M.scalar(), M.string()).returns(M.any()), -}; +/** + * Byte offset of the first argument in ABI-encoded calldata (after the + * 4-byte function selector). An `allowedCalldata` caveat spec at this offset + * pins the address argument for transfer/approve. + */ +const FIRST_ARG_OFFSET = 4; + +const METHODS_WITH_ADDRESS_ARG: ReadonlySet = new Set([ + 'transfer', + 'approve', +]); + +/** + * 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.any()); + case 'call': + return M.callWhen(M.string(), M.scalar(), M.string()).returns(M.any()); + default: + throw new Error(`Unknown catalog method: ${String(methodName)}`); + } +} type SpendTracker = { spent: bigint; @@ -151,8 +201,13 @@ export function makeDelegationTwin( [methodName]: entry.schema, }; + const restrictedAddress = findRestrictedAddress(caveatSpecs); + const methodGuards: Record> = { - [methodName]: METHOD_GUARDS[methodName as CatalogMethodName], + [methodName]: buildMethodGuard( + methodName as CatalogMethodName, + restrictedAddress, + ), }; if (readFn && token) { diff --git a/packages/evm-wallet-experiment/src/types.ts b/packages/evm-wallet-experiment/src/types.ts index 1122684f92..68ce3f4d80 100644 --- a/packages/evm-wallet-experiment/src/types.ts +++ b/packages/evm-wallet-experiment/src/types.ts @@ -77,6 +77,7 @@ export function makeChainConfig(options: { export const CaveatTypeValues = [ 'allowedTargets', 'allowedMethods', + 'allowedCalldata', 'valueLte', 'nativeTokenTransferAmount', 'erc20TransferAmount', @@ -342,6 +343,11 @@ export const CaveatSpecStruct = union([ after: BigIntStruct, before: BigIntStruct, }), + object({ + type: literal('allowedCalldata'), + dataStart: number(), + value: HexStruct, + }), object({ type: literal('valueLte'), max: BigIntStruct, From 16110a5aa528b6f90f4a00a43febaef9cd74eb13 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Wed, 8 Apr 2026 15:47:52 -0400 Subject: [PATCH 10/31] feat(evm-wallet-experiment): add storeDelegation to delegation-vat Accepts a full DelegationGrant and persists only the delegation to baggage. Used by coordinator-vat's provisionTwin, where redeemFn/readFn closures must stay in the coordinator scope and cannot cross the CapTP vat boundary. Co-Authored-By: Claude Sonnet 4.6 --- packages/evm-wallet-experiment/src/vats/delegation-vat.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/evm-wallet-experiment/src/vats/delegation-vat.ts b/packages/evm-wallet-experiment/src/vats/delegation-vat.ts index f5d45b3b68..562ea9f749 100644 --- a/packages/evm-wallet-experiment/src/vats/delegation-vat.ts +++ b/packages/evm-wallet-experiment/src/vats/delegation-vat.ts @@ -207,6 +207,12 @@ export function buildRootObject( persistDelegations(); }, + async storeDelegation(grant: DelegationGrant): Promise { + const { delegation } = grant; + delegations.set(delegation.id, delegation); + persistDelegations(); + }, + async addDelegation( grant: DelegationGrant, redeemFn: (execution: Execution) => Promise, From 0dde78cc76fe364390b4d718c8570d968730f7e7 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Wed, 8 Apr 2026 14:55:23 -0400 Subject: [PATCH 11/31] feat(evm-wallet-experiment): enforce cumulativeSpend locally in sendTransaction - setup-wallets: provision delegation twin via provisionTwin (not receiveDelegation) so the away coordinator holds an active twin with a cumulativeSpend caveat spec (1000 ETH, native token) - coordinator-vat: store twins created by provisionTwin in a local coordinatorTwins map keyed by delegation ID - coordinator-vat: sendTransaction routes through the twin when one is registered for the matched delegation, so local caveat checks (e.g. cumulativeSpend budget) fire before the UserOp hits the chain Co-Authored-By: Claude Sonnet 4.6 --- .../src/vats/coordinator-vat.ts | 105 +++++++++++++++++- .../test/e2e/docker/setup-wallets.ts | 19 +++- 2 files changed, 116 insertions(+), 8 deletions(-) diff --git a/packages/evm-wallet-experiment/src/vats/coordinator-vat.ts b/packages/evm-wallet-experiment/src/vats/coordinator-vat.ts index 9edddbb9b0..93bf855851 100644 --- a/packages/evm-wallet-experiment/src/vats/coordinator-vat.ts +++ b/packages/evm-wallet-experiment/src/vats/coordinator-vat.ts @@ -3,7 +3,14 @@ import { makeDefaultExo } from '@metamask/kernel-utils/exo'; import { Logger } from '@metamask/logger'; import type { Baggage } from '@metamask/ocap-kernel'; +import { + ENFORCER_CONTRACT_KEY_MAP, + PLACEHOLDER_CONTRACTS, + registerChainContracts, +} from '../constants.ts'; +import type { ChainContracts } from '../constants.ts'; import { buildDelegationGrant } from '../lib/delegation-grant.ts'; +import { makeDelegationTwin } from '../lib/delegation-twin.ts'; import { decodeAllowanceResult, decodeBalanceOfResult, @@ -367,6 +374,14 @@ export function buildRootObject( 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 + >(); let issuerService: OcapURLIssuerFacet | undefined; let redemptionService: OcapURLRedemptionFacet | undefined; @@ -1591,6 +1606,21 @@ export function buildRootObject( // registry (e.g. local Anvil at chain 31337). if (config.environment) { registerEnvironment(config.chainId, config.environment); + + // Also register in our own getChainContracts() registry so that + // makeDelegationGrant() can build caveats for this chain. + const rawEnforcers = config.environment.caveatEnforcers ?? {}; + const enforcers = { ...PLACEHOLDER_CONTRACTS.enforcers }; + for (const [key, addr] of Object.entries(rawEnforcers)) { + const caveatType = ENFORCER_CONTRACT_KEY_MAP[key]; + if (caveatType !== undefined) { + enforcers[caveatType] = addr; + } + } + registerChainContracts(config.chainId, { + delegationManager: config.environment.DelegationManager, + enforcers, + } as ChainContracts); } bundlerConfig = harden({ @@ -1781,6 +1811,16 @@ export function buildRootObject( `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) { + return E(twin).call( + tx.to, + tx.value ?? ('0x0' as Hex), + tx.data ?? ('0x' as Hex), + ); + } return submitDelegationUserOp({ delegations: [delegation], execution: { @@ -2249,10 +2289,23 @@ export function buildRootObject( const delegator = smartAccountConfig?.address ?? (await resolveOwnerAddress()); - const grant = buildDelegationGrant(method, { - delegator, - ...options, - } as Parameters[1]); + // 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 = buildDelegationGrant( + method, + coercedOptions as Parameters[1], + ); // Sign the delegation via the existing create → prepare → sign → store flow const { delegation } = grant; @@ -2313,7 +2366,37 @@ export function buildRootObject( throw new Error('Delegation vat not available'); } - const { delegation } = grant; + // 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 => { @@ -2338,7 +2421,17 @@ export function buildRootObject( }; } - return E(delegationVat).addDelegation(grant, redeemFn, readFn); + // 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); + return twin; }, // ------------------------------------------------------------------ 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 31a1eea296..d7802cbb3d 100644 --- a/packages/evm-wallet-experiment/test/e2e/docker/setup-wallets.ts +++ b/packages/evm-wallet-experiment/test/e2e/docker/setup-wallets.ts @@ -157,8 +157,23 @@ async function main() { `Delegation created: ${(delegation as { id: string }).id.slice(0, 20)}...`, ); - callVat(kernelServices.away, away.kref, 'receiveDelegation', [delegation]); - console.log('Delegation received by away.'); + 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).', + ); writeDockerDelegationContextFiles(kernelServices.home, home, away); From 80d3acb1f5cdb41725d279fffad21856e25d91c5 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Thu, 9 Apr 2026 09:39:03 -0400 Subject: [PATCH 12/31] refactor(evm-wallet-experiment): deduplicate FIRST_ARG_OFFSET and ERC-20 grant builders - Export FIRST_ARG_OFFSET from erc20.ts; remove local copies in delegation-grant.ts and delegation-twin.ts - Extract buildErc20Grant helper from the near-identical buildTransferGrant/buildApproveGrant (~90 lines removed) - Strengthen delegation twin method guards: M.any() -> M.string() for Hex returns, M.bigint() for getBalance - Clarify cast: as keyof typeof METHOD_CATALOG -> as CatalogMethodName Co-Authored-By: Claude Sonnet 4.6 --- .../src/lib/delegation-grant.ts | 161 ++++++++---------- .../src/lib/delegation-twin.ts | 21 +-- .../evm-wallet-experiment/src/lib/erc20.ts | 5 + 3 files changed, 81 insertions(+), 106 deletions(-) diff --git a/packages/evm-wallet-experiment/src/lib/delegation-grant.ts b/packages/evm-wallet-experiment/src/lib/delegation-grant.ts index 4123d2341f..743022499a 100644 --- a/packages/evm-wallet-experiment/src/lib/delegation-grant.ts +++ b/packages/evm-wallet-experiment/src/lib/delegation-grant.ts @@ -8,7 +8,11 @@ import { makeCaveat, } from './caveats.ts'; import { makeDelegation } from './delegation.ts'; -import { ERC20_APPROVE_SELECTOR, ERC20_TRANSFER_SELECTOR } from './erc20.ts'; +import { + ERC20_APPROVE_SELECTOR, + ERC20_TRANSFER_SELECTOR, + FIRST_ARG_OFFSET, +} from './erc20.ts'; import type { Address, Caveat, @@ -19,12 +23,6 @@ import type { const harden = globalThis.harden ?? ((value: T): T => value); -/** - * Byte offset of the first argument in ABI-encoded calldata (after the - * 4-byte function selector). - */ -const FIRST_ARG_OFFSET = 4; - /** * Encode an address as a 32-byte ABI-encoded word (left-padded with zeros). * @@ -102,23 +100,47 @@ export function buildDelegationGrant( } } +type Erc20GrantOptions = { + methodName: 'transfer' | 'approve'; + selector: Hex; + delegator: Address; + delegate: Address; + token: Address; + max: bigint; + chainId: number; + validUntil?: number; + restrictedAddress?: Address; + entropy?: Hex; +}; + /** - * Build a transfer delegation grant. + * Build a delegation grant for an ERC-20 method (transfer or approve). * - * @param options - Transfer grant options. - * @returns An unsigned DelegationGrant for ERC-20 transfers. + * @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.entropy - Optional entropy for delegation salt. + * @returns An unsigned DelegationGrant for the given ERC-20 method. */ -function buildTransferGrant(options: TransferOptions): DelegationGrant { - const { - delegator, - delegate, - token, - max, - chainId, - validUntil, - recipient, - entropy, - } = options; +function buildErc20Grant({ + methodName, + selector, + delegator, + delegate, + token, + max, + chainId, + validUntil, + restrictedAddress, + entropy, +}: Erc20GrantOptions): DelegationGrant { const caveats: Caveat[] = [ makeCaveat({ type: 'allowedTargets', @@ -127,7 +149,7 @@ function buildTransferGrant(options: TransferOptions): DelegationGrant { }), makeCaveat({ type: 'allowedMethods', - terms: encodeAllowedMethods([ERC20_TRANSFER_SELECTOR]), + terms: encodeAllowedMethods([selector]), chainId, }), makeCaveat({ @@ -139,8 +161,8 @@ function buildTransferGrant(options: TransferOptions): DelegationGrant { const caveatSpecs: CaveatSpec[] = [{ type: 'cumulativeSpend', token, max }]; - if (recipient !== undefined) { - const value = abiEncodeAddress(recipient); + if (restrictedAddress !== undefined) { + const value = abiEncodeAddress(restrictedAddress); caveats.push( makeCaveat({ type: 'allowedCalldata', @@ -178,7 +200,22 @@ function buildTransferGrant(options: TransferOptions): DelegationGrant { entropy, }); - return harden({ delegation, methodName: 'transfer', caveatSpecs, token }); + return harden({ delegation, methodName, caveatSpecs, token }); +} + +/** + * Build a transfer delegation grant. + * + * @param options - Transfer grant options. + * @returns An unsigned DelegationGrant for ERC-20 transfers. + */ +function buildTransferGrant(options: TransferOptions): DelegationGrant { + return buildErc20Grant({ + ...options, + methodName: 'transfer', + selector: ERC20_TRANSFER_SELECTOR, + restrictedAddress: options.recipient, + }); } /** @@ -188,76 +225,12 @@ function buildTransferGrant(options: TransferOptions): DelegationGrant { * @returns An unsigned DelegationGrant for ERC-20 approvals. */ function buildApproveGrant(options: ApproveOptions): DelegationGrant { - const { - delegator, - delegate, - token, - max, - chainId, - validUntil, - spender, - entropy, - } = options; - const caveats: Caveat[] = [ - makeCaveat({ - type: 'allowedTargets', - terms: encodeAllowedTargets([token]), - chainId, - }), - makeCaveat({ - type: 'allowedMethods', - terms: encodeAllowedMethods([ERC20_APPROVE_SELECTOR]), - chainId, - }), - makeCaveat({ - type: 'erc20TransferAmount', - terms: encodeErc20TransferAmount({ token, amount: max }), - chainId, - }), - ]; - - const caveatSpecs: CaveatSpec[] = [{ type: 'cumulativeSpend', token, max }]; - - if (spender !== undefined) { - const value = abiEncodeAddress(spender); - 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, - entropy, + return buildErc20Grant({ + ...options, + methodName: 'approve', + selector: ERC20_APPROVE_SELECTOR, + restrictedAddress: options.spender, }); - - return harden({ delegation, methodName: 'approve', caveatSpecs, token }); } /** diff --git a/packages/evm-wallet-experiment/src/lib/delegation-twin.ts b/packages/evm-wallet-experiment/src/lib/delegation-twin.ts index dd8e094a77..1247a99e4f 100644 --- a/packages/evm-wallet-experiment/src/lib/delegation-twin.ts +++ b/packages/evm-wallet-experiment/src/lib/delegation-twin.ts @@ -2,7 +2,11 @@ import { M } from '@endo/patterns'; import type { MethodSchema } from '@metamask/kernel-utils'; import { makeDiscoverableExo } from '@metamask/kernel-utils/discoverable'; -import { decodeBalanceOfResult, encodeBalanceOf } from './erc20.ts'; +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 { @@ -13,13 +17,6 @@ import type { Hex, } from '../types.ts'; -/** - * Byte offset of the first argument in ABI-encoded calldata (after the - * 4-byte function selector). An `allowedCalldata` caveat spec at this offset - * pins the address argument for transfer/approve. - */ -const FIRST_ARG_OFFSET = 4; - const METHODS_WITH_ADDRESS_ARG: ReadonlySet = new Set([ 'transfer', 'approve', @@ -64,9 +61,9 @@ function buildMethodGuard( switch (methodName) { case 'transfer': case 'approve': - return M.callWhen(addrGuard, M.scalar()).returns(M.any()); + return M.callWhen(addrGuard, M.scalar()).returns(M.string()); case 'call': - return M.callWhen(M.string(), M.scalar(), M.string()).returns(M.any()); + return M.callWhen(M.string(), M.scalar(), M.string()).returns(M.string()); default: throw new Error(`Unknown catalog method: ${String(methodName)}`); } @@ -140,7 +137,7 @@ export function makeDelegationTwin( const { grant, redeemFn, readFn } = options; const { methodName, caveatSpecs, delegation } = grant; - const entry = METHOD_CATALOG[methodName as keyof typeof METHOD_CATALOG]; + const entry = METHOD_CATALOG[methodName as CatalogMethodName]; if (!entry) { throw new Error(`Unknown method in grant: ${methodName}`); } @@ -219,7 +216,7 @@ export function makeDelegationTwin( return decodeBalanceOfResult(result); }; schema.getBalance = GET_BALANCE_SCHEMA; - methodGuards.getBalance = M.callWhen().returns(M.any()); + methodGuards.getBalance = M.callWhen().returns(M.bigint()); } const interfaceGuard = M.interface( diff --git a/packages/evm-wallet-experiment/src/lib/erc20.ts b/packages/evm-wallet-experiment/src/lib/erc20.ts index a86100c996..6e5d72bd40 100644 --- a/packages/evm-wallet-experiment/src/lib/erc20.ts +++ b/packages/evm-wallet-experiment/src/lib/erc20.ts @@ -12,6 +12,11 @@ const harden = globalThis.harden ?? ((value: T): T => value); // Constants // --------------------------------------------------------------------------- +/** + * Byte offset of the first argument in ABI-encoded calldata (after the 4-byte selector). + */ +export const FIRST_ARG_OFFSET = 4; + /** * Function selector for ERC-20 `transfer(address,uint256)`. */ From 19116b07db9ac489ef27fc99ddbc39d42c37c4bc Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Tue, 14 Apr 2026 13:09:18 -0400 Subject: [PATCH 13/31] refactor(evm-wallet-experiment): replace entropy param with makeSaltGenerator factory Replace the module-level saltCounter + entropy threading with a proper factory: makeSaltGenerator() returns a closure with its own counter, so each vat instance gets an independent salt sequence rather than sharing module-level state. The entropy parameter is removed from TransferOptions, ApproveOptions, CallOptions, and makeDelegation; callers that need per-instance isolation use makeDelegationGrantBuilder with a makeSaltGenerator() instance. - Add SaltGenerator type and makeSaltGenerator() to delegation.ts - generateSalt is now makeSaltGenerator() called once at module load (backward-compatible export) - makeDelegation accepts saltGenerator? instead of entropy? - Add makeDelegationGrantBuilder({ saltGenerator }) factory to delegation-grant.ts; extract dispatchGrant to share the switch - coordinator-vat creates one saltGenerator + grantBuilder per vat instance inside buildRootObject - Add --build to docker:up so stale images are detected automatically - Export makeSaltGenerator, SaltGenerator, makeDelegationGrantBuilder from index.ts Co-Authored-By: Claude Sonnet 4.6 --- packages/evm-wallet-experiment/package.json | 2 +- packages/evm-wallet-experiment/src/index.ts | 7 +- .../src/lib/delegation-grant.test.ts | 50 +++++++- .../src/lib/delegation-grant.ts | 114 ++++++++++++++---- .../src/lib/delegation.test.ts | 30 +++++ .../src/lib/delegation.ts | 71 +++++++---- .../src/vats/coordinator-vat.ts | 15 ++- 7 files changed, 234 insertions(+), 55 deletions(-) diff --git a/packages/evm-wallet-experiment/package.json b/packages/evm-wallet-experiment/package.json index a2e30d5bbe..8da7b67a63 100644 --- a/packages/evm-wallet-experiment/package.json +++ b/packages/evm-wallet-experiment/package.json @@ -61,7 +61,7 @@ "docker:build": "yarn workspace @ocap/monorepo build && yarn docker:compose build", "docker:build:force": "yarn docker:compose build --no-cache", "docker:ensure-logs": "mkdir -p logs", - "docker:up": "yarn docker:ensure-logs && yarn docker:compose up", + "docker:up": "yarn docker:ensure-logs && yarn docker:compose up --build", "docker:down": "yarn docker:compose down", "docker:interactive:up": "yarn docker:ensure-logs && node docker/run-interactive-compose.mjs up --build", "docker:interactive:down": "node docker/run-interactive-compose.mjs down", diff --git a/packages/evm-wallet-experiment/src/index.ts b/packages/evm-wallet-experiment/src/index.ts index 2caf06f422..b0d7ca17fa 100644 --- a/packages/evm-wallet-experiment/src/index.ts +++ b/packages/evm-wallet-experiment/src/index.ts @@ -108,7 +108,9 @@ export { finalizeDelegation, computeDelegationId, generateSalt, + makeSaltGenerator, } from './lib/delegation.ts'; +export type { SaltGenerator } from './lib/delegation.ts'; // UserOperation utilities export { @@ -181,7 +183,10 @@ export { METHOD_CATALOG } from './lib/method-catalog.ts'; export type { CatalogMethodName } from './lib/method-catalog.ts'; // Grant builder -export { buildDelegationGrant } from './lib/delegation-grant.ts'; +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 index e33f508c64..0768f2b9d4 100644 --- a/packages/evm-wallet-experiment/src/lib/delegation-grant.test.ts +++ b/packages/evm-wallet-experiment/src/lib/delegation-grant.test.ts @@ -1,7 +1,11 @@ import { describe, expect, it } from 'vitest'; import type { Address } from '../types.ts'; -import { buildDelegationGrant } from './delegation-grant.ts'; +import { + buildDelegationGrant, + makeDelegationGrantBuilder, +} from './delegation-grant.ts'; +import { makeSaltGenerator } from './delegation.ts'; const ALICE = '0x1111111111111111111111111111111111111111' as Address; const BOB = '0x2222222222222222222222222222222222222222' as Address; @@ -153,3 +157,47 @@ describe('buildDelegationGrant', () => { }); }); }); + +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 index 743022499a..565eaeb6c3 100644 --- a/packages/evm-wallet-experiment/src/lib/delegation-grant.ts +++ b/packages/evm-wallet-experiment/src/lib/delegation-grant.ts @@ -7,7 +7,8 @@ import { encodeValueLte, makeCaveat, } from './caveats.ts'; -import { makeDelegation } from './delegation.ts'; +import { generateSalt, makeDelegation } from './delegation.ts'; +import type { SaltGenerator } from './delegation.ts'; import { ERC20_APPROVE_SELECTOR, ERC20_TRANSFER_SELECTOR, @@ -41,7 +42,6 @@ type TransferOptions = { chainId: number; validUntil?: number; recipient?: Address; - entropy?: Hex; }; type ApproveOptions = { @@ -52,7 +52,6 @@ type ApproveOptions = { chainId: number; validUntil?: number; spender?: Address; - entropy?: Hex; }; type CallOptions = { @@ -62,7 +61,6 @@ type CallOptions = { chainId: number; maxValue?: bigint; validUntil?: number; - entropy?: Hex; }; export function buildDelegationGrant( @@ -80,6 +78,10 @@ export function buildDelegationGrant( /** * 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. @@ -87,14 +89,71 @@ export function buildDelegationGrant( 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); + return buildTransferGrant(options as TransferOptions, saltGenerator); case 'approve': - return buildApproveGrant(options as ApproveOptions); + return buildApproveGrant(options as ApproveOptions, saltGenerator); case 'call': - return buildCallGrant(options as CallOptions); + return buildCallGrant(options as CallOptions, saltGenerator); default: throw new Error(`Unknown method: ${String(method)}`); } @@ -110,7 +169,7 @@ type Erc20GrantOptions = { chainId: number; validUntil?: number; restrictedAddress?: Address; - entropy?: Hex; + saltGenerator: SaltGenerator; }; /** @@ -126,7 +185,7 @@ type Erc20GrantOptions = { * @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.entropy - Optional entropy for delegation salt. + * @param options.saltGenerator - Salt generator for delegation uniqueness. * @returns An unsigned DelegationGrant for the given ERC-20 method. */ function buildErc20Grant({ @@ -139,7 +198,7 @@ function buildErc20Grant({ chainId, validUntil, restrictedAddress, - entropy, + saltGenerator, }: Erc20GrantOptions): DelegationGrant { const caveats: Caveat[] = [ makeCaveat({ @@ -197,7 +256,7 @@ function buildErc20Grant({ delegate, caveats, chainId, - entropy, + saltGenerator, }); return harden({ delegation, methodName, caveatSpecs, token }); @@ -207,14 +266,19 @@ function buildErc20Grant({ * 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): DelegationGrant { +function buildTransferGrant( + options: TransferOptions, + saltGenerator: SaltGenerator, +): DelegationGrant { return buildErc20Grant({ ...options, methodName: 'transfer', selector: ERC20_TRANSFER_SELECTOR, restrictedAddress: options.recipient, + saltGenerator, }); } @@ -222,14 +286,19 @@ function buildTransferGrant(options: TransferOptions): DelegationGrant { * 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): DelegationGrant { +function buildApproveGrant( + options: ApproveOptions, + saltGenerator: SaltGenerator, +): DelegationGrant { return buildErc20Grant({ ...options, methodName: 'approve', selector: ERC20_APPROVE_SELECTOR, restrictedAddress: options.spender, + saltGenerator, }); } @@ -237,18 +306,15 @@ function buildApproveGrant(options: ApproveOptions): DelegationGrant { * 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): DelegationGrant { - const { - delegator, - delegate, - targets, - chainId, - maxValue, - validUntil, - entropy, - } = options; +function buildCallGrant( + options: CallOptions, + saltGenerator: SaltGenerator, +): DelegationGrant { + const { delegator, delegate, targets, chainId, maxValue, validUntil } = + options; const caveats: Caveat[] = [ makeCaveat({ type: 'allowedTargets', @@ -285,7 +351,7 @@ function buildCallGrant(options: CallOptions): DelegationGrant { delegate, caveats, chainId, - entropy, + saltGenerator, }); return harden({ delegation, methodName: 'call', caveatSpecs }); diff --git a/packages/evm-wallet-experiment/src/lib/delegation.test.ts b/packages/evm-wallet-experiment/src/lib/delegation.test.ts index 19b607be8d..c540b55729 100644 --- a/packages/evm-wallet-experiment/src/lib/delegation.test.ts +++ b/packages/evm-wallet-experiment/src/lib/delegation.test.ts @@ -12,6 +12,7 @@ import { import { computeDelegationId, makeDelegation, + makeSaltGenerator, prepareDelegationTypedData, delegationMatchesAction, explainDelegationMatch, @@ -40,6 +41,35 @@ describe('lib/delegation', () => { }); }); + describe('makeSaltGenerator', () => { + it('returns a function that generates 32-byte hex salts', () => { + const generate = makeSaltGenerator(); + expect(generate()).toMatch(/^0x[\da-f]{64}$/iu); + }); + + it('generates unique salts across sequential calls', () => { + const generate = makeSaltGenerator(); + expect(generate()).not.toBe(generate()); + }); + + it('two generators produce independent sequences', () => { + const gen1 = makeSaltGenerator(); + const gen2 = makeSaltGenerator(); + // Each generator's counter is independent — advance gen1 several times + // without touching gen2 and verify gen2 still produces valid salts. + gen1(); + gen1(); + gen1(); + expect(gen2()).toMatch(/^0x[\da-f]{64}$/iu); + }); + + it('accepts entropy without throwing', () => { + const entropy = '0xdeadbeef' as `0x${string}`; + const generate = makeSaltGenerator(entropy); + expect(generate()).toMatch(/^0x[\da-f]{64}$/iu); + }); + }); + describe('computeDelegationId', () => { it('produces a deterministic hash', () => { const params = { diff --git a/packages/evm-wallet-experiment/src/lib/delegation.ts b/packages/evm-wallet-experiment/src/lib/delegation.ts index 72fe088afe..b407164d82 100644 --- a/packages/evm-wallet-experiment/src/lib/delegation.ts +++ b/packages/evm-wallet-experiment/src/lib/delegation.ts @@ -48,45 +48,64 @@ export function computeDelegationId(delegation: { ); } -// Monotonic counter for salt uniqueness in SES compartments where -// neither crypto.getRandomValues nor Math.random is available. -// This is intentionally module-level: generateSalt() is a standalone -// exported function (not part of a factory), so the counter must persist -// across calls for the lifetime of the module/compartment. -let saltCounter = 0; +/** + * A function that generates a unique delegation salt on each call. + */ +export type SaltGenerator = () => Hex; /** - * Generate a random salt for delegation uniqueness. + * Create a salt generator for delegation uniqueness. * * Prefers crypto.getRandomValues when available. In SES compartments - * where crypto is not endowed, falls back to keccak256(entropy + counter) - * when entropy is provided, or keccak256(counter) otherwise. + * where crypto is not endowed, falls back to a closure-local counter + * hashed with optional caller-supplied entropy. Each call to + * makeSaltGenerator produces an independent counter, so two vat instances + * each get their own sequence rather than sharing module-level state. * * @param entropy - Optional caller-supplied entropy hex string. When provided * and crypto is unavailable, mixed into the counter hash so that separate - * vat instances (each with counter starting at 0) produce distinct salts. - * @returns A hex-encoded random salt. + * vat instances produce distinct salts even though both start at counter 1. + * @returns A salt generator function. */ -export function generateSalt(entropy?: Hex): Hex { +export function makeSaltGenerator(entropy?: Hex): SaltGenerator { // eslint-disable-next-line n/no-unsupported-features/node-builtins if (globalThis.crypto?.getRandomValues) { - const bytes = new Uint8Array(32); - // eslint-disable-next-line n/no-unsupported-features/node-builtins - globalThis.crypto.getRandomValues(bytes); - return toHex(bytes); + return () => { + const bytes = new Uint8Array(32); + // eslint-disable-next-line n/no-unsupported-features/node-builtins + globalThis.crypto.getRandomValues(bytes); + return toHex(bytes); + }; } - // SES fallback: unique per vat lifetime but not cryptographically random. + // SES fallback: unique per generator lifetime but not cryptographically random. // The salt only needs uniqueness, not unpredictability. - saltCounter += 1; + let counter = 0; if (entropy !== undefined) { - return keccak256( - encodePacked(['bytes', 'uint256'], [entropy, BigInt(saltCounter)]), - ); + return () => { + counter += 1; + return keccak256( + encodePacked(['bytes', 'uint256'], [entropy, BigInt(counter)]), + ); + }; } - return keccak256(encodePacked(['uint256'], [BigInt(saltCounter)])); + return () => { + counter += 1; + return keccak256(encodePacked(['uint256'], [BigInt(counter)])); + }; } +/** + * Generate a random salt for delegation uniqueness. + * + * Uses a module-level counter as the SES fallback. Prefer + * {@link makeSaltGenerator} when creating delegations in a vat, since it + * gives each vat instance an independent counter. + * + * @returns A hex-encoded random salt. + */ +export const generateSalt: SaltGenerator = makeSaltGenerator(); + /** * Create a new unsigned delegation struct. * @@ -96,8 +115,8 @@ export function generateSalt(entropy?: Hex): Hex { * @param options.caveats - The caveats restricting the delegation. * @param options.chainId - The chain ID. * @param options.salt - Optional salt (generated if omitted). - * @param options.entropy - Optional entropy hex string forwarded to - * {@link generateSalt} when no explicit salt is provided. + * @param options.saltGenerator - Optional salt generator to use when no + * explicit salt is provided. Defaults to {@link generateSalt}. * @param options.authority - Optional parent delegation hash (root if omitted). * @returns The unsigned Delegation struct. */ @@ -107,10 +126,10 @@ export function makeDelegation(options: { caveats: Caveat[]; chainId: number; salt?: Hex; - entropy?: Hex; + saltGenerator?: SaltGenerator; authority?: Hex; }): Delegation { - const salt = options.salt ?? generateSalt(options.entropy); + const salt = options.salt ?? (options.saltGenerator ?? generateSalt)(); const authority = options.authority ?? ROOT_AUTHORITY; const id = computeDelegationId({ diff --git a/packages/evm-wallet-experiment/src/vats/coordinator-vat.ts b/packages/evm-wallet-experiment/src/vats/coordinator-vat.ts index 93bf855851..0eb9ce084a 100644 --- a/packages/evm-wallet-experiment/src/vats/coordinator-vat.ts +++ b/packages/evm-wallet-experiment/src/vats/coordinator-vat.ts @@ -9,8 +9,12 @@ import { registerChainContracts, } from '../constants.ts'; import type { ChainContracts } from '../constants.ts'; -import { buildDelegationGrant } from '../lib/delegation-grant.ts'; +import { + buildDelegationGrant, + makeDelegationGrantBuilder, +} from '../lib/delegation-grant.ts'; import { makeDelegationTwin } from '../lib/delegation-twin.ts'; +import { makeSaltGenerator } from '../lib/delegation.ts'; import { decodeAllowanceResult, decodeBalanceOfResult, @@ -370,6 +374,13 @@ export function buildRootObject( } }); + // Per-vat salt generator: each vat instance gets its own counter so that + // delegations created in separate vat lifetimes (or parallel vats) do not + // collide even when crypto.getRandomValues is unavailable (SES fallback). + const grantBuilder = makeDelegationGrantBuilder({ + saltGenerator: makeSaltGenerator(), + }); + // References to other vats (set during bootstrap) let keyringVat: KeyringFacet | undefined; let providerVat: ProviderFacet | undefined; @@ -2302,7 +2313,7 @@ export function buildRootObject( }), }; - const grant = buildDelegationGrant( + const grant = grantBuilder.buildDelegationGrant( method, coercedOptions as Parameters[1], ); From 7590ea9de2eac98545a57fbc6cfd3234dfc36fc7 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Thu, 9 Apr 2026 12:35:53 -0400 Subject: [PATCH 14/31] fix(evm-wallet-experiment): update callVatExpectError to return error message directly With the queueMessage RPC fix in @metamask/ocap-kernel, vat rejections now surface as response.error with the actual message rather than a generic 'Internal error'. Update callVatExpectError in both the docker e2e helper and the integration test runner to return response.error.message directly and throw on unexpected success, replacing the dead response.result.body path. --- .../test/e2e/docker/helpers/daemon-client.mjs | 4 ++-- .../test/integration/run-daemon-wallet.mjs | 16 +++++++--------- 2 files changed, 9 insertions(+), 11 deletions(-) diff --git a/packages/evm-wallet-experiment/test/e2e/docker/helpers/daemon-client.mjs b/packages/evm-wallet-experiment/test/e2e/docker/helpers/daemon-client.mjs index 0251252a35..e56415d970 100644 --- a/packages/evm-wallet-experiment/test/e2e/docker/helpers/daemon-client.mjs +++ b/packages/evm-wallet-experiment/test/e2e/docker/helpers/daemon-client.mjs @@ -46,10 +46,10 @@ async function callVat(socketPath, target, method, args = []) { async function callVatExpectError(socketPath, target, method, args = []) { const response = await rpc(socketPath, 'queueMessage', [target, method, args]); if (response.error) { - return JSON.stringify(response.error); + return response.error.message; } await waitUntilQuiescent(); - return response.result.body; + throw new Error(`Expected error but call succeeded: ${JSON.stringify(response.result)}`); } /** 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 2083fceb29..dc574ca18a 100644 --- a/packages/evm-wallet-experiment/test/integration/run-daemon-wallet.mjs +++ b/packages/evm-wallet-experiment/test/integration/run-daemon-wallet.mjs @@ -125,7 +125,8 @@ async function callVat(socketPath, target, method, args = []) { } /** - * Send a queueMessage RPC expecting it to return an error CapData. + * Send a queueMessage RPC expecting it to fail, and return the error message. + * Throws if the call unexpectedly succeeds. * * @param {string} socketPath * @param {string} target @@ -140,12 +141,12 @@ async function callVatExpectError(socketPath, target, method, args = []) { args, ]); if (response.error) { - // RPC-level error (method dispatch failed) - return JSON.stringify(response.error); + return response.error.message; } - // Vat-level error (method threw, encoded as CapData) await waitUntilQuiescent(); - return response.result.body; + throw new Error( + `Expected error but call succeeded: ${JSON.stringify(response.result)}`, + ); } // --------------------------------------------------------------------------- @@ -315,10 +316,7 @@ async function main() { 'signMessage', ['should fail'], ); - assert( - errorBody.includes('#error') || errorBody.includes('No authority'), - 'error surfaces through daemon', - ); + assert(errorBody.includes('No authority'), 'error surfaces through daemon'); // ----------------------------------------------------------------------- // Test 10: Terminate subcluster via daemon From 69f7e29dbc1bcc24839ec1fff62d5036394a4dc0 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Wed, 8 Apr 2026 15:49:27 -0400 Subject: [PATCH 15/31] test(evm-wallet-experiment): add delegation twin e2e script Script invoked by the docker e2e suite to test local cumulativeSpend enforcement and chain-side rejection of an expired timestamp caveat. Co-Authored-By: Claude Sonnet 4.6 --- .../e2e/docker/run-delegation-twin-e2e.mjs | 228 ++++++++++++++++++ 1 file changed, 228 insertions(+) create mode 100644 packages/evm-wallet-experiment/test/e2e/docker/run-delegation-twin-e2e.mjs 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 new file mode 100644 index 0000000000..a723e1e831 --- /dev/null +++ b/packages/evm-wallet-experiment/test/e2e/docker/run-delegation-twin-e2e.mjs @@ -0,0 +1,228 @@ +/* eslint-disable no-plusplus, n/no-process-exit, import-x/no-unresolved */ +/** + * 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. + * + * ── 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. + * + * ── Usage ───────────────────────────────────────────────────────────────── + * + * Invoked by docker-e2e.test.ts via dockerExec: + * + * node --conditions development run-delegation-twin-e2e.mjs \ + * + * + * mode bundler-7702 | bundler-hybrid | peer-relay + * homeKref coordinator kref on the home kernel (e.g. ko4) + * awayKref coordinator kref on the away kernel + * delegateAddress on-chain delegate address for the delegation + */ + +import '@metamask/kernel-shims/endoify-node'; + +import { randomBytes } from 'node:crypto'; +import { readFile } from 'node:fs/promises'; + +import { makeDaemonClient } from './helpers/daemon-client.mjs'; + +const [, , mode, homeKref, awayKref, delegateAddress] = process.argv; + +if (!mode || !homeKref || !awayKref || !delegateAddress) { + console.error( + 'Usage: run-delegation-twin-e2e.mjs ', + ); + process.exit(1); +} + +const SERVICE_PAIRS = { + 'bundler-7702': { + home: 'kernel-home-bundler-7702', + away: 'kernel-away-bundler-7702', + }, + 'bundler-hybrid': { + home: 'kernel-home-bundler-hybrid', + away: 'kernel-away-bundler-hybrid', + }, + 'peer-relay': { + home: 'kernel-home-peer-relay', + away: 'kernel-away-peer-relay', + }, +}; + +const pair = SERVICE_PAIRS[mode]; +if (!pair) { + console.error(`Unknown mode: ${mode}`); + process.exit(1); +} + +const homeReady = JSON.parse( + await readFile(`/run/ocap/${pair.home}-ready.json`, 'utf8'), +); +const awayReady = JSON.parse( + await readFile(`/run/ocap/${pair.away}-ready.json`, 'utf8'), +); + +const homeClient = makeDaemonClient(homeReady.socketPath); +const awayClient = makeDaemonClient(awayReady.socketPath); + +// Zero-code address on Anvil — EVM calls to it succeed with empty return. +// The erc20TransferAmount enforcer only inspects calldata amount, not the +// token contract itself, so this works as a stand-in token. +const FAKE_TOKEN = '0x000000000000000000000000000000000000dEaD'; +const BURN_ADDRESS = '0x000000000000000000000000000000000000dEaD'; +const CHAIN_ID = 31337; + +let passed = 0; +let failed = 0; + +function assert(condition, label) { + if (condition) { + passed++; + console.log(` ✓ ${label}`); + } else { + failed++; + console.error(` ✗ ${label}`); + } +} + +console.log(`\n=== Delegation Twin E2E (${mode}) ===\n`); + +// Per-run entropy so each test run produces unique delegation hashes even when +// the coordinator vat is freshly instantiated (counter reset to 0). +const entropy = `0x${randomBytes(32).toString('hex')}`; + +// ── Test 1: Twin enforces cumulative spend locally ───────────────────────── + +console.log('--- Transfer twin: spend tracking ---'); + +const transferGrant = await homeClient.callVat( + homeKref, + 'makeDelegationGrant', + [ + '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', + chainId: CHAIN_ID, + entropy, + }, + ], +); + +assert( + transferGrant !== null && typeof transferGrant === 'object', + 'home created transfer grant', +); + +const twinStandin = await awayClient.callVat(awayKref, 'provisionTwin', [ + transferGrant, +]); +const twinKref = twinStandin.getKref(); + +assert( + typeof twinKref === 'string' && twinKref.length > 0, + `twin kref: ${twinKref}`, +); + +// 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', [ + BURN_ADDRESS, + '3', +]); + +// For bundler-hybrid, wait for on-chain UserOp inclusion. +if (mode === 'bundler-hybrid') { + console.log(' Waiting for UserOp receipt (hybrid mode)...'); + await awayClient.callVat(awayKref, 'waitForUserOpReceipt', [ + { userOpHash: txHash, pollIntervalMs: 500, timeoutMs: 120_000 }, + ]); +} + +assert( + typeof txHash === 'string' && /^0x[\da-f]{64}$/iu.test(txHash), + `first spend (3 units) → tx hash: ${String(txHash).slice(0, 20)}...`, +); + +// 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, + entropy, + }, +]); + +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'], +); + +assert( + typeof expiredError === 'string' && expiredError.length > 0, + `expired delegation rejected by chain (not twin): ${String(expiredError).slice(0, 80)}`, +); + +// ── Results ──────────────────────────────────────────────────────────────── + +console.log(`\n=== Results: ${passed} passed, ${failed} failed ===\n`); +if (failed === 0) { + console.log('All delegation twin tests passed'); +} +process.exit(failed > 0 ? 1 : 0); From dda45ab5f92b2c21d647a56e63d5e103440a636d Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Wed, 8 Apr 2026 15:48:01 -0400 Subject: [PATCH 16/31] test(evm-wallet-experiment): add delegation twin docker e2e test Runs run-delegation-twin-e2e.mjs inside the away container as part of the Docker E2E suite. Covers local cumulativeSpend enforcement and chain-side rejection of an expired timestamp delegation. Co-Authored-By: Claude Sonnet 4.6 --- .../test/e2e/docker/docker-e2e.test.ts | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) 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 cf3c49851c..eb7bc930c1 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 @@ -13,6 +13,7 @@ import type { } from './helpers/docker-e2e-kernel-services.ts'; import { callVat, + dockerExec, evmRpc, isStackHealthy, readContracts, @@ -321,6 +322,27 @@ describe('Docker E2E', () => { expectations[delegationMode](); }); }); + + // ------------------------------------------------------------------------- + // Delegation twin + // ------------------------------------------------------------------------- + + describe('delegation twin', () => { + it('enforces cumulativeSpend locally; chain enforces expired timestamp', () => { + const delegate = resolveOnChainDelegateAddress({ + delegationMode, + home: homeResult, + away: awayResult, + }); + const scriptPath = + '/app/packages/evm-wallet-experiment/test/e2e/docker/run-delegation-twin-e2e.mjs'; + const output = dockerExec( + kernelServices.away, + `node --conditions development ${scriptPath} ${delegationMode} ${homeResult.kref} ${awayResult.kref} ${delegate}`, + ); + expect(output).toContain('All delegation twin tests passed'); + }, 180_000); + }); }, ); }); From 5d0cf0145332a6ab55f5b705a5f24291c24af37b Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Thu, 9 Apr 2026 16:50:49 -0400 Subject: [PATCH 17/31] test(evm-wallet-experiment): improve delegation twin e2e failure messages Include log file path in assertion failure messages so the relevant service log is immediately identifiable without digging through output. --- .../test/e2e/docker/docker-e2e.test.ts | 22 ++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/packages/evm-wallet-experiment/test/e2e/docker/docker-e2e.test.ts b/packages/evm-wallet-experiment/test/e2e/docker/docker-e2e.test.ts index eb7bc930c1..bfd19c5734 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 @@ -336,11 +336,23 @@ describe('Docker E2E', () => { }); const scriptPath = '/app/packages/evm-wallet-experiment/test/e2e/docker/run-delegation-twin-e2e.mjs'; - const output = dockerExec( - kernelServices.away, - `node --conditions development ${scriptPath} ${delegationMode} ${homeResult.kref} ${awayResult.kref} ${delegate}`, - ); - expect(output).toContain('All delegation twin tests passed'); + const logFile = `logs/${kernelServices.away}.log`; + let output = ''; + try { + output = dockerExec( + kernelServices.away, + `node --conditions development ${scriptPath} ${delegationMode} ${homeResult.kref} ${awayResult.kref} ${delegate}`, + ); + } catch (error) { + throw new Error( + `Delegation twin e2e script failed — see ${logFile}\n` + + `${error instanceof Error ? error.message : String(error)}`, + ); + } + expect( + output, + `Assertions failed — see ${logFile} and logs/test-results.json`, + ).toContain('All delegation twin tests passed'); }, 180_000); }); }, From 91104dc15d65c3d6f0fc177276b256f09a966956 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Thu, 19 Mar 2026 12:41:07 -0400 Subject: [PATCH 18/31] docs(evm-wallet-experiment): add GATOR.md conceptual model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Explains the grant → twin → discoverable capability layering: - Delegation grants as serializable describable delegations (redeemable bytestring + readable caveat specs) - Delegation twins as local capabilities that mirror on-chain stateful caveats (latently; on-chain is authoritative) - M.* patterns as the mechanism for discoverability and pre-validation Documents the enforcer mapping table and M.*/Gator overlap. --- .../evm-wallet-experiment/src/lib/GATOR.md | 217 ++++++++++++++++++ 1 file changed, 217 insertions(+) create mode 100644 packages/evm-wallet-experiment/src/lib/GATOR.md diff --git a/packages/evm-wallet-experiment/src/lib/GATOR.md b/packages/evm-wallet-experiment/src/lib/GATOR.md new file mode 100644 index 0000000000..c92234b6e2 --- /dev/null +++ b/packages/evm-wallet-experiment/src/lib/GATOR.md @@ -0,0 +1,217 @@ +# Gator + Endo Integration + +This document describes how [MetaMask Delegation Framework +("Gator")](https://github.com/MetaMask/delegation-framework) is integrated with +the ocap kernel. Gator constructs capabilities. Endo `M.*` patterns make them +discoverable. + +## Conceptual model + +``` + Delegation grant + ┌──────────────────────────────────────────────────────┐ + │ delegation ← redeemable bytestring (signed, EIP-7702) + │ caveatSpecs ← readable description of active caveats + │ methodName ← which catalog operation this enables + │ token? ← ERC-20 contract in play (if any) + └──────────────────────────────────────────────────────┘ + │ + │ makeDelegationTwin() + ▼ + Delegation twin (discoverable exo) + ┌──────────────────────────────────────────────────────┐ + │ transfer / approve / call ← ocap capability methods + │ getBalance? ← optional read method + │ SpendTracker ← local mirror of on-chain state + │ InterfaceGuard ← M.* patterns derived from caveats + └──────────────────────────────────────────────────────┘ + │ + │ makeDiscoverableExo() + ▼ + Discoverable capability + (surfaced to agents via kernel capability discovery) +``` + +### Delegation grants + +A delegation grant is a **serializable, describable** version of a delegation. +It holds two things together: + +- **`delegation`** — the redeemable bytestring: a fully-formed, signed + delegation struct ready to pass to `redeemDelegation` on-chain. This is + the authoritative bytes; everything else is derived from it. + +- **`caveatSpecs`** — a structured, human-readable description of the caveats + in effect. Unlike raw `caveats` (which are opaque encoded calldata passed to + enforcer contracts), `caveatSpecs` name the constraint and its parameters in + terms the application can reason about: `{ type: 'cumulativeSpend', token, +max }`, `{ type: 'allowedCalldata', dataStart, value }`, etc. + +Grants are what get stored and transmitted. They can be reconstructed into +twins whenever a live capability is needed. + +### Delegation twins + +A delegation twin is a **local capability** that wraps a grant and gives it an +ocap interface. The twin: + +- Exposes the delegation's permitted operations as callable methods +- Derives its interface guard from the grant's `caveatSpecs`, so a call that + would fail on-chain (e.g., wrong recipient, over-budget) is rejected locally + first with a descriptive error +- Tracks stateful caveats locally — cumulative spend, value limits — as a + **latent mirror** of on-chain state + +The local tracker is advisory, not authoritative. On-chain state is the truth. +If spend is tracked externally (e.g., another redemption outside this twin), the +local tracker will optimistically allow a call that the chain will reject. The +twin's job is to provide fast pre-rejection and a structured capability +interface, not to replace the on-chain enforcer. + +### M.\* patterns and discoverability + +`M.*` interface guards serve two purposes: + +1. **Discoverability** — `makeDiscoverableExo` attaches the interface guard and + method schema to the exo. The kernel's capability discovery mechanism reads + these to surface the capability to agents, including what methods are + available and what arguments they accept. + +2. **Pre-validation** — the guard can narrow the accepted argument shapes based + on the active caveats. If an `allowedCalldata` caveat pins the first argument + to a specific address, the corresponding guard uses that literal as the + pattern, so a call with any other address is rejected before hitting the + network. + +--- + +## Caveat → guard mapping + +The following table maps Gator caveat enforcers to the `M.*` patterns used in +delegation twin interface guards. + +### Execution-envelope caveats + +These constrain the execution itself (target, selector, value), not individual +calldata arguments. They are represented in `caveatSpecs` and influence the +twin's behavior but do not correspond to argument-level `M.*` patterns. + +| Caveat enforcer | CaveatSpec type | Twin behavior | +| ----------------------------------- | ------------------ | --------------------------------------------------- | +| `AllowedTargetsEnforcer` | _(structural)_ | Determines which contract the twin calls | +| `AllowedMethodsEnforcer` | _(structural)_ | Determines which function selector the twin uses | +| `ValueLteEnforcer` | `valueLte` | Local pre-check: rejects calls where `value > max` | +| `ERC20TransferAmountEnforcer` | `cumulativeSpend` | Local SpendTracker: rejects when cumulative `> max` | +| `NativeTokenTransferAmountEnforcer` | _(not yet mapped)_ | — | +| `LimitedCallsEnforcer` | _(not yet mapped)_ | — | +| `TimestampEnforcer` | `blockWindow` | Stored in caveatSpecs; not yet locally enforced | + +### Calldata argument caveats → M.\* patterns + +| CaveatSpec type / enforcer | M.\* pattern | Notes | +| ---------------------------------------------- | --------------------------- | ----------------------------------------------------------------------- | +| `allowedCalldata` at offset 4 (first arg) | Literal address value | Pins the first argument of transfer/approve to a specific address | +| `allowedCalldata` at offset N (any static arg) | Literal value (ABI-encoded) | Any static ABI type (address, uint256, bool, bytes32) at a known offset | +| _(no calldata constraint)_ | `M.string()` / `M.scalar()` | Unconstrained argument | + +### Overlap at a glance + +``` + Endo M.* patterns Gator enforcers + ┌────────────────────┐ ┌─────────────────────────┐ + │ │ │ │ + │ M.not() │ │ Stateful: │ + │ M.neq() │ │ ERC20Transfer │ + │ M.gt/gte/lt/ │ │ AmountEnforcer │ + │ lte() on args │ │ LimitedCalls │ + │ M.nat() │ │ NativeToken │ + │ M.splitRecord │ │ TransferAmount │ + │ M.splitArray │ │ │ + │ M.partial ┌────────────────────────┐ │ + │ M.record │ SHARED │ │ + │ M.array │ │ │ + │ │ Literal/eq pinning │ │ + │ │ AND (conjunction) │ │ + │ │ OR (disjunction) │ │ + │ │ Unconstrained │ │ + │ │ (any/string/scalar) │ │ + │ │ Temporal: │ │ + │ │ Timestamp │ │ + │ │ BlockNumber │ │ + │ └────────────────────────┘ │ + │ │ │ │ + └────────────────────┘ └──────────────────────┘ + + Endo-only: negation, Shared: equality, Gator-only: stateful + range checks on args, logic operators, tracking, execution + structural patterns, unconstrained, envelope, (target, + dynamic ABI types temporal constraints selector, value) +``` + +--- + +## What maps well + +For contracts with a **completely static ABI** (all arguments are fixed-size +types like address, uint256, bool, bytes32): + +1. **Literal pinning**: Fully supported via `AllowedCalldataEnforcer`. Each + pinned argument is one caveat. Maps to a literal value as the `M.*` pattern. + +2. **Conjunction**: Naturally expressed as multiple caveats on the same + delegation. `M.and` is implicit. + +3. **Disjunction**: Supported via `LogicalOrWrapperEnforcer`, but note that the + **redeemer** chooses which group to satisfy — all groups must represent + equally acceptable outcomes. + +4. **Unconstrained args**: Omit the enforcer. Use `M.string()` or `M.scalar()`. + +## What does not map + +1. **Range checks on calldata args**: `M.gt(n)`, `M.gte(n)`, `M.lt(n)`, + `M.lte(n)`, `M.nat()` have no calldata-level enforcer. `ValueLteEnforcer` + only constrains the execution's `value` field (native token amount). A custom + enforcer contract would be needed. + +2. **Negation**: `M.not(p)`, `M.neq(v)` have no on-chain equivalent. Gator + enforcers are allowlists, not denylists. + +3. **Dynamic ABI types**: `string`, `bytes`, arrays, and nested structs use ABI + offset indirection. `AllowedCalldataEnforcer` is fragile for these — you'd + need to pin the offset pointer, the length, and the data separately. Not + recommended. + +4. **Stateful patterns**: `M.*` patterns are stateless. Stateful enforcers + (`ERC20TransferAmountEnforcer`, `LimitedCallsEnforcer`, etc.) maintain + on-chain state across invocations. The twin's local trackers mirror this + state but are not authoritative. + +5. **Structural patterns**: `M.splitRecord`, `M.splitArray`, `M.partial` operate + on JS object/array structure that doesn't exist in flat ABI calldata. + +--- + +## The AllowedCalldataEnforcer + +The key bridge between the two systems is `AllowedCalldataEnforcer`. It +validates that a byte range of the execution calldata matches an expected value: + +``` +terms = [32-byte offset] ++ [expected bytes] +``` + +For a function with a static ABI, every argument occupies a fixed 32-byte slot +at a known offset from the start of calldata (after the 4-byte selector): + +| Arg index | Offset | +| --------- | ------- | +| 0 | 4 | +| 1 | 36 | +| 2 | 68 | +| n | 4 + 32n | + +This means independent arguments can each be constrained by stacking multiple +`allowedCalldata` caveats with different offsets. In `delegation-twin.ts`, +`allowedCalldata` entries at offset 4 are read from `caveatSpecs` and used to +narrow the first-argument pattern in the exo interface guard. From a44a085c87401a3dc1427145184f4aa891eb85eb Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Tue, 14 Apr 2026 14:53:29 -0400 Subject: [PATCH 19/31] test(evm-wallet-experiment): expose sendTransaction twin routing bug Transfer and approve twins only expose their own method name, not .call(). sendTransaction hardcodes E(twin).call() for all twin types, so routing through a provisioned transfer twin throws 'call is not a function on target'. Add two tests: one for the transfer twin (fails, demonstrating the bug) and one for the call twin (passes, control case). Co-Authored-By: Claude Sonnet 4.6 --- .../src/vats/coordinator-vat.test.ts | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/packages/evm-wallet-experiment/src/vats/coordinator-vat.test.ts b/packages/evm-wallet-experiment/src/vats/coordinator-vat.test.ts index b167c1ec8d..a77082b94f 100644 --- a/packages/evm-wallet-experiment/src/vats/coordinator-vat.test.ts +++ b/packages/evm-wallet-experiment/src/vats/coordinator-vat.test.ts @@ -4,6 +4,7 @@ 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, @@ -666,6 +667,62 @@ describe('coordinator-vat', () => { ); 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('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', () => { From 217f7e6d359c222107a7547398e4ba17e8844ba4 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Tue, 14 Apr 2026 14:55:25 -0400 Subject: [PATCH 20/31] fix(evm-wallet-experiment): dispatch sendTransaction twin by method name sendTransaction was hardcoding E(twin).call() for all provisioned twins. Transfer and approve twins only expose their own method name, so this threw 'call is not a function on target' at runtime. Track each twin's CatalogMethodName in a parallel coordinatorTwinMethods map. In sendTransaction, decode the ABI-encoded (address, uint256) calldata args and dispatch to E(twin).transfer() or E(twin).approve() for ERC-20 twins; fall through to E(twin).call() for call twins. Co-Authored-By: Claude Sonnet 4.6 --- .../src/vats/coordinator-vat.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/packages/evm-wallet-experiment/src/vats/coordinator-vat.ts b/packages/evm-wallet-experiment/src/vats/coordinator-vat.ts index 0eb9ce084a..0f1392d180 100644 --- a/packages/evm-wallet-experiment/src/vats/coordinator-vat.ts +++ b/packages/evm-wallet-experiment/src/vats/coordinator-vat.ts @@ -393,6 +393,9 @@ export function buildRootObject( 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; @@ -1826,6 +1829,15 @@ export function buildRootObject( // 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 + const data = tx.data ?? ('0x' as Hex); + 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), @@ -2442,6 +2454,10 @@ export function buildRootObject( readFn, }); coordinatorTwins.set(coercedGrant.delegation.id, twin); + coordinatorTwinMethods.set( + coercedGrant.delegation.id, + coercedGrant.methodName as CatalogMethodName, + ); return twin; }, From fe4a8c92f32dda68b636c4abd13d2a351bcb795b Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Tue, 14 Apr 2026 15:03:54 -0400 Subject: [PATCH 21/31] refactor(evm-wallet-experiment): remove dead addDelegation and getTwin from delegation vat Twins cannot live in the delegation vat because redeemFn/readFn closures cannot cross the CapTP vat boundary. The coordinator builds twins locally via provisionTwin() and uses storeDelegation() for persistence. The addDelegation and getTwin methods were never called anywhere and represent an abandoned design. Remove them along with the now-unused makeDelegationTwin import and the twins map. Co-Authored-By: Claude Sonnet 4.6 --- .../src/vats/delegation-vat.ts | 29 ------------------- 1 file changed, 29 deletions(-) diff --git a/packages/evm-wallet-experiment/src/vats/delegation-vat.ts b/packages/evm-wallet-experiment/src/vats/delegation-vat.ts index 562ea9f749..6637a76e07 100644 --- a/packages/evm-wallet-experiment/src/vats/delegation-vat.ts +++ b/packages/evm-wallet-experiment/src/vats/delegation-vat.ts @@ -3,7 +3,6 @@ import type { Logger } from '@metamask/logger'; import type { Baggage } from '@metamask/ocap-kernel'; import { DEFAULT_DELEGATION_MANAGER } from '../constants.ts'; -import { makeDelegationTwin } from '../lib/delegation-twin.ts'; import { computeDelegationId, makeDelegation, @@ -20,7 +19,6 @@ import type { DelegationGrant, DelegationMatchResult, Eip712TypedData, - Execution, Hex, } from '../types.ts'; @@ -62,9 +60,6 @@ export function buildRootObject( ) : new Map(); - // Twin exo references, keyed by delegation ID - const twins: Map> = new Map(); - /** * Persist the current delegations map to baggage. */ @@ -212,29 +207,5 @@ export function buildRootObject( delegations.set(delegation.id, delegation); persistDelegations(); }, - - async addDelegation( - grant: DelegationGrant, - redeemFn: (execution: Execution) => Promise, - readFn?: (opts: { to: Address; data: Hex }) => Promise, - ): Promise> { - const { delegation } = grant; - delegations.set(delegation.id, delegation); - persistDelegations(); - - const twin = makeDelegationTwin({ grant, redeemFn, readFn }); - twins.set(delegation.id, twin); - return twin; - }, - - async getTwin( - delegationId: string, - ): Promise> { - const twin = twins.get(delegationId); - if (!twin) { - throw new Error(`Twin not found for delegation: ${delegationId}`); - } - return twin; - }, }); } From 1a31e9ce7bd7c6b0bba10acda81ac23997daf92b Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Tue, 14 Apr 2026 16:11:48 -0400 Subject: [PATCH 22/31] test(evm-wallet-experiment): expose calldata decode crash for transfer twin A delegation with only allowedTargets (no erc20TransferAmount) matches a no-calldata tx because delegationMatchesAction skips allowedMethods when action.data is falsy. sendTransaction then tries to decode the twin's calldata args with data.slice() and BigInt('0x'), which throws a SyntaxError with no useful context. Co-Authored-By: Claude Sonnet 4.6 --- .../src/vats/coordinator-vat.test.ts | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/packages/evm-wallet-experiment/src/vats/coordinator-vat.test.ts b/packages/evm-wallet-experiment/src/vats/coordinator-vat.test.ts index a77082b94f..6218e51da9 100644 --- a/packages/evm-wallet-experiment/src/vats/coordinator-vat.test.ts +++ b/packages/evm-wallet-experiment/src/vats/coordinator-vat.test.ts @@ -704,6 +704,49 @@ describe('coordinator-vat', () => { 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', { From 3ec5e00ec031fcac0a0d8e14681e9b7b74fd5aeb Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Tue, 14 Apr 2026 16:12:20 -0400 Subject: [PATCH 23/31] fix(evm-wallet-experiment): validate calldata length before decoding transfer twin args data.slice(74, 138) returns an empty string when tx.data is missing or short, causing BigInt('0x') to throw a SyntaxError with no useful context. Guard the decode path with an explicit length check and throw a clear error. Co-Authored-By: Claude Sonnet 4.6 --- .../evm-wallet-experiment/src/vats/coordinator-vat.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/evm-wallet-experiment/src/vats/coordinator-vat.ts b/packages/evm-wallet-experiment/src/vats/coordinator-vat.ts index 0f1392d180..6cf79288d6 100644 --- a/packages/evm-wallet-experiment/src/vats/coordinator-vat.ts +++ b/packages/evm-wallet-experiment/src/vats/coordinator-vat.ts @@ -1833,7 +1833,15 @@ export function buildRootObject( 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); From f7782fb24a79742542cd8154970be8681529ac22 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Tue, 14 Apr 2026 16:22:29 -0400 Subject: [PATCH 24/31] fix(evm-wallet-experiment): add missing blockWindow caveatSpec in buildCallGrant buildErc20Grant pushes both the timestamp caveat and a blockWindow caveatSpec when validUntil is provided. buildCallGrant pushed only the timestamp caveat, so call grants with a time restriction had incomplete caveatSpecs and consumers couldn't discover the time window from the grant's structured description. Mirror the same caveatSpecs.push call. Co-Authored-By: Claude Sonnet 4.6 --- .../src/lib/delegation-grant.test.ts | 17 +++++++++++++++++ .../src/lib/delegation-grant.ts | 5 +++++ 2 files changed, 22 insertions(+) diff --git a/packages/evm-wallet-experiment/src/lib/delegation-grant.test.ts b/packages/evm-wallet-experiment/src/lib/delegation-grant.test.ts index 0768f2b9d4..bfe19748f5 100644 --- a/packages/evm-wallet-experiment/src/lib/delegation-grant.test.ts +++ b/packages/evm-wallet-experiment/src/lib/delegation-grant.test.ts @@ -155,6 +155,23 @@ describe('buildDelegationGrant', () => { 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 }, + ]); + }); }); }); diff --git a/packages/evm-wallet-experiment/src/lib/delegation-grant.ts b/packages/evm-wallet-experiment/src/lib/delegation-grant.ts index 565eaeb6c3..834015dc22 100644 --- a/packages/evm-wallet-experiment/src/lib/delegation-grant.ts +++ b/packages/evm-wallet-experiment/src/lib/delegation-grant.ts @@ -344,6 +344,11 @@ function buildCallGrant( chainId, }), ); + caveatSpecs.push({ + type: 'blockWindow', + after: 0n, + before: BigInt(validUntil), + }); } const delegation = makeDelegation({ From ef461a6f909243b044e66e21d5ab0a1d5c2608b1 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Tue, 14 Apr 2026 16:37:24 -0400 Subject: [PATCH 25/31] test(evm-wallet-experiment): expose spend tracker race condition Two concurrent transfer calls both pass the budget check before either commits, so spent never exceeds max during the check and both succeed even when their combined amount exceeds the limit. The rollback method exists for exactly this pattern but is never used. Co-Authored-By: Claude Sonnet 4.6 --- .../src/lib/delegation-twin.test.ts | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) 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 8b07f90a54..d06b77899e 100644 --- a/packages/evm-wallet-experiment/src/lib/delegation-twin.test.ts +++ b/packages/evm-wallet-experiment/src/lib/delegation-twin.test.ts @@ -142,6 +142,36 @@ describe('makeDelegationTwin', () => { const result = await twin.transfer(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() + .mockImplementationOnce( + async () => + new Promise((resolve) => { + resolveFirst = resolve; + }), + ) + .mockResolvedValue(TX_HASH); + + const twin = makeDelegationTwin({ + grant: makeTransferGrant(5n), + redeemFn, + }) as Record 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( + 'Insufficient budget', + ); + resolveFirst(TX_HASH); + expect(await first).toBe(TX_HASH); + }); }); describe('discoverability', () => { From 75894d671fa78564589377173520e7f405658295 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Tue, 14 Apr 2026 16:38:16 -0400 Subject: [PATCH 26/31] fix(evm-wallet-experiment): reserve spend budget before await, rollback on failure Committing after redeemFn resolved left a window where two concurrent calls could both pass the remaining() check and together exceed the budget. Commit before the await to atomically reserve the budget, then rollback if redeemFn throws. This is the pattern rollback() was designed for but was never called. Co-Authored-By: Claude Sonnet 4.6 --- .../src/lib/delegation-twin.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/evm-wallet-experiment/src/lib/delegation-twin.ts b/packages/evm-wallet-experiment/src/lib/delegation-twin.ts index 1247a99e4f..e21ea9be07 100644 --- a/packages/evm-wallet-experiment/src/lib/delegation-twin.ts +++ b/packages/evm-wallet-experiment/src/lib/delegation-twin.ts @@ -177,6 +177,9 @@ export function makeDelegationTwin( `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( @@ -184,11 +187,14 @@ export function makeDelegationTwin( normalizedArgs, ); - const txHash = await redeemFn(execution); - if (tracker && trackAmount !== undefined) { - tracker.commit(trackAmount); + try { + return await redeemFn(execution); + } catch (error) { + if (tracker && trackAmount !== undefined) { + tracker.rollback(trackAmount); + } + throw error; } - return txHash; }; const methods: Record unknown> = { From 3d07836d4a8a09cd1f1f81ef51ef270904d77a41 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Tue, 14 Apr 2026 16:48:15 -0400 Subject: [PATCH 27/31] test(evm-wallet-experiment): expose getBalance querying delegate instead of delegator The test was asserting encodeBalanceOf(BOB) where BOB is the delegate, masking the bug. The delegator (ALICE) holds the tokens; querying the delegate's balance is irrelevant to how much can be transferred via the delegation. Assert ALICE so the test catches the wrong address. Co-Authored-By: Claude Sonnet 4.6 --- packages/evm-wallet-experiment/src/lib/delegation-twin.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/evm-wallet-experiment/src/lib/delegation-twin.test.ts b/packages/evm-wallet-experiment/src/lib/delegation-twin.test.ts index d06b77899e..184485c712 100644 --- a/packages/evm-wallet-experiment/src/lib/delegation-twin.test.ts +++ b/packages/evm-wallet-experiment/src/lib/delegation-twin.test.ts @@ -232,7 +232,7 @@ describe('makeDelegationTwin', () => { expect(balance).toBe(1000000n); expect(readFn).toHaveBeenCalledWith({ to: TOKEN, - data: encodeBalanceOf(BOB), + data: encodeBalanceOf(ALICE), }); }); }); From 0c1be9b2282214cc9dd976a37bb646c085dd9316 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Tue, 14 Apr 2026 16:48:47 -0400 Subject: [PATCH 28/31] fix(evm-wallet-experiment): getBalance queries delegator balance, not delegate The delegator holds the tokens; the delegate only has permission to transfer them. encodeBalanceOf(delegation.delegate) returned the delegate's own balance, which is irrelevant to the delegation's spending capacity. Query delegation.delegator instead. Co-Authored-By: Claude Sonnet 4.6 --- packages/evm-wallet-experiment/src/lib/delegation-twin.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/evm-wallet-experiment/src/lib/delegation-twin.ts b/packages/evm-wallet-experiment/src/lib/delegation-twin.ts index e21ea9be07..e8ebf12271 100644 --- a/packages/evm-wallet-experiment/src/lib/delegation-twin.ts +++ b/packages/evm-wallet-experiment/src/lib/delegation-twin.ts @@ -217,7 +217,7 @@ export function makeDelegationTwin( methods.getBalance = async (): Promise => { const result = await readFn({ to: token, - data: encodeBalanceOf(delegation.delegate), + data: encodeBalanceOf(delegation.delegator), }); return decodeBalanceOfResult(result); }; From cc7c54f36917758306c1adc94c3f3043db407a63 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Wed, 15 Apr 2026 08:27:55 -0400 Subject: [PATCH 29/31] docs(evm-wallet-experiment): clarify salt generator comment in coordinator vat The previous comment claimed the per-vat generator prevents collisions even in the SES fallback, which overstates the guarantee. When crypto.getRandomValues is available (all real environments) salts are random regardless. The counter fallback is only reached in strict SES compartments that do not endow crypto. Co-Authored-By: Claude Sonnet 4.6 --- packages/evm-wallet-experiment/src/vats/coordinator-vat.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/evm-wallet-experiment/src/vats/coordinator-vat.ts b/packages/evm-wallet-experiment/src/vats/coordinator-vat.ts index 6cf79288d6..556558a661 100644 --- a/packages/evm-wallet-experiment/src/vats/coordinator-vat.ts +++ b/packages/evm-wallet-experiment/src/vats/coordinator-vat.ts @@ -374,9 +374,10 @@ export function buildRootObject( } }); - // Per-vat salt generator: each vat instance gets its own counter so that - // delegations created in separate vat lifetimes (or parallel vats) do not - // collide even when crypto.getRandomValues is unavailable (SES fallback). + // 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(), }); From d8282b962604f269d819642abd62279b10df2382 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Wed, 15 Apr 2026 08:43:33 -0400 Subject: [PATCH 30/31] fix(evm-wallet-experiment): update describeCaveat to use packed erc20TransferAmount offsets 56bbb6459 switched encodeErc20TransferAmount from ABI encoding to packed encoding to match the on-chain ERC20TransferAmountEnforcer, but describeCaveat was never updated. It checked for 130-char ABI-encoded terms, always found 106-char packed terms instead, and silently fell back to the generic 'ERC-20 transfer limit' string. Update the length check and slice offsets to match packed layout. Co-Authored-By: Claude Sonnet 4.6 --- .../evm-wallet-experiment/src/vats/coordinator-vat.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/evm-wallet-experiment/src/vats/coordinator-vat.ts b/packages/evm-wallet-experiment/src/vats/coordinator-vat.ts index 556558a661..870b344e5e 100644 --- a/packages/evm-wallet-experiment/src/vats/coordinator-vat.ts +++ b/packages/evm-wallet-experiment/src/vats/coordinator-vat.ts @@ -157,12 +157,12 @@ function describeCaveat(caveat: Caveat): string { case 'timestamp': return 'time-limited'; case 'erc20TransferAmount': { - // ABI-encoded (address, uint256): 12 bytes padding + 20 bytes address + 32 bytes uint256 - // In hex string: '0x' + 24 pad chars + 40 address chars + 64 amount chars = 130 chars - if (caveat.terms.length >= 130) { + // 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(26, 66)}`; - const amount = BigInt(`0x${caveat.terms.slice(66)}`); + 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 From fd1fecfae94e6feef45fc84b90134ffc23ba9d7d Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Wed, 15 Apr 2026 09:12:01 -0400 Subject: [PATCH 31/31] fix(evm-wallet-experiment): remove dead entropy field from e2e test TransferOptions and CallOptions no longer accept an entropy field since the refactor moved salt isolation into makeSaltGenerator() at the vat level. The field was silently discarded, so the per-run uniqueness guarantee described in the comment never held. Remove the unused entropy constant, its import, and the two call sites. Co-Authored-By: Claude Sonnet 4.6 --- .../test/e2e/docker/run-delegation-twin-e2e.mjs | 7 ------- 1 file changed, 7 deletions(-) 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 a723e1e831..b97cf4ee9f 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 @@ -32,7 +32,6 @@ import '@metamask/kernel-shims/endoify-node'; -import { randomBytes } from 'node:crypto'; import { readFile } from 'node:fs/promises'; import { makeDaemonClient } from './helpers/daemon-client.mjs'; @@ -99,10 +98,6 @@ function assert(condition, label) { console.log(`\n=== Delegation Twin E2E (${mode}) ===\n`); -// Per-run entropy so each test run produces unique delegation hashes even when -// the coordinator vat is freshly instantiated (counter reset to 0). -const entropy = `0x${randomBytes(32).toString('hex')}`; - // ── Test 1: Twin enforces cumulative spend locally ───────────────────────── console.log('--- Transfer twin: spend tracking ---'); @@ -119,7 +114,6 @@ const transferGrant = await homeClient.callVat( // JSON; coordinator-vat coerces it to BigInt before buildDelegationGrant. max: '5', chainId: CHAIN_ID, - entropy, }, ], ); @@ -189,7 +183,6 @@ const expiredGrant = await homeClient.callVat(homeKref, 'makeDelegationGrant', [ targets: [BURN_ADDRESS], chainId: CHAIN_ID, validUntil: expiredAt, - entropy, }, ]);