diff --git a/packages/evm-wallet-experiment/package.json b/packages/evm-wallet-experiment/package.json index 7036dd9234..8da7b67a63 100644 --- a/packages/evm-wallet-experiment/package.json +++ b/packages/evm-wallet-experiment/package.json @@ -58,10 +58,10 @@ "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", + "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", @@ -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/constants.ts b/packages/evm-wallet-experiment/src/constants.ts index dd3f66badb..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, @@ -160,6 +162,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 +203,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; diff --git a/packages/evm-wallet-experiment/src/index.ts b/packages/evm-wallet-experiment/src/index.ts index d5f78d5771..b0d7ca17fa 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, @@ -69,6 +73,7 @@ export { // Caveat utilities (for creating delegations externally) export { encodeAllowedTargets, + encodeAllowedCalldata, encodeAllowedMethods, encodeValueLte, encodeNativeTokenTransferAmount, @@ -103,7 +108,9 @@ export { finalizeDelegation, computeDelegationId, generateSalt, + makeSaltGenerator, } from './lib/delegation.ts'; +export type { SaltGenerator } from './lib/delegation.ts'; // UserOperation utilities export { @@ -170,3 +177,16 @@ 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, + makeDelegationGrantBuilder, +} from './lib/delegation-grant.ts'; + +// Twin factory +export { makeDelegationTwin } from './lib/delegation-twin.ts'; 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. diff --git a/packages/evm-wallet-experiment/src/lib/caveats.test.ts b/packages/evm-wallet-experiment/src/lib/caveats.test.ts index 2fa13d9489..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, @@ -21,11 +22,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,25 +36,40 @@ 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); }); }); + 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']; 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 +105,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); }); @@ -158,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 1f002a3eea..b395b6f204 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,30 @@ 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, + ); +} + +/** + * 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)}`; } /** @@ -22,7 +45,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 +88,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-grant.test.ts b/packages/evm-wallet-experiment/src/lib/delegation-grant.test.ts new file mode 100644 index 0000000000..bfe19748f5 --- /dev/null +++ b/packages/evm-wallet-experiment/src/lib/delegation-grant.test.ts @@ -0,0 +1,220 @@ +import { describe, expect, it } from 'vitest'; + +import type { Address } from '../types.ts'; +import { + buildDelegationGrant, + makeDelegationGrantBuilder, +} from './delegation-grant.ts'; +import { makeSaltGenerator } from './delegation.ts'; + +const ALICE = '0x1111111111111111111111111111111111111111' as Address; +const BOB = '0x2222222222222222222222222222222222222222' as Address; +const TOKEN = '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as Address; +const CHAIN_ID = 11155111; + +describe('buildDelegationGrant', () => { + describe('transfer', () => { + it('produces correct caveats', () => { + const grant = buildDelegationGrant('transfer', { + delegator: ALICE, + delegate: BOB, + token: TOKEN, + max: 1000n, + chainId: CHAIN_ID, + }); + + expect(grant.methodName).toBe('transfer'); + expect(grant.token).toBe(TOKEN); + expect(grant.delegation.delegator).toBe(ALICE); + expect(grant.delegation.delegate).toBe(BOB); + expect(grant.delegation.chainId).toBe(CHAIN_ID); + expect(grant.delegation.status).toBe('pending'); + + const caveatTypes = grant.delegation.caveats.map((cv) => cv.type); + expect(caveatTypes).toStrictEqual([ + 'allowedTargets', + 'allowedMethods', + 'erc20TransferAmount', + ]); + }); + + it('includes timestamp caveat only when validUntil provided', () => { + const withoutExpiry = buildDelegationGrant('transfer', { + delegator: ALICE, + delegate: BOB, + token: TOKEN, + max: 1000n, + chainId: CHAIN_ID, + }); + expect( + withoutExpiry.delegation.caveats.map((cv) => cv.type), + ).not.toContain('timestamp'); + + const withExpiry = buildDelegationGrant('transfer', { + delegator: ALICE, + delegate: BOB, + token: TOKEN, + max: 1000n, + chainId: CHAIN_ID, + validUntil: 1700000000, + }); + expect(withExpiry.delegation.caveats.map((cv) => cv.type)).toContain( + 'timestamp', + ); + }); + + it('caveatSpecs contain cumulativeSpend entry', () => { + const grant = buildDelegationGrant('transfer', { + delegator: ALICE, + delegate: BOB, + token: TOKEN, + max: 500n, + chainId: CHAIN_ID, + }); + + expect(grant.caveatSpecs).toStrictEqual([ + { type: 'cumulativeSpend', token: TOKEN, max: 500n }, + ]); + }); + + it('includes blockWindow caveatSpec when validUntil provided', () => { + const grant = buildDelegationGrant('transfer', { + delegator: ALICE, + delegate: BOB, + token: TOKEN, + max: 500n, + chainId: CHAIN_ID, + validUntil: 1700000000, + }); + + expect(grant.caveatSpecs).toStrictEqual([ + { type: 'cumulativeSpend', token: TOKEN, max: 500n }, + { type: 'blockWindow', after: 0n, before: 1700000000n }, + ]); + }); + }); + + describe('approve', () => { + it('produces correct caveats', () => { + const grant = buildDelegationGrant('approve', { + delegator: ALICE, + delegate: BOB, + token: TOKEN, + max: 2000n, + chainId: CHAIN_ID, + }); + + expect(grant.methodName).toBe('approve'); + expect(grant.token).toBe(TOKEN); + const caveatTypes = grant.delegation.caveats.map((cv) => cv.type); + expect(caveatTypes).toStrictEqual([ + 'allowedTargets', + 'allowedMethods', + 'erc20TransferAmount', + ]); + }); + }); + + describe('call', () => { + it('produces allowedTargets caveat for provided targets', () => { + const target1 = '0x3333333333333333333333333333333333333333' as Address; + const target2 = '0x4444444444444444444444444444444444444444' as Address; + const grant = buildDelegationGrant('call', { + delegator: ALICE, + delegate: BOB, + targets: [target1, target2], + chainId: CHAIN_ID, + }); + + expect(grant.methodName).toBe('call'); + expect(grant.caveatSpecs).toStrictEqual([]); + expect(grant.delegation.caveats[0]?.type).toBe('allowedTargets'); + }); + + it('includes valueLte caveat when maxValue provided', () => { + const grant = buildDelegationGrant('call', { + delegator: ALICE, + delegate: BOB, + targets: [TOKEN], + chainId: CHAIN_ID, + maxValue: 10000n, + }); + + const caveatTypes = grant.delegation.caveats.map((cv) => cv.type); + expect(caveatTypes).toContain('valueLte'); + }); + + it('does not include valueLte caveat when maxValue omitted', () => { + const grant = buildDelegationGrant('call', { + delegator: ALICE, + delegate: BOB, + targets: [TOKEN], + chainId: CHAIN_ID, + }); + + const caveatTypes = grant.delegation.caveats.map((cv) => cv.type); + expect(caveatTypes).not.toContain('valueLte'); + }); + + it('includes blockWindow caveatSpec when validUntil provided', () => { + const grant = buildDelegationGrant('call', { + delegator: ALICE, + delegate: BOB, + targets: [TOKEN], + chainId: CHAIN_ID, + validUntil: 1700000000, + }); + + expect(grant.delegation.caveats.map((cv) => cv.type)).toContain( + 'timestamp', + ); + expect(grant.caveatSpecs).toStrictEqual([ + { type: 'blockWindow', after: 0n, before: 1700000000n }, + ]); + }); + }); +}); + +describe('makeDelegationGrantBuilder', () => { + it('produces grants with the injected salt generator', () => { + let counter = 0; + const fixedSalt = `0x${'ab'.repeat(32)}`; + const saltGenerator = () => { + counter += 1; + return fixedSalt; + }; + const builder = makeDelegationGrantBuilder({ saltGenerator }); + + const grant = builder.buildDelegationGrant('transfer', { + delegator: ALICE, + delegate: BOB, + token: TOKEN, + max: 100n, + chainId: CHAIN_ID, + }); + + expect(grant.delegation.salt).toBe(fixedSalt); + expect(counter).toBe(1); + }); + + it('two builders with independent generators produce different salts', () => { + const gen1 = makeSaltGenerator('0x01' as `0x${string}`); + const gen2 = makeSaltGenerator('0x02' as `0x${string}`); + const builder1 = makeDelegationGrantBuilder({ saltGenerator: gen1 }); + const builder2 = makeDelegationGrantBuilder({ saltGenerator: gen2 }); + + const baseOpts = { + delegator: ALICE, + delegate: BOB, + token: TOKEN, + max: 100n, + chainId: CHAIN_ID, + }; + + const grant1 = builder1.buildDelegationGrant('transfer', baseOpts); + const grant2 = builder2.buildDelegationGrant('transfer', baseOpts); + + expect(grant1.delegation.salt).not.toBe(grant2.delegation.salt); + expect(grant1.delegation.id).not.toBe(grant2.delegation.id); + }); +}); diff --git a/packages/evm-wallet-experiment/src/lib/delegation-grant.ts b/packages/evm-wallet-experiment/src/lib/delegation-grant.ts new file mode 100644 index 0000000000..834015dc22 --- /dev/null +++ b/packages/evm-wallet-experiment/src/lib/delegation-grant.ts @@ -0,0 +1,363 @@ +import { + encodeAllowedCalldata, + encodeAllowedMethods, + encodeAllowedTargets, + encodeErc20TransferAmount, + encodeTimestamp, + encodeValueLte, + makeCaveat, +} from './caveats.ts'; +import { generateSalt, makeDelegation } from './delegation.ts'; +import type { SaltGenerator } from './delegation.ts'; +import { + ERC20_APPROVE_SELECTOR, + ERC20_TRANSFER_SELECTOR, + FIRST_ARG_OFFSET, +} from './erc20.ts'; +import type { + Address, + Caveat, + CaveatSpec, + DelegationGrant, + Hex, +} from '../types.ts'; + +const harden = globalThis.harden ?? ((value: T): T => value); + +/** + * Encode an address as a 32-byte ABI-encoded word (left-padded with zeros). + * + * @param address - The Ethereum address to encode. + * @returns The 0x-prefixed 32-byte hex string. + */ +function abiEncodeAddress(address: Address): Hex { + return `0x${address.slice(2).toLowerCase().padStart(64, '0')}`; +} + +type TransferOptions = { + delegator: Address; + delegate: Address; + token: Address; + max: bigint; + chainId: number; + validUntil?: number; + recipient?: Address; +}; + +type ApproveOptions = { + delegator: Address; + delegate: Address; + token: Address; + max: bigint; + chainId: number; + validUntil?: number; + spender?: Address; +}; + +type CallOptions = { + delegator: Address; + delegate: Address; + targets: Address[]; + chainId: number; + maxValue?: bigint; + validUntil?: number; +}; + +export function buildDelegationGrant( + method: 'transfer', + options: TransferOptions, +): DelegationGrant; +export function buildDelegationGrant( + method: 'approve', + options: ApproveOptions, +): DelegationGrant; +export function buildDelegationGrant( + method: 'call', + options: CallOptions, +): DelegationGrant; +/** + * Build an unsigned delegation grant for the given method. + * + * Uses {@link generateSalt} (module-level fallback) for salt generation. + * For vat usage where per-instance salt isolation matters, prefer + * {@link makeDelegationGrantBuilder} with a {@link makeSaltGenerator} instance. + * + * @param method - The catalog method name. + * @param options - Method-specific options. + * @returns An unsigned DelegationGrant. + */ +export function buildDelegationGrant( + method: 'transfer' | 'approve' | 'call', + options: TransferOptions | ApproveOptions | CallOptions, +): DelegationGrant { + return dispatchGrant(method, options, generateSalt); +} + +/** + * Create a delegation grant builder with an injected salt generator. + * + * The returned builder exposes the same {@link buildDelegationGrant} overloads + * but uses the provided {@link SaltGenerator} for every grant it builds. + * Instantiate once per vat (or per logical context) so that the generator's + * internal counter is isolated from other instances. + * + * @param options - Builder options. + * @param options.saltGenerator - The salt generator to use for all grants. + * @returns An object with a {@link buildDelegationGrant} method. + */ +export function makeDelegationGrantBuilder(options: { + saltGenerator: SaltGenerator; +}): { + buildDelegationGrant( + method: 'transfer', + opts: TransferOptions, + ): DelegationGrant; + buildDelegationGrant( + method: 'approve', + opts: ApproveOptions, + ): DelegationGrant; + buildDelegationGrant(method: 'call', opts: CallOptions): DelegationGrant; +} { + const { saltGenerator } = options; + function build(method: 'transfer', opts: TransferOptions): DelegationGrant; + function build(method: 'approve', opts: ApproveOptions): DelegationGrant; + function build(method: 'call', opts: CallOptions): DelegationGrant; + /** + * @param method - The catalog method name. + * @param opts - Method-specific grant options. + * @returns An unsigned DelegationGrant. + */ + function build( + method: 'transfer' | 'approve' | 'call', + opts: TransferOptions | ApproveOptions | CallOptions, + ): DelegationGrant { + return dispatchGrant(method, opts, saltGenerator); + } + return harden({ buildDelegationGrant: build }); +} + +/** + * @param method - The catalog method name. + * @param options - Method-specific grant options. + * @param saltGenerator - Salt generator for delegation uniqueness. + * @returns An unsigned DelegationGrant. + */ +function dispatchGrant( + method: 'transfer' | 'approve' | 'call', + options: TransferOptions | ApproveOptions | CallOptions, + saltGenerator: SaltGenerator, +): DelegationGrant { + switch (method) { + case 'transfer': + return buildTransferGrant(options as TransferOptions, saltGenerator); + case 'approve': + return buildApproveGrant(options as ApproveOptions, saltGenerator); + case 'call': + return buildCallGrant(options as CallOptions, saltGenerator); + default: + throw new Error(`Unknown method: ${String(method)}`); + } +} + +type Erc20GrantOptions = { + methodName: 'transfer' | 'approve'; + selector: Hex; + delegator: Address; + delegate: Address; + token: Address; + max: bigint; + chainId: number; + validUntil?: number; + restrictedAddress?: Address; + saltGenerator: SaltGenerator; +}; + +/** + * Build a delegation grant for an ERC-20 method (transfer or approve). + * + * @param options - ERC-20 grant options. + * @param options.methodName - The catalog method name ('transfer' or 'approve'). + * @param options.selector - The ERC-20 function selector. + * @param options.delegator - The delegating account address. + * @param options.delegate - The delegate account address. + * @param options.token - The ERC-20 token contract address. + * @param options.max - The maximum token amount allowed. + * @param options.chainId - The chain ID. + * @param options.validUntil - Optional Unix timestamp after which the delegation expires. + * @param options.restrictedAddress - Optional address to lock the first argument to. + * @param options.saltGenerator - Salt generator for delegation uniqueness. + * @returns An unsigned DelegationGrant for the given ERC-20 method. + */ +function buildErc20Grant({ + methodName, + selector, + delegator, + delegate, + token, + max, + chainId, + validUntil, + restrictedAddress, + saltGenerator, +}: Erc20GrantOptions): DelegationGrant { + const caveats: Caveat[] = [ + makeCaveat({ + type: 'allowedTargets', + terms: encodeAllowedTargets([token]), + chainId, + }), + makeCaveat({ + type: 'allowedMethods', + terms: encodeAllowedMethods([selector]), + chainId, + }), + makeCaveat({ + type: 'erc20TransferAmount', + terms: encodeErc20TransferAmount({ token, amount: max }), + chainId, + }), + ]; + + const caveatSpecs: CaveatSpec[] = [{ type: 'cumulativeSpend', token, max }]; + + if (restrictedAddress !== undefined) { + const value = abiEncodeAddress(restrictedAddress); + caveats.push( + makeCaveat({ + type: 'allowedCalldata', + terms: encodeAllowedCalldata({ dataStart: FIRST_ARG_OFFSET, value }), + chainId, + }), + ); + caveatSpecs.push({ + type: 'allowedCalldata', + dataStart: FIRST_ARG_OFFSET, + value, + }); + } + + if (validUntil !== undefined) { + caveats.push( + makeCaveat({ + type: 'timestamp', + terms: encodeTimestamp({ after: 0, before: validUntil }), + chainId, + }), + ); + caveatSpecs.push({ + type: 'blockWindow', + after: 0n, + before: BigInt(validUntil), + }); + } + + const delegation = makeDelegation({ + delegator, + delegate, + caveats, + chainId, + saltGenerator, + }); + + return harden({ delegation, methodName, caveatSpecs, token }); +} + +/** + * Build a transfer delegation grant. + * + * @param options - Transfer grant options. + * @param saltGenerator - Salt generator for delegation uniqueness. + * @returns An unsigned DelegationGrant for ERC-20 transfers. + */ +function buildTransferGrant( + options: TransferOptions, + saltGenerator: SaltGenerator, +): DelegationGrant { + return buildErc20Grant({ + ...options, + methodName: 'transfer', + selector: ERC20_TRANSFER_SELECTOR, + restrictedAddress: options.recipient, + saltGenerator, + }); +} + +/** + * Build an approve delegation grant. + * + * @param options - Approve grant options. + * @param saltGenerator - Salt generator for delegation uniqueness. + * @returns An unsigned DelegationGrant for ERC-20 approvals. + */ +function buildApproveGrant( + options: ApproveOptions, + saltGenerator: SaltGenerator, +): DelegationGrant { + return buildErc20Grant({ + ...options, + methodName: 'approve', + selector: ERC20_APPROVE_SELECTOR, + restrictedAddress: options.spender, + saltGenerator, + }); +} + +/** + * Build a raw call delegation grant. + * + * @param options - Call grant options. + * @param saltGenerator - Salt generator for delegation uniqueness. + * @returns An unsigned DelegationGrant for raw calls. + */ +function buildCallGrant( + options: CallOptions, + saltGenerator: SaltGenerator, +): DelegationGrant { + const { delegator, delegate, targets, chainId, maxValue, validUntil } = + options; + const caveats: Caveat[] = [ + makeCaveat({ + type: 'allowedTargets', + terms: encodeAllowedTargets(targets), + chainId, + }), + ]; + + const caveatSpecs: CaveatSpec[] = []; + + if (maxValue !== undefined) { + caveats.push( + makeCaveat({ + type: 'valueLte', + terms: encodeValueLte(maxValue), + chainId, + }), + ); + caveatSpecs.push({ type: 'valueLte', max: maxValue }); + } + + if (validUntil !== undefined) { + caveats.push( + makeCaveat({ + type: 'timestamp', + terms: encodeTimestamp({ after: 0, before: validUntil }), + chainId, + }), + ); + caveatSpecs.push({ + type: 'blockWindow', + after: 0n, + before: BigInt(validUntil), + }); + } + + const delegation = makeDelegation({ + delegator, + delegate, + caveats, + chainId, + saltGenerator, + }); + + return harden({ delegation, methodName: 'call', caveatSpecs }); +} diff --git a/packages/evm-wallet-experiment/src/lib/delegation-twin.test.ts b/packages/evm-wallet-experiment/src/lib/delegation-twin.test.ts new file mode 100644 index 0000000000..184485c712 --- /dev/null +++ b/packages/evm-wallet-experiment/src/lib/delegation-twin.test.ts @@ -0,0 +1,361 @@ +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'; + +let lastInterfaceGuard: unknown; + +vi.mock('@metamask/kernel-utils/discoverable', () => ({ + makeDiscoverableExo: ( + _name: string, + methods: Record unknown>, + methodSchema: Record, + interfaceGuard?: unknown, + ) => { + lastInterfaceGuard = interfaceGuard; + return { + ...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); + }); + + 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', () => { + 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(ALICE), + }); + }); + }); + + describe('call twin', () => { + it('builds raw execution from args', async () => { + const redeemFn = vi.fn().mockResolvedValue(TX_HASH); + const target = '0x3333333333333333333333333333333333333333' as Address; + const twin = makeDelegationTwin({ + grant: makeCallGrant(), + redeemFn, + }) as Record Promise>; + + 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'); + }); + }); + + 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'); + }); + + 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 new file mode 100644 index 0000000000..e8ebf12271 --- /dev/null +++ b/packages/evm-wallet-experiment/src/lib/delegation-twin.ts @@ -0,0 +1,240 @@ +import { M } from '@endo/patterns'; +import type { MethodSchema } from '@metamask/kernel-utils'; +import { makeDiscoverableExo } from '@metamask/kernel-utils/discoverable'; + +import { + decodeBalanceOfResult, + encodeBalanceOf, + FIRST_ARG_OFFSET, +} from './erc20.ts'; +import { GET_BALANCE_SCHEMA, METHOD_CATALOG } from './method-catalog.ts'; +import type { CatalogMethodName } from './method-catalog.ts'; +import type { + Address, + CaveatSpec, + DelegationGrant, + Execution, + Hex, +} from '../types.ts'; + +const METHODS_WITH_ADDRESS_ARG: ReadonlySet = new Set([ + 'transfer', + 'approve', +]); + +/** + * Extract a restricted address from an `allowedCalldata` caveat spec that + * pins the first argument (offset 4, 32-byte ABI-encoded address). + * + * @param caveatSpecs - The caveat specs to search. + * @returns The restricted address, or undefined if none found. + */ +function findRestrictedAddress(caveatSpecs: CaveatSpec[]): Address | undefined { + const spec = caveatSpecs.find( + (cs): cs is CaveatSpec & { type: 'allowedCalldata' } => + cs.type === 'allowedCalldata' && cs.dataStart === FIRST_ARG_OFFSET, + ); + if (!spec) { + return undefined; + } + // The value is a 32-byte ABI-encoded address; extract the last 40 hex chars. + return `0x${spec.value.slice(-40)}`; +} + +/** + * Build the method guard for a catalog method, optionally restricting the + * first (address) argument to a single literal value. + * + * @param methodName - The catalog method name. + * @param restrictAddress - If provided, lock the first arg to this literal. + * @returns A method guard for use in an InterfaceGuard. + */ +function buildMethodGuard( + methodName: CatalogMethodName, + restrictAddress?: Address, +): ReturnType { + const addrGuard = + restrictAddress !== undefined && METHODS_WITH_ADDRESS_ARG.has(methodName) + ? restrictAddress + : M.string(); + + switch (methodName) { + case 'transfer': + case 'approve': + return M.callWhen(addrGuard, M.scalar()).returns(M.string()); + case 'call': + return M.callWhen(M.string(), M.scalar(), M.string()).returns(M.string()); + default: + throw new Error(`Unknown catalog method: ${String(methodName)}`); + } +} + +type SpendTracker = { + spent: bigint; + max: bigint; + remaining: () => bigint; + commit: (amount: bigint) => void; + rollback: (amount: bigint) => void; +}; + +/** + * Create a spend tracker for a cumulative-spend caveat spec. + * + * @param spec - The cumulative-spend caveat spec. + * @returns A spend tracker with commit/rollback semantics. + */ +function makeSpendTracker( + spec: CaveatSpec & { type: 'cumulativeSpend' }, +): SpendTracker { + let spent = 0n; + return { + get spent() { + return spent; + }, + max: spec.max, + remaining: () => spec.max - spent, + commit: (amount: bigint) => { + spent += amount; + }, + rollback: (amount: bigint) => { + spent -= amount; + }, + }; +} + +/** + * Find and create a spend tracker from a list of caveat specs. + * + * @param caveatSpecs - The caveat specs to search. + * @returns A spend tracker if a cumulative-spend spec is found. + */ +function findSpendTracker(caveatSpecs: CaveatSpec[]): SpendTracker | undefined { + const spec = caveatSpecs.find( + (cs): cs is CaveatSpec & { type: 'cumulativeSpend' } => + cs.type === 'cumulativeSpend', + ); + return spec ? makeSpendTracker(spec) : undefined; +} + +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 CatalogMethodName]; + if (!entry) { + throw new Error(`Unknown method in grant: ${methodName}`); + } + + const tracker = findSpendTracker(caveatSpecs); + const valueLteSpec = caveatSpecs.find( + (cs): cs is CaveatSpec & { type: 'valueLte' } => cs.type === 'valueLte', + ); + const { token } = grant; + const idPrefix = delegation.id.slice(0, 12); + + const primaryMethod = async (...args: unknown[]): Promise => { + // Coerce args[1] (amount/value) to BigInt for transfer, approve, and call + // — necessary when args arrive as strings over the daemon JSON-RPC boundary. + const normalizedArgs = + args.length > 1 + ? [ + args[0], + BigInt(args[1] as string | number | bigint), + ...args.slice(2), + ] + : args; + + // Local valueLte check for call twins (mirrors on-chain ValueLteEnforcer). + if (valueLteSpec !== undefined) { + const value = normalizedArgs[1] as bigint; + if (value > valueLteSpec.max) { + throw new Error(`Value ${value} exceeds limit ${valueLteSpec.max}`); + } + } + + let trackAmount: bigint | undefined; + if (tracker) { + trackAmount = normalizedArgs[1] as bigint; + if (trackAmount > tracker.remaining()) { + throw new Error( + `Insufficient budget: requested ${trackAmount}, remaining ${tracker.remaining()}`, + ); + } + // Reserve before the await so concurrent calls see the updated budget + // and cannot both pass the check. Roll back if redeemFn fails. + tracker.commit(trackAmount); + } + + const execution = entry.buildExecution( + token ?? ('' as Address), + normalizedArgs, + ); + + try { + return await redeemFn(execution); + } catch (error) { + if (tracker && trackAmount !== undefined) { + tracker.rollback(trackAmount); + } + throw error; + } + }; + + const methods: Record unknown> = { + [methodName]: primaryMethod, + }; + const schema: Record = { + [methodName]: entry.schema, + }; + + const restrictedAddress = findRestrictedAddress(caveatSpecs); + + const methodGuards: Record> = { + [methodName]: buildMethodGuard( + methodName as CatalogMethodName, + restrictedAddress, + ), + }; + + if (readFn && token) { + methods.getBalance = async (): Promise => { + const result = await readFn({ + to: token, + data: encodeBalanceOf(delegation.delegator), + }); + return decodeBalanceOfResult(result); + }; + schema.getBalance = GET_BALANCE_SCHEMA; + methodGuards.getBalance = M.callWhen().returns(M.bigint()); + } + + const interfaceGuard = M.interface( + `DelegationTwin:${methodName}:${idPrefix}`, + methodGuards, + { defaultGuards: 'passable' }, + ); + + return makeDiscoverableExo( + `DelegationTwin:${methodName}:${idPrefix}`, + methods, + schema, + interfaceGuard, + ); +} 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 56ead1f863..b407164d82 100644 --- a/packages/evm-wallet-experiment/src/lib/delegation.ts +++ b/packages/evm-wallet-experiment/src/lib/delegation.ts @@ -48,37 +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(counter). + * 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. * - * @returns A hex-encoded random salt. + * @param entropy - Optional caller-supplied entropy hex string. When provided + * and crypto is unavailable, mixed into the counter hash so that separate + * vat instances produce distinct salts even though both start at counter 1. + * @returns A salt generator function. */ -export function generateSalt(): 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: keccak256(counter). Unique per vat lifetime but not - // cryptographically random. The salt only needs uniqueness, not - // unpredictability. - saltCounter += 1; - return keccak256(encodePacked(['uint256'], [BigInt(saltCounter)])); + // SES fallback: unique per generator lifetime but not cryptographically random. + // The salt only needs uniqueness, not unpredictability. + let counter = 0; + if (entropy !== undefined) { + return () => { + counter += 1; + return keccak256( + encodePacked(['bytes', 'uint256'], [entropy, BigInt(counter)]), + ); + }; + } + 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. * @@ -88,6 +115,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.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. */ @@ -97,9 +126,10 @@ export function makeDelegation(options: { caveats: Caveat[]; chainId: number; salt?: Hex; + saltGenerator?: SaltGenerator; authority?: Hex; }): Delegation { - const salt = options.salt ?? generateSalt(); + const salt = options.salt ?? (options.saltGenerator ?? generateSalt)(); const authority = options.authority ?? ROOT_AUTHORITY; const id = computeDelegationId({ @@ -192,10 +222,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 +242,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 +307,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, 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)`. */ 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..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', @@ -322,6 +323,48 @@ 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, + }), + object({ + type: literal('allowedCalldata'), + dataStart: number(), + value: HexStruct, + }), + object({ + type: literal('valueLte'), + max: 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.test.ts b/packages/evm-wallet-experiment/src/vats/coordinator-vat.test.ts index b167c1ec8d..6218e51da9 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,105 @@ 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('rejects with a clear error when calldata is missing for a transfer twin', async () => { + // delegationMatchesAction skips the allowedMethods check when + // action.data is falsy, and skips erc20TransferAmount when the + // delegation doesn't have that caveat. A delegation with only + // allowedTargets therefore matches a no-calldata tx. The decode path + // must validate length before slicing or BigInt('0x') throws a + // SyntaxError with no useful context. + const accounts = await coordinator.getAccounts(); + + // Create a delegation with only allowedTargets (no erc20TransferAmount) + // so it will match the no-data tx below. + const delegation = await coordinator.createDelegation({ + delegate: accounts[0], + caveats: [ + makeCaveat({ + type: 'allowedTargets', + terms: encodeAllowedTargets([TOKEN]), + }), + ], + chainId: 1, + }); + + // Build the grant manually to attach a transfer twin without the + // erc20TransferAmount caveat that would otherwise guard the decode path. + const grant = { + delegation, + methodName: 'transfer', + caveatSpecs: [], + token: TOKEN, + }; + await coordinator.provisionTwin(grant); + + await expect( + coordinator.sendTransaction({ + from: accounts[0], + to: TOKEN, + // no data — delegation matches, twin found, decode must not crash + value: '0x0' as Hex, + chainId: 1, + }), + ).rejects.toThrow('calldata too short'); + }); + + it('routes through a call twin using the call method', async () => { + const accounts = await coordinator.getAccounts(); + const grant = await coordinator.makeDelegationGrant('call', { + delegate: accounts[0], + targets: [TARGET], + chainId: 1, + }); + await coordinator.provisionTwin(grant); + + const result = await coordinator.sendTransaction({ + from: accounts[0], + to: TARGET, + data: '0xdeadbeef' as Hex, + value: '0x0' as Hex, + chainId: 1, + }); + expect(result).toBe('0xuserophash'); + }); + }); }); describe('signMessage', () => { diff --git a/packages/evm-wallet-experiment/src/vats/coordinator-vat.ts b/packages/evm-wallet-experiment/src/vats/coordinator-vat.ts index e30ea984bb..870b344e5e 100644 --- a/packages/evm-wallet-experiment/src/vats/coordinator-vat.ts +++ b/packages/evm-wallet-experiment/src/vats/coordinator-vat.ts @@ -3,6 +3,18 @@ import { makeDefaultExo } from '@metamask/kernel-utils/exo'; import { Logger } from '@metamask/logger'; import type { Baggage } from '@metamask/ocap-kernel'; +import { + ENFORCER_CONTRACT_KEY_MAP, + PLACEHOLDER_CONTRACTS, + registerChainContracts, +} from '../constants.ts'; +import type { ChainContracts } from '../constants.ts'; +import { + buildDelegationGrant, + makeDelegationGrantBuilder, +} from '../lib/delegation-grant.ts'; +import { makeDelegationTwin } from '../lib/delegation-twin.ts'; +import { makeSaltGenerator } from '../lib/delegation.ts'; import { decodeAllowanceResult, decodeBalanceOfResult, @@ -16,6 +28,7 @@ import { encodeSymbol, encodeTransfer, } from '../lib/erc20.ts'; +import type { CatalogMethodName } from '../lib/method-catalog.ts'; import { buildBatchExecuteCallData, buildSdkBatchRedeemCallData, @@ -36,6 +49,7 @@ import type { ChainConfig, CreateDelegationOptions, Delegation, + DelegationGrant, DelegationMatchResult, Eip712TypedData, Execution, @@ -143,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 @@ -360,10 +374,29 @@ export function buildRootObject( } }); + // Per-vat salt generator so each vat instance has an independent counter + // rather than sharing the module-level one. When crypto.getRandomValues is + // available (Node.js, browsers) salts are random; the counter fallback is + // only used in strict SES compartments that do not endow crypto. + const grantBuilder = makeDelegationGrantBuilder({ + saltGenerator: makeSaltGenerator(), + }); + // References to other vats (set during bootstrap) let keyringVat: KeyringFacet | undefined; let providerVat: ProviderFacet | undefined; let delegationVat: DelegationFacet | undefined; + + // Twins provisioned via provisionTwin(), keyed by delegation ID. + // redeemFn/readFn closures cannot cross the CapTP vat boundary, so twins + // live here rather than in the delegation vat. + const coordinatorTwins = new Map< + string, + ReturnType + >(); + // Method name for each provisioned twin, used by sendTransaction to dispatch + // to the correct method (transfer/approve twins don't expose .call()). + const coordinatorTwinMethods = new Map(); let issuerService: OcapURLIssuerFacet | undefined; let redemptionService: OcapURLRedemptionFacet | undefined; @@ -1588,6 +1621,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({ @@ -1778,6 +1826,33 @@ 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) { + const twinMethod = coordinatorTwinMethods.get(delegation.id); + if (twinMethod === 'transfer' || twinMethod === 'approve') { + // Decode the ABI-encoded (address, uint256) calldata args. + // Layout (after '0x'): 8 selector + 64 address word + 64 amount word + // = 138 chars total. Validate before slicing to avoid BigInt('0x') + // SyntaxError when calldata is missing or truncated. + const data = tx.data ?? ('0x' as Hex); + if (data.length < 138) { + throw new Error( + `Cannot route through ${twinMethod} twin: calldata too short ` + + `(${data.length} chars, need 138)`, + ); + } + const addrArg = `0x${data.slice(34, 74)}`; + const amountArg = BigInt(`0x${data.slice(74, 138)}`); + return E(twin)[twinMethod](addrArg, amountArg); + } + return E(twin).call( + tx.to, + tx.value ?? ('0x0' as Hex), + tx.data ?? ('0x' as Hex), + ); + } return submitDelegationUserOp({ delegations: [delegation], execution: { @@ -2230,6 +2305,171 @@ 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()); + + // Coerce numeric options that arrive as strings over the JSON-RPC boundary + // (the daemon queueMessage protocol only carries plain JSON). + const rawOptions = { delegator, ...options }; + const coercedOptions = { + ...rawOptions, + ...(rawOptions.max !== undefined && { + max: BigInt(rawOptions.max as string | number | bigint), + }), + ...(rawOptions.maxValue !== undefined && { + maxValue: BigInt(rawOptions.maxValue as string | number | bigint), + }), + }; + + const grant = grantBuilder.buildDelegationGrant( + method, + coercedOptions as Parameters[1], + ); + + // Sign the delegation via the existing create → prepare → sign → store flow + const { delegation } = grant; + + // Determine signing function (same logic as createDelegation) + let signTypedDataFn: + | ((data: Eip712TypedData) => Promise) + | undefined; + + if (keyringVat) { + const accounts = await E(keyringVat).getAccounts(); + if (accounts.length > 0) { + const kv = keyringVat; + signTypedDataFn = async (data: Eip712TypedData) => + E(kv).signTypedData(data); + } + } + + if (!signTypedDataFn && externalSigner) { + const accounts = await E(externalSigner).getAccounts(); + if (accounts.length > 0) { + const ext = externalSigner; + const from = accounts[0]; + signTypedDataFn = async (data: Eip712TypedData) => + E(ext).signTypedData(data, from); + } + } + + if (!signTypedDataFn) { + throw new Error('No signing authority available'); + } + + // Store, prepare, sign, and finalize the delegation + const stored = await E(delegationVat).createDelegation({ + delegate: delegation.delegate, + caveats: delegation.caveats, + chainId: delegation.chainId, + salt: delegation.salt, + delegator, + }); + + const typedData = await E(delegationVat).prepareDelegationForSigning( + stored.id, + ); + const signature = await signTypedDataFn(typedData); + await E(delegationVat).storeSigned(stored.id, signature); + + const signedDelegation = await E(delegationVat).getDelegation(stored.id); + + return harden({ + ...grant, + delegation: signedDelegation, + }); + }, + + async provisionTwin(grant: DelegationGrant): Promise { + if (!delegationVat) { + throw new Error('Delegation vat not available'); + } + + // Coerce BigInt fields in caveatSpecs — they arrive as strings when the + // grant crosses the daemon JSON-RPC boundary. + const coercedGrant: DelegationGrant = harden({ + ...grant, + caveatSpecs: grant.caveatSpecs.map((spec) => { + if (spec.type === 'cumulativeSpend') { + return harden({ + ...spec, + max: BigInt(spec.max as unknown as string | number | bigint), + }); + } + if (spec.type === 'blockWindow') { + return harden({ + ...spec, + after: BigInt(spec.after as unknown as string | number | bigint), + before: BigInt( + spec.before as unknown as string | number | bigint, + ), + }); + } + if (spec.type === 'valueLte') { + return harden({ + ...spec, + max: BigInt(spec.max as unknown as string | number | bigint), + }); + } + return spec; + }), + }); + + const { delegation } = coercedGrant; + + // Build redeemFn closure that submits a delegation UserOp + const redeemFn = async (execution: Execution): Promise => { + return submitDelegationUserOp({ + delegations: [delegation], + execution, + }); + }; + + // Build readFn closure if provider is available + let readFn: + | ((opts: { to: Address; data: Hex }) => Promise) + | undefined; + if (providerVat) { + const pv = providerVat; + readFn = async (opts: { to: Address; data: Hex }): Promise => { + const result = await E(pv).request('eth_call', [ + { to: opts.to, data: opts.data }, + 'latest', + ]); + return result as Hex; + }; + } + + // Create the twin locally — redeemFn/readFn are closures that cannot + // cross the CapTP vat boundary, so we build the twin here and store + // the delegation in the delegation vat via a plain-data call. + await E(delegationVat).storeDelegation(coercedGrant); + const twin = makeDelegationTwin({ + grant: coercedGrant, + redeemFn, + readFn, + }); + coordinatorTwins.set(coercedGrant.delegation.id, twin); + coordinatorTwinMethods.set( + coercedGrant.delegation.id, + coercedGrant.methodName as CatalogMethodName, + ); + return twin; + }, + // ------------------------------------------------------------------ // Delegation redemption (ERC-4337) // ------------------------------------------------------------------ diff --git a/packages/evm-wallet-experiment/src/vats/delegation-vat.ts b/packages/evm-wallet-experiment/src/vats/delegation-vat.ts index 0d71a43bd7..6637a76e07 100644 --- a/packages/evm-wallet-experiment/src/vats/delegation-vat.ts +++ b/packages/evm-wallet-experiment/src/vats/delegation-vat.ts @@ -16,6 +16,7 @@ import type { Address, CreateDelegationOptions, Delegation, + DelegationGrant, DelegationMatchResult, Eip712TypedData, Hex, @@ -200,5 +201,11 @@ export function buildRootObject( ); persistDelegations(); }, + + async storeDelegation(grant: DelegationGrant): Promise { + const { delegation } = grant; + delegations.set(delegation.id, delegation); + persistDelegations(); + }, }); } 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..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 @@ -13,6 +13,7 @@ import type { } from './helpers/docker-e2e-kernel-services.ts'; import { callVat, + dockerExec, evmRpc, isStackHealthy, readContracts, @@ -321,6 +322,39 @@ 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 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); + }); }, ); }); 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..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 @@ -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 { @@ -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/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..b97cf4ee9f --- /dev/null +++ b/packages/evm-wallet-experiment/test/e2e/docker/run-delegation-twin-e2e.mjs @@ -0,0 +1,221 @@ +/* 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 { 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`); + +// ── 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, + }, + ], +); + +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, + }, +]); + +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); 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); 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 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"