diff --git a/packages/evm-wallet-experiment/package.json b/packages/evm-wallet-experiment/package.json index a2d73df7f..e39be3563 100644 --- a/packages/evm-wallet-experiment/package.json +++ b/packages/evm-wallet-experiment/package.json @@ -58,6 +58,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 dd3f66bad..2153d5a0a 100644 --- a/packages/evm-wallet-experiment/src/constants.ts +++ b/packages/evm-wallet-experiment/src/constants.ts @@ -77,6 +77,7 @@ export const PLACEHOLDER_CONTRACTS: ChainContracts = harden({ enforcers: { allowedTargets: '0x0000000000000000000000000000000000000001' as Address, allowedMethods: '0x0000000000000000000000000000000000000002' as Address, + allowedCalldata: '0x0000000000000000000000000000000000000008' as Address, valueLte: '0x0000000000000000000000000000000000000003' as Address, nativeTokenTransferAmount: '0x0000000000000000000000000000000000000007' as Address, @@ -101,6 +102,7 @@ const SHARED_DELEGATION_MANAGER: Address = const SHARED_ENFORCERS: Record = harden({ allowedTargets: '0x7F20f61b1f09b08D970938F6fa563634d65c4EeB' as Address, allowedMethods: '0x2c21fD0Cb9DC8445CB3fb0DC5E7Bb0Aca01842B5' as Address, + allowedCalldata: '0xc2b0d624c1c4319760c96503ba27c347f3260f55' as Address, valueLte: '0x92Bf12322527cAA612fd31a0e810472BBB106A8F' as Address, nativeTokenTransferAmount: '0xF71af580b9c3078fbc2BBF16FbB8EEd82b330320' as Address, diff --git a/packages/evm-wallet-experiment/src/index.ts b/packages/evm-wallet-experiment/src/index.ts index 1924fab0e..681483a46 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, @@ -169,3 +174,13 @@ export type { MetaMaskSigner, MetaMaskSignerOptions, } from './lib/metamask-signer.ts'; + +// Method catalog +export { METHOD_CATALOG } from './lib/method-catalog.ts'; +export type { CatalogMethodName } from './lib/method-catalog.ts'; + +// Grant builder +export { buildDelegationGrant } from './lib/delegation-grant.ts'; + +// Twin factory +export { makeDelegationTwin } from './lib/delegation-twin.ts'; diff --git a/packages/evm-wallet-experiment/src/lib/GATOR.md b/packages/evm-wallet-experiment/src/lib/GATOR.md new file mode 100644 index 000000000..709786068 --- /dev/null +++ b/packages/evm-wallet-experiment/src/lib/GATOR.md @@ -0,0 +1,175 @@ +# Gator Enforcers and Endo Patterns + +This document maps the constraint surface of [MetaMask Delegation Framework +("Gator")](https://github.com/MetaMask/delegation-framework) caveat enforcers +onto [Endo](https://github.com/endojs/endo) `M.*` pattern matchers from +`@endo/patterns`, and scopes out what level of integration is achievable. + +## Overlap at a glance + +For a contract with a completely static ABI: + +``` + 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) + (feasibly) +``` + +## Background + +A **delegation** in Gator authorizes a delegate to execute transactions on +behalf of a delegator, subject to **caveats**. Each caveat is an on-chain +enforcer contract that validates some property of the execution (target, +calldata, value, etc.) before it proceeds. + +An **interface guard** in Endo is a local (in-process) contract that validates +method calls on an exo object. `M.*` patterns describe the shape of arguments +and return values. + +The two systems operate at different layers: + +- Gator enforcers: on-chain, per-execution, byte-level calldata validation +- Endo patterns: in-process, per-method-call, structured value validation + +The goal is to derive Endo interface guards from Gator caveat configurations so +that the local exo twin rejects calls that would inevitably fail on-chain, +giving callers fast, descriptive errors without paying gas. + +## The AllowedCalldataEnforcer + +The key bridge between the two worlds 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 you can independently constrain any argument by stacking multiple +`allowedCalldata` caveats with different offsets. + +### Current integration + +`makeDelegationTwin` reads `allowedCalldata` entries from `caveatSpecs` and +narrows the exo interface guard accordingly. Currently this is used to pin +the first argument (recipient/spender address) of `transfer`/`approve` to a +literal value. + +## M.\* to Gator enforcer mapping + +### Direct mappings (static ABI types) + +| M.\* pattern | Gator enforcer | Notes | +| ------------------------------------------------------------- | ------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | +| `"literal"` (string/bigint/number passed directly as pattern) | `AllowedCalldataEnforcer` | Pin a 32-byte slot to the ABI encoding of the literal value. Works for address, uint256, bool, bytes32, and other static types. | +| `M.string()` | _(no enforcer)_ | Accepts any string. No calldata constraint needed; this is the default/unconstrained case. | +| `M.scalar()` | _(no enforcer)_ | Accepts any scalar (string, number, bigint, etc.). Unconstrained. | +| `M.any()` | _(no enforcer)_ | Accepts anything. Unconstrained. | +| `M.lte(n)` | `ValueLteEnforcer` | **Only for the `value` field of the execution envelope**, not for calldata args. There is no per-argument LTE enforcer. | +| `M.gte(n)`, `M.gt(n)`, `M.lt(n)` | **No enforcer** | Gator has no general-purpose comparison enforcers for calldata arguments. | +| `M.or(p1, p2, ...)` | `LogicalOrWrapperEnforcer` | Groups of caveats with OR semantics. Each group is a conjunction; the redeemer picks which group to satisfy. See caveats below. | +| `M.and(p1, p2, ...)` | Multiple caveats on same delegation | Caveats are AND-composed by default: every enforcer must pass. | +| `M.not(p)` | **No enforcer** | No negation primitive in Gator. | +| `M.eq(v)` | `AllowedCalldataEnforcer` | Same as literal pinning. | +| `M.neq(v)` | **No enforcer** | No negation/inequality. | +| `M.nat()` | **No enforcer** | Non-negative bigint. No range-check enforcer for calldata args. | +| `M.boolean()` | `AllowedCalldataEnforcer` (partially) | Could pin to `0` or `1` via two `LogicalOrWrapper` groups, but this is a degenerate use. In practice, leave unconstrained or pin to a specific bool. | +| `M.bigint()` | _(no enforcer)_ | Type-level only; any uint256 passes. | +| `M.number()` | _(no enforcer)_ | Type-level only. | +| `M.record()` / `M.array()` | **Not applicable** | ABI calldata for dynamic types uses offset indirection. See limitations below. | + +### Execution-envelope-level mappings + +These constrain the execution itself, not individual calldata arguments: + +| Constraint | Gator enforcer | M.\* equivalent | +| -------------------------- | ----------------------------------- | ------------------------------------------ | +| Allowed target contracts | `AllowedTargetsEnforcer` | (not an arg guard; structural) | +| Allowed function selectors | `AllowedMethodsEnforcer` | (not an arg guard; method-level) | +| Max native value per call | `ValueLteEnforcer` | `M.lte(n)` on the `value` field | +| Cumulative ERC-20 amount | `ERC20TransferAmountEnforcer` | (stateful; tracked on-chain) | +| Cumulative native amount | `NativeTokenTransferAmountEnforcer` | (stateful; tracked on-chain) | +| Exact calldata match | `ExactCalldataEnforcer` | Equivalent to pinning ALL args as literals | +| Exact execution match | `ExactExecutionEnforcer` | Pin target + value + all calldata | +| Call count limit | `LimitedCallsEnforcer` | (stateful; no M.\* equivalent) | +| Time window | `TimestampEnforcer` | (temporal; no M.\* equivalent) | + +## What works well + +For a contract with a **completely static ABI** (all arguments are fixed-size +types like address, uint256, bool, bytes32): + +1. **Literal pinning** (`M.eq` / literal patterns): Fully supported via + `AllowedCalldataEnforcer`. Each pinned argument is one caveat. + +2. **Conjunction** (`M.and`): Naturally expressed as multiple caveats on the + same delegation. + +3. **Disjunction** (`M.or`): Supported via `LogicalOrWrapperEnforcer`, but + with an important security caveat: the **redeemer** chooses which group to + satisfy, so all groups must represent equally acceptable outcomes. + +4. **Unconstrained args** (`M.string()`, `M.any()`, `M.scalar()`): Simply + omit the enforcer for that argument slot. + +## What does NOT map + +1. **Inequality / 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), not encoded function arguments. 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. The data lives at a variable position in calldata, + making `AllowedCalldataEnforcer` fragile to use (you'd need to pin the + offset pointer AND the data AND the length). Not recommended. + +4. **Stateful patterns**: `M.*` patterns are stateless. Gator enforcers like + `ERC20TransferAmountEnforcer`, `LimitedCallsEnforcer`, and + `NativeTokenTransferAmountEnforcer` maintain on-chain state across + invocations. These have no M.\* equivalent and are handled separately + via `CaveatSpec` (e.g., `cumulativeSpend` drives the local `SpendTracker`). + +5. **Structural patterns**: `M.splitRecord`, `M.splitArray`, `M.partial` — + these operate on JS object/array structure that doesn't exist in flat ABI + calldata. diff --git a/packages/evm-wallet-experiment/src/lib/caveats.test.ts b/packages/evm-wallet-experiment/src/lib/caveats.test.ts index 2fa13d948..5398b7e9e 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, @@ -43,6 +44,20 @@ describe('lib/caveats', () => { }); }); + describe('encodeAllowedCalldata', () => { + it('encodes offset and expected value', () => { + const value = + '0x0000000000000000000000001234567890abcdef1234567890abcdef12345678' as Hex; + const encoded = encodeAllowedCalldata({ dataStart: 4, value }); + + // First 32 bytes = offset (4), remainder = the value bytes + expect(encoded.slice(0, 66)).toBe( + `0x${(4).toString(16).padStart(64, '0')}`, + ); + expect(encoded.slice(66)).toBe(value.slice(2)); + }); + }); + describe('encodeAllowedMethods', () => { it('encodes function selectors', () => { const selectors: Hex[] = ['0xa9059cbb', '0x095ea7b3']; @@ -158,6 +173,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 1f002a3ee..726d74b32 100644 --- a/packages/evm-wallet-experiment/src/lib/caveats.ts +++ b/packages/evm-wallet-experiment/src/lib/caveats.ts @@ -14,6 +14,25 @@ export function encodeAllowedTargets(targets: Address[]): Hex { return encodeAbiParameters(parseAbiParameters('address[]'), [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)}`; +} + /** * Encode caveat terms for the AllowedMethods enforcer. * Restricts delegation to only call specific function selectors. diff --git a/packages/evm-wallet-experiment/src/lib/delegation-grant.test.ts b/packages/evm-wallet-experiment/src/lib/delegation-grant.test.ts new file mode 100644 index 000000000..e33f508c6 --- /dev/null +++ b/packages/evm-wallet-experiment/src/lib/delegation-grant.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, it } from 'vitest'; + +import type { Address } from '../types.ts'; +import { buildDelegationGrant } from './delegation-grant.ts'; + +const ALICE = '0x1111111111111111111111111111111111111111' as Address; +const BOB = '0x2222222222222222222222222222222222222222' as Address; +const TOKEN = '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as Address; +const CHAIN_ID = 11155111; + +describe('buildDelegationGrant', () => { + describe('transfer', () => { + it('produces correct caveats', () => { + const grant = buildDelegationGrant('transfer', { + delegator: ALICE, + delegate: BOB, + token: TOKEN, + max: 1000n, + chainId: CHAIN_ID, + }); + + expect(grant.methodName).toBe('transfer'); + expect(grant.token).toBe(TOKEN); + expect(grant.delegation.delegator).toBe(ALICE); + expect(grant.delegation.delegate).toBe(BOB); + expect(grant.delegation.chainId).toBe(CHAIN_ID); + expect(grant.delegation.status).toBe('pending'); + + const caveatTypes = grant.delegation.caveats.map((cv) => cv.type); + expect(caveatTypes).toStrictEqual([ + 'allowedTargets', + 'allowedMethods', + 'erc20TransferAmount', + ]); + }); + + it('includes timestamp caveat only when validUntil provided', () => { + const withoutExpiry = buildDelegationGrant('transfer', { + delegator: ALICE, + delegate: BOB, + token: TOKEN, + max: 1000n, + chainId: CHAIN_ID, + }); + expect( + withoutExpiry.delegation.caveats.map((cv) => cv.type), + ).not.toContain('timestamp'); + + const withExpiry = buildDelegationGrant('transfer', { + delegator: ALICE, + delegate: BOB, + token: TOKEN, + max: 1000n, + chainId: CHAIN_ID, + validUntil: 1700000000, + }); + expect(withExpiry.delegation.caveats.map((cv) => cv.type)).toContain( + 'timestamp', + ); + }); + + it('caveatSpecs contain cumulativeSpend entry', () => { + const grant = buildDelegationGrant('transfer', { + delegator: ALICE, + delegate: BOB, + token: TOKEN, + max: 500n, + chainId: CHAIN_ID, + }); + + expect(grant.caveatSpecs).toStrictEqual([ + { type: 'cumulativeSpend', token: TOKEN, max: 500n }, + ]); + }); + + it('includes blockWindow caveatSpec when validUntil provided', () => { + const grant = buildDelegationGrant('transfer', { + delegator: ALICE, + delegate: BOB, + token: TOKEN, + max: 500n, + chainId: CHAIN_ID, + validUntil: 1700000000, + }); + + expect(grant.caveatSpecs).toStrictEqual([ + { type: 'cumulativeSpend', token: TOKEN, max: 500n }, + { type: 'blockWindow', after: 0n, before: 1700000000n }, + ]); + }); + }); + + describe('approve', () => { + it('produces correct caveats', () => { + const grant = buildDelegationGrant('approve', { + delegator: ALICE, + delegate: BOB, + token: TOKEN, + max: 2000n, + chainId: CHAIN_ID, + }); + + expect(grant.methodName).toBe('approve'); + expect(grant.token).toBe(TOKEN); + const caveatTypes = grant.delegation.caveats.map((cv) => cv.type); + expect(caveatTypes).toStrictEqual([ + 'allowedTargets', + 'allowedMethods', + 'erc20TransferAmount', + ]); + }); + }); + + describe('call', () => { + it('produces allowedTargets caveat for provided targets', () => { + const target1 = '0x3333333333333333333333333333333333333333' as Address; + const target2 = '0x4444444444444444444444444444444444444444' as Address; + const grant = buildDelegationGrant('call', { + delegator: ALICE, + delegate: BOB, + targets: [target1, target2], + chainId: CHAIN_ID, + }); + + expect(grant.methodName).toBe('call'); + expect(grant.caveatSpecs).toStrictEqual([]); + expect(grant.delegation.caveats[0]?.type).toBe('allowedTargets'); + }); + + it('includes valueLte caveat when maxValue provided', () => { + const grant = buildDelegationGrant('call', { + delegator: ALICE, + delegate: BOB, + targets: [TOKEN], + chainId: CHAIN_ID, + maxValue: 10000n, + }); + + const caveatTypes = grant.delegation.caveats.map((cv) => cv.type); + expect(caveatTypes).toContain('valueLte'); + }); + + it('does not include valueLte caveat when maxValue omitted', () => { + const grant = buildDelegationGrant('call', { + delegator: ALICE, + delegate: BOB, + targets: [TOKEN], + chainId: CHAIN_ID, + }); + + const caveatTypes = grant.delegation.caveats.map((cv) => cv.type); + expect(caveatTypes).not.toContain('valueLte'); + }); + }); +}); diff --git a/packages/evm-wallet-experiment/src/lib/delegation-grant.ts b/packages/evm-wallet-experiment/src/lib/delegation-grant.ts new file mode 100644 index 000000000..ff0b3cd3f --- /dev/null +++ b/packages/evm-wallet-experiment/src/lib/delegation-grant.ts @@ -0,0 +1,272 @@ +import { + encodeAllowedCalldata, + encodeAllowedMethods, + encodeAllowedTargets, + encodeErc20TransferAmount, + encodeTimestamp, + encodeValueLte, + makeCaveat, +} from './caveats.ts'; +import { makeDelegation } from './delegation.ts'; +import { ERC20_APPROVE_SELECTOR, ERC20_TRANSFER_SELECTOR } from './erc20.ts'; +import type { + Address, + Caveat, + CaveatSpec, + DelegationGrant, + Hex, +} from '../types.ts'; + +const harden = globalThis.harden ?? ((value: T): T => value); + +/** + * Byte offset of the first argument in ABI-encoded calldata (after the + * 4-byte function selector). + */ +const FIRST_ARG_OFFSET = 4; + +/** + * Encode an address as a 32-byte ABI-encoded word (left-padded with zeros). + * + * @param address - The Ethereum address to encode. + * @returns The 0x-prefixed 32-byte hex string. + */ +function abiEncodeAddress(address: Address): Hex { + return `0x${address.slice(2).toLowerCase().padStart(64, '0')}`; +} + +type TransferOptions = { + delegator: Address; + delegate: Address; + 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. + * + * @param method - The catalog method name. + * @param options - Method-specific options. + * @returns An unsigned DelegationGrant. + */ +export function buildDelegationGrant( + method: 'transfer' | 'approve' | 'call', + options: TransferOptions | ApproveOptions | CallOptions, +): DelegationGrant { + switch (method) { + case 'transfer': + return buildTransferGrant(options as TransferOptions); + case 'approve': + return buildApproveGrant(options as ApproveOptions); + case 'call': + return buildCallGrant(options as CallOptions); + default: + throw new Error(`Unknown method: ${String(method)}`); + } +} + +/** + * Build a transfer delegation grant. + * + * @param options - Transfer grant options. + * @returns An unsigned DelegationGrant for ERC-20 transfers. + */ +function buildTransferGrant(options: TransferOptions): DelegationGrant { + const { delegator, delegate, token, max, chainId, validUntil, recipient } = + options; + const caveats: Caveat[] = [ + makeCaveat({ + type: 'allowedTargets', + terms: encodeAllowedTargets([token]), + chainId, + }), + makeCaveat({ + type: 'allowedMethods', + terms: encodeAllowedMethods([ERC20_TRANSFER_SELECTOR]), + chainId, + }), + makeCaveat({ + type: 'erc20TransferAmount', + terms: encodeErc20TransferAmount({ token, amount: max }), + chainId, + }), + ]; + + const caveatSpecs: CaveatSpec[] = [{ type: 'cumulativeSpend', token, max }]; + + if (recipient !== undefined) { + const value = abiEncodeAddress(recipient); + caveats.push( + makeCaveat({ + type: 'allowedCalldata', + terms: encodeAllowedCalldata({ dataStart: FIRST_ARG_OFFSET, value }), + chainId, + }), + ); + caveatSpecs.push({ + type: 'allowedCalldata', + dataStart: FIRST_ARG_OFFSET, + value, + }); + } + + if (validUntil !== undefined) { + caveats.push( + makeCaveat({ + type: 'timestamp', + terms: encodeTimestamp({ after: 0, before: validUntil }), + chainId, + }), + ); + caveatSpecs.push({ + type: 'blockWindow', + after: 0n, + before: BigInt(validUntil), + }); + } + + const delegation = makeDelegation({ delegator, delegate, caveats, chainId }); + + return harden({ delegation, methodName: 'transfer', caveatSpecs, token }); +} + +/** + * Build an approve delegation grant. + * + * @param options - Approve grant options. + * @returns An unsigned DelegationGrant for ERC-20 approvals. + */ +function buildApproveGrant(options: ApproveOptions): DelegationGrant { + const { delegator, delegate, token, max, chainId, validUntil, spender } = + options; + const caveats: Caveat[] = [ + makeCaveat({ + type: 'allowedTargets', + terms: encodeAllowedTargets([token]), + chainId, + }), + makeCaveat({ + type: 'allowedMethods', + terms: encodeAllowedMethods([ERC20_APPROVE_SELECTOR]), + chainId, + }), + makeCaveat({ + type: 'erc20TransferAmount', + terms: encodeErc20TransferAmount({ token, amount: max }), + chainId, + }), + ]; + + const caveatSpecs: CaveatSpec[] = [{ type: 'cumulativeSpend', token, max }]; + + if (spender !== undefined) { + const value = abiEncodeAddress(spender); + caveats.push( + makeCaveat({ + type: 'allowedCalldata', + terms: encodeAllowedCalldata({ dataStart: FIRST_ARG_OFFSET, value }), + chainId, + }), + ); + caveatSpecs.push({ + type: 'allowedCalldata', + dataStart: FIRST_ARG_OFFSET, + value, + }); + } + + if (validUntil !== undefined) { + caveats.push( + makeCaveat({ + type: 'timestamp', + terms: encodeTimestamp({ after: 0, before: validUntil }), + chainId, + }), + ); + caveatSpecs.push({ + type: 'blockWindow', + after: 0n, + before: BigInt(validUntil), + }); + } + + const delegation = makeDelegation({ delegator, delegate, caveats, chainId }); + + return harden({ delegation, methodName: 'approve', caveatSpecs, token }); +} + +/** + * Build a raw call delegation grant. + * + * @param options - Call grant options. + * @returns An unsigned DelegationGrant for raw calls. + */ +function buildCallGrant(options: CallOptions): DelegationGrant { + const { delegator, delegate, targets, chainId, maxValue, validUntil } = + options; + const caveats: Caveat[] = [ + makeCaveat({ + type: 'allowedTargets', + terms: encodeAllowedTargets(targets), + chainId, + }), + ]; + + if (maxValue !== undefined) { + caveats.push( + makeCaveat({ + type: 'valueLte', + terms: encodeValueLte(maxValue), + chainId, + }), + ); + } + + if (validUntil !== undefined) { + caveats.push( + makeCaveat({ + type: 'timestamp', + terms: encodeTimestamp({ after: 0, before: validUntil }), + chainId, + }), + ); + } + + const delegation = makeDelegation({ delegator, delegate, caveats, chainId }); + + return harden({ delegation, methodName: 'call', caveatSpecs: [] }); +} diff --git a/packages/evm-wallet-experiment/src/lib/delegation-twin.test.ts b/packages/evm-wallet-experiment/src/lib/delegation-twin.test.ts new file mode 100644 index 000000000..8b07f90a5 --- /dev/null +++ b/packages/evm-wallet-experiment/src/lib/delegation-twin.test.ts @@ -0,0 +1,331 @@ +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); + }); + }); + + describe('discoverability', () => { + it('returns method schemas from __getDescription__', () => { + const redeemFn = vi.fn().mockResolvedValue(TX_HASH); + const twin = makeDelegationTwin({ + grant: makeTransferGrant(1000n), + redeemFn, + }) as Record; + + const desc = (twin.__getDescription__ as () => Record)(); + expect(desc).toHaveProperty('transfer'); + expect( + (desc.transfer as Record).description, + ).toBeDefined(); + }); + }); + + describe('getBalance', () => { + it('is present when readFn provided', () => { + const redeemFn = vi.fn().mockResolvedValue(TX_HASH); + const readFn = vi + .fn() + .mockResolvedValue( + '0x00000000000000000000000000000000000000000000000000000000000f4240' as Hex, + ); + const twin = makeDelegationTwin({ + grant: makeTransferGrant(1000n), + redeemFn, + readFn, + }) as Record; + expect(twin).toHaveProperty('getBalance'); + }); + + it('is absent when readFn not provided', () => { + const redeemFn = vi.fn().mockResolvedValue(TX_HASH); + const twin = makeDelegationTwin({ + grant: makeTransferGrant(1000n), + redeemFn, + }) as Record; + expect(twin).not.toHaveProperty('getBalance'); + }); + + it('calls readFn with correct args and decodes result', async () => { + const redeemFn = vi.fn().mockResolvedValue(TX_HASH); + const readFn = vi + .fn() + .mockResolvedValue( + '0x00000000000000000000000000000000000000000000000000000000000f4240' as Hex, + ); + const twin = makeDelegationTwin({ + grant: makeTransferGrant(1000n), + redeemFn, + readFn, + }) as Record Promise>; + + const balance = await twin.getBalance(); + expect(balance).toBe(1000000n); + expect(readFn).toHaveBeenCalledWith({ + to: TOKEN, + data: encodeBalanceOf(BOB), + }); + }); + }); + + describe('call twin', () => { + it('builds raw execution from args', async () => { + const redeemFn = vi.fn().mockResolvedValue(TX_HASH); + const target = '0x3333333333333333333333333333333333333333' as Address; + const twin = makeDelegationTwin({ + grant: makeCallGrant(), + redeemFn, + }) as Record Promise>; + + await twin.call(target, 0n, '0xdeadbeef' as Hex); + expect(redeemFn).toHaveBeenCalledOnce(); + const execution = redeemFn.mock.calls[0]?.[0] as Execution; + expect(execution.target).toBe(target); + expect(execution.callData).toBe('0xdeadbeef'); + }); + }); + + 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 000000000..995a2308b --- /dev/null +++ b/packages/evm-wallet-experiment/src/lib/delegation-twin.ts @@ -0,0 +1,212 @@ +import { M } from '@endo/patterns'; +import type { MethodSchema } from '@metamask/kernel-utils'; +import { makeDiscoverableExo } from '@metamask/kernel-utils/discoverable'; + +import { decodeBalanceOfResult, encodeBalanceOf } from './erc20.ts'; +import { GET_BALANCE_SCHEMA, METHOD_CATALOG } from './method-catalog.ts'; +import type { CatalogMethodName } from './method-catalog.ts'; +import type { + Address, + CaveatSpec, + DelegationGrant, + Execution, + Hex, +} from '../types.ts'; + +/** + * Byte offset of the first argument in ABI-encoded calldata (after the + * 4-byte function selector). An `allowedCalldata` caveat spec at this offset + * pins the address argument for transfer/approve. + */ +const FIRST_ARG_OFFSET = 4; + +const METHODS_WITH_ADDRESS_ARG: ReadonlySet = new Set([ + 'transfer', + 'approve', +]); + +/** + * Extract a restricted address from an `allowedCalldata` caveat spec that + * pins the first argument (offset 4, 32-byte ABI-encoded address). + * + * @param caveatSpecs - The caveat specs to search. + * @returns The restricted address, or undefined if none found. + */ +function findRestrictedAddress(caveatSpecs: CaveatSpec[]): Address | undefined { + const spec = caveatSpecs.find( + (cs): cs is CaveatSpec & { type: 'allowedCalldata' } => + cs.type === 'allowedCalldata' && cs.dataStart === FIRST_ARG_OFFSET, + ); + if (!spec) { + return undefined; + } + // The value is a 32-byte ABI-encoded address; extract the last 40 hex chars. + return `0x${spec.value.slice(-40)}`; +} + +/** + * Build the method guard for a catalog method, optionally restricting the + * first (address) argument to a single literal value. + * + * @param methodName - The catalog method name. + * @param restrictAddress - If provided, lock the first arg to this literal. + * @returns A method guard for use in an InterfaceGuard. + */ +function buildMethodGuard( + methodName: CatalogMethodName, + restrictAddress?: Address, +): ReturnType { + const addrGuard = + restrictAddress !== undefined && METHODS_WITH_ADDRESS_ARG.has(methodName) + ? restrictAddress + : M.string(); + + switch (methodName) { + case 'transfer': + case 'approve': + return M.callWhen(addrGuard, M.scalar()).returns(M.any()); + case 'call': + return M.callWhen(M.string(), M.scalar(), M.string()).returns(M.any()); + default: + throw new Error(`Unknown catalog method: ${String(methodName)}`); + } +} + +type SpendTracker = { + spent: bigint; + max: bigint; + remaining: () => bigint; + commit: (amount: bigint) => void; + rollback: (amount: bigint) => void; +}; + +/** + * Create a spend tracker for a cumulative-spend caveat spec. + * + * @param spec - The cumulative-spend caveat spec. + * @returns A spend tracker with commit/rollback semantics. + */ +function makeSpendTracker( + spec: CaveatSpec & { type: 'cumulativeSpend' }, +): SpendTracker { + let spent = 0n; + return { + get spent() { + return spent; + }, + max: spec.max, + remaining: () => spec.max - spent, + commit: (amount: bigint) => { + spent += amount; + }, + rollback: (amount: bigint) => { + spent -= amount; + }, + }; +} + +/** + * Find and create a spend tracker from a list of caveat specs. + * + * @param caveatSpecs - The caveat specs to search. + * @returns A spend tracker if a cumulative-spend spec is found. + */ +function findSpendTracker(caveatSpecs: CaveatSpec[]): SpendTracker | undefined { + const spec = caveatSpecs.find( + (cs): cs is CaveatSpec & { type: 'cumulativeSpend' } => + cs.type === 'cumulativeSpend', + ); + return spec ? makeSpendTracker(spec) : undefined; +} + +type DelegationTwinOptions = { + grant: DelegationGrant; + redeemFn: (execution: Execution) => Promise; + readFn?: (opts: { to: Address; data: Hex }) => Promise; +}; + +/** + * Create a discoverable exo twin for a delegation grant. + * + * @param options - Twin construction options. + * @param options.grant - The delegation grant to wrap. + * @param options.redeemFn - Function to redeem a delegation execution. + * @param options.readFn - Optional function for read-only calls. + * @returns A discoverable exo with delegation methods. + */ +export function makeDelegationTwin( + options: DelegationTwinOptions, +): ReturnType { + const { grant, redeemFn, readFn } = options; + const { methodName, caveatSpecs, delegation } = grant; + + const entry = METHOD_CATALOG[methodName as keyof typeof METHOD_CATALOG]; + if (!entry) { + throw new Error(`Unknown method in grant: ${methodName}`); + } + + const tracker = findSpendTracker(caveatSpecs); + const { token } = grant; + const idPrefix = delegation.id.slice(0, 12); + + const primaryMethod = async (...args: unknown[]): Promise => { + let trackAmount: bigint | undefined; + if (tracker) { + trackAmount = args[1] as bigint; + if (trackAmount > tracker.remaining()) { + throw new Error( + `Insufficient budget: requested ${trackAmount}, remaining ${tracker.remaining()}`, + ); + } + } + + const execution = entry.buildExecution(token ?? ('' as Address), args); + + const txHash = await redeemFn(execution); + if (tracker && trackAmount !== undefined) { + tracker.commit(trackAmount); + } + return txHash; + }; + + const methods: Record unknown> = { + [methodName]: primaryMethod, + }; + const schema: Record = { + [methodName]: entry.schema, + }; + + 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.delegate), + }); + return decodeBalanceOfResult(result); + }; + schema.getBalance = GET_BALANCE_SCHEMA; + methodGuards.getBalance = M.callWhen().returns(M.any()); + } + + const interfaceGuard = M.interface( + `DelegationTwin:${methodName}:${idPrefix}`, + methodGuards, + { defaultGuards: 'passable' }, + ); + + return makeDiscoverableExo( + `DelegationTwin:${methodName}:${idPrefix}`, + methods, + schema, + interfaceGuard, + ); +} diff --git a/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 000000000..5ba75e804 --- /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 000000000..1c930be81 --- /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 3e313cb5c..2d539dfbe 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,44 @@ 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, + }), +]); + +export type CaveatSpec = Infer; + +export const DelegationGrantStruct = object({ + delegation: DelegationStruct, + methodName: string(), + caveatSpecs: array(CaveatSpecStruct), + token: optional(AddressStruct), +}); + +export type DelegationGrant = Infer; + // --------------------------------------------------------------------------- // Swap types (MetaSwap API) // --------------------------------------------------------------------------- diff --git a/packages/evm-wallet-experiment/src/vats/coordinator-vat.ts b/packages/evm-wallet-experiment/src/vats/coordinator-vat.ts index f1998b198..a1b51a04b 100644 --- a/packages/evm-wallet-experiment/src/vats/coordinator-vat.ts +++ b/packages/evm-wallet-experiment/src/vats/coordinator-vat.ts @@ -3,6 +3,7 @@ import { makeDefaultExo } from '@metamask/kernel-utils/exo'; import { Logger } from '@metamask/logger'; import type { Baggage } from '@metamask/ocap-kernel'; +import { buildDelegationGrant } from '../lib/delegation-grant.ts'; import { decodeAllowanceResult, decodeBalanceOfResult, @@ -16,6 +17,7 @@ import { encodeSymbol, encodeTransfer, } from '../lib/erc20.ts'; +import type { CatalogMethodName } from '../lib/method-catalog.ts'; import { buildBatchExecuteCallData, buildSdkBatchRedeemCallData, @@ -34,6 +36,7 @@ import type { ChainConfig, CreateDelegationOptions, Delegation, + DelegationGrant, DelegationMatchResult, Eip712TypedData, Execution, @@ -1799,6 +1802,114 @@ export function buildRootObject( return E(delegationVat).listDelegations(); }, + // ------------------------------------------------------------------ + // Delegation twins + // ------------------------------------------------------------------ + + async makeDelegationGrant( + method: CatalogMethodName, + options: Record, + ): Promise { + if (!delegationVat) { + throw new Error('Delegation vat not available'); + } + + // Resolve delegator: smart account address if configured, else first local account + const delegator = + smartAccountConfig?.address ?? (await resolveOwnerAddress()); + + const grant = buildDelegationGrant(method, { + delegator, + ...options, + } as Parameters[1]); + + // Sign the delegation via the existing create → prepare → sign → store flow + const { delegation } = grant; + + // Determine signing function (same logic as createDelegation) + let signTypedDataFn: + | ((data: Eip712TypedData) => Promise) + | undefined; + + if (keyringVat) { + const accounts = await E(keyringVat).getAccounts(); + if (accounts.length > 0) { + const kv = keyringVat; + signTypedDataFn = async (data: Eip712TypedData) => + E(kv).signTypedData(data); + } + } + + if (!signTypedDataFn && externalSigner) { + const accounts = await E(externalSigner).getAccounts(); + if (accounts.length > 0) { + const ext = externalSigner; + const from = accounts[0]; + signTypedDataFn = async (data: Eip712TypedData) => + E(ext).signTypedData(data, from); + } + } + + if (!signTypedDataFn) { + throw new Error('No signing authority available'); + } + + // Store, prepare, sign, and finalize the delegation + const stored = await E(delegationVat).createDelegation({ + delegate: delegation.delegate, + caveats: delegation.caveats, + chainId: delegation.chainId, + salt: delegation.salt, + delegator, + }); + + const typedData = await E(delegationVat).prepareDelegationForSigning( + stored.id, + ); + const signature = await signTypedDataFn(typedData); + await E(delegationVat).storeSigned(stored.id, signature); + + const signedDelegation = await E(delegationVat).getDelegation(stored.id); + + return harden({ + ...grant, + delegation: signedDelegation, + }); + }, + + async provisionTwin(grant: DelegationGrant): Promise { + if (!delegationVat) { + throw new Error('Delegation vat not available'); + } + + const { delegation } = grant; + + // Build redeemFn closure that submits a delegation UserOp + const redeemFn = async (execution: Execution): Promise => { + return submitDelegationUserOp({ + delegations: [delegation], + execution, + }); + }; + + // Build readFn closure if provider is available + let readFn: + | ((opts: { to: Address; data: Hex }) => Promise) + | undefined; + if (providerVat) { + const pv = providerVat; + readFn = async (opts: { to: Address; data: Hex }): Promise => { + const result = await E(pv).request('eth_call', [ + { to: opts.to, data: opts.data }, + 'latest', + ]); + return result as Hex; + }; + } + + return E(delegationVat).addDelegation(grant, redeemFn, readFn); + }, + // ------------------------------------------------------------------ // Delegation redemption (ERC-4337) // ------------------------------------------------------------------ diff --git a/packages/evm-wallet-experiment/src/vats/delegation-vat.ts b/packages/evm-wallet-experiment/src/vats/delegation-vat.ts index 0d71a43bd..f5d45b3b6 100644 --- a/packages/evm-wallet-experiment/src/vats/delegation-vat.ts +++ b/packages/evm-wallet-experiment/src/vats/delegation-vat.ts @@ -3,6 +3,7 @@ import type { Logger } from '@metamask/logger'; import type { Baggage } from '@metamask/ocap-kernel'; import { DEFAULT_DELEGATION_MANAGER } from '../constants.ts'; +import { makeDelegationTwin } from '../lib/delegation-twin.ts'; import { computeDelegationId, makeDelegation, @@ -16,8 +17,10 @@ import type { Address, CreateDelegationOptions, Delegation, + DelegationGrant, DelegationMatchResult, Eip712TypedData, + Execution, Hex, } from '../types.ts'; @@ -59,6 +62,9 @@ export function buildRootObject( ) : new Map(); + // Twin exo references, keyed by delegation ID + const twins: Map> = new Map(); + /** * Persist the current delegations map to baggage. */ @@ -200,5 +206,29 @@ export function buildRootObject( ); persistDelegations(); }, + + async addDelegation( + grant: DelegationGrant, + redeemFn: (execution: Execution) => Promise, + readFn?: (opts: { to: Address; data: Hex }) => Promise, + ): Promise> { + const { delegation } = grant; + delegations.set(delegation.id, delegation); + persistDelegations(); + + const twin = makeDelegationTwin({ grant, redeemFn, readFn }); + twins.set(delegation.id, twin); + return twin; + }, + + async getTwin( + delegationId: string, + ): Promise> { + const twin = twins.get(delegationId); + if (!twin) { + throw new Error(`Twin not found for delegation: ${delegationId}`); + } + return twin; + }, }); } diff --git a/yarn.lock b/yarn.lock index 822747d5e..6fac12778 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3862,6 +3862,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"