Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/evm-wallet-experiment/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:^",
Expand Down
2 changes: 2 additions & 0 deletions packages/evm-wallet-experiment/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -101,6 +102,7 @@ const SHARED_DELEGATION_MANAGER: Address =
const SHARED_ENFORCERS: Record<CaveatType, Address> = harden({
allowedTargets: '0x7F20f61b1f09b08D970938F6fa563634d65c4EeB' as Address,
allowedMethods: '0x2c21fD0Cb9DC8445CB3fb0DC5E7Bb0Aca01842B5' as Address,
allowedCalldata: '0xc2b0d624c1c4319760c96503ba27c347f3260f55' as Address,
valueLte: '0x92Bf12322527cAA612fd31a0e810472BBB106A8F' as Address,
nativeTokenTransferAmount:
'0xF71af580b9c3078fbc2BBF16FbB8EEd82b330320' as Address,
Expand Down
15 changes: 15 additions & 0 deletions packages/evm-wallet-experiment/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,12 @@ export type {
Address,
Action,
Caveat,
CaveatSpec,
CaveatType,
ChainConfig,
CreateDelegationOptions,
Delegation,
DelegationGrant,
DelegationMatchResult,
DelegationStatus,
Eip712Domain,
Expand All @@ -48,10 +50,12 @@ export type {

export {
ActionStruct,
CaveatSpecStruct,
CaveatStruct,
CaveatTypeValues,
ChainConfigStruct,
CreateDelegationOptionsStruct,
DelegationGrantStruct,
DelegationStatusValues,
DelegationStruct,
Eip712DomainStruct,
Expand All @@ -69,6 +73,7 @@ export {
// Caveat utilities (for creating delegations externally)
export {
encodeAllowedTargets,
encodeAllowedCalldata,
encodeAllowedMethods,
encodeValueLte,
encodeNativeTokenTransferAmount,
Expand Down Expand Up @@ -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';
175 changes: 175 additions & 0 deletions packages/evm-wallet-experiment/src/lib/GATOR.md
Original file line number Diff line number Diff line change
@@ -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.
16 changes: 16 additions & 0 deletions packages/evm-wallet-experiment/src/lib/caveats.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { describe, it, expect } from 'vitest';

import {
encodeAllowedTargets,
encodeAllowedCalldata,
encodeAllowedMethods,
encodeValueLte,
encodeNativeTokenTransferAmount,
Expand Down Expand Up @@ -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'];
Expand Down Expand Up @@ -158,6 +173,7 @@ describe('lib/caveats', () => {
const types = [
'allowedTargets',
'allowedMethods',
'allowedCalldata',
'valueLte',
'nativeTokenTransferAmount',
'erc20TransferAmount',
Expand Down
19 changes: 19 additions & 0 deletions packages/evm-wallet-experiment/src/lib/caveats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading