Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
1e909e3
fix(evm-wallet-experiment): wire build as prerequisite to docker:build
grypez Apr 9, 2026
c068d62
fix(evm-wallet-experiment): serialize BigInt values in daemon-client …
grypez Apr 8, 2026
56bbb64
fix(evm-wallet-experiment): use packed encoding for AllowedTargets, A…
grypez Apr 8, 2026
b63ef6d
feat(evm-wallet-experiment): add runtime chain contract registration …
grypez Apr 8, 2026
088aad6
feat(evm-wallet-experiment): add delegation twins
grypez Mar 19, 2026
501770b
feat(evm-wallet-experiment): thread InterfaceGuards into delegation t…
grypez Mar 19, 2026
123e86f
feat(evm-wallet-experiment): add valueLte caveat spec and normalize t…
grypez Apr 8, 2026
087a000
feat(evm-wallet-experiment): add entropy parameter to delegation salt…
grypez Apr 8, 2026
5459c9a
feat(evm-wallet-experiment): restrict address arg via AllowedCalldata…
grypez Mar 19, 2026
16110a5
feat(evm-wallet-experiment): add storeDelegation to delegation-vat
grypez Apr 8, 2026
0dde78c
feat(evm-wallet-experiment): enforce cumulativeSpend locally in sendT…
grypez Apr 8, 2026
80d3acb
refactor(evm-wallet-experiment): deduplicate FIRST_ARG_OFFSET and ERC…
grypez Apr 9, 2026
19116b0
refactor(evm-wallet-experiment): replace entropy param with makeSaltG…
grypez Apr 14, 2026
7590ea9
fix(evm-wallet-experiment): update callVatExpectError to return error…
grypez Apr 9, 2026
69f7e29
test(evm-wallet-experiment): add delegation twin e2e script
grypez Apr 8, 2026
dda45ab
test(evm-wallet-experiment): add delegation twin docker e2e test
grypez Apr 8, 2026
5d0cf01
test(evm-wallet-experiment): improve delegation twin e2e failure mess…
grypez Apr 9, 2026
91104dc
docs(evm-wallet-experiment): add GATOR.md conceptual model
grypez Mar 19, 2026
a44a085
test(evm-wallet-experiment): expose sendTransaction twin routing bug
grypez Apr 14, 2026
217f7e6
fix(evm-wallet-experiment): dispatch sendTransaction twin by method name
grypez Apr 14, 2026
fe4a8c9
refactor(evm-wallet-experiment): remove dead addDelegation and getTwi…
grypez Apr 14, 2026
1a31e9c
test(evm-wallet-experiment): expose calldata decode crash for transfe…
grypez Apr 14, 2026
3ec5e00
fix(evm-wallet-experiment): validate calldata length before decoding …
grypez Apr 14, 2026
f7782fb
fix(evm-wallet-experiment): add missing blockWindow caveatSpec in bui…
grypez Apr 14, 2026
ef461a6
test(evm-wallet-experiment): expose spend tracker race condition
grypez Apr 14, 2026
75894d6
fix(evm-wallet-experiment): reserve spend budget before await, rollba…
grypez Apr 14, 2026
3d07836
test(evm-wallet-experiment): expose getBalance querying delegate inst…
grypez Apr 14, 2026
0c1be9b
fix(evm-wallet-experiment): getBalance queries delegator balance, not…
grypez Apr 14, 2026
cc7c54f
docs(evm-wallet-experiment): clarify salt generator comment in coordi…
grypez Apr 15, 2026
d8282b9
fix(evm-wallet-experiment): update describeCaveat to use packed erc20…
grypez Apr 15, 2026
fd1fecf
fix(evm-wallet-experiment): remove dead entropy field from e2e test
grypez Apr 15, 2026
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
5 changes: 3 additions & 2 deletions packages/evm-wallet-experiment/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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:^",
Expand Down
39 changes: 39 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 Expand Up @@ -160,6 +162,39 @@ export const CHAIN_CONTRACTS: Readonly<Record<number, ChainContracts>> = 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<Record<string, CaveatType>> =
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<number, ChainContracts>();

/**
* 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);
}
Comment thread
grypez marked this conversation as resolved.

/**
* Get the contract addresses for a chain, falling back to placeholders.
*
Expand All @@ -168,6 +203,10 @@ export const CHAIN_CONTRACTS: Readonly<Record<number, ChainContracts>> = 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;
Expand Down
20 changes: 20 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 @@ -103,7 +108,9 @@ export {
finalizeDelegation,
computeDelegationId,
generateSalt,
makeSaltGenerator,
} from './lib/delegation.ts';
export type { SaltGenerator } from './lib/delegation.ts';

// UserOperation utilities
export {
Expand Down Expand Up @@ -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';
217 changes: 217 additions & 0 deletions packages/evm-wallet-experiment/src/lib/GATOR.md
Original file line number Diff line number Diff line change
@@ -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.
Loading
Loading