Skip to content

feat(evm-wallet-experiment): Add delegator exos#882

Merged
grypez merged 31 commits intomainfrom
grypez/gator-exo
Apr 15, 2026
Merged

feat(evm-wallet-experiment): Add delegator exos#882
grypez merged 31 commits intomainfrom
grypez/gator-exo

Conversation

@grypez
Copy link
Copy Markdown
Contributor

@grypez grypez commented Mar 19, 2026

Introduces delegation grants and delegation twins — the bridge between
the MetaMask Delegation Framework ("Gator") and the ocap kernel's capability
model.

A delegation grant is a serializable, describable representation of a
delegation. It pairs the raw redeemable bytestring with caveatSpecs: a
structured, human-readable description of the active caveats (spend limits,
allowed targets, calldata constraints, etc.) that the application can reason
about without decoding opaque on-chain calldata.

A delegation twin is a local discoverable exo wrapping a grant. It exposes
the delegation's permitted operations (transfer, approve, call,
getBalance) as typed ocap capability methods, derives M.* interface guards
directly from the grant's caveatSpecs, and maintains a local mirror of
stateful caveats (cumulative spend, value limits) for fast pre-rejection before
a call ever reaches the network.

Changes

New: delegation grants and twins (src/lib/)

  • delegation-grant.tsbuildDelegationGrant() builders for transfer,
    approve, and call grants; encodes all Gator caveats and returns a
    DelegationGrant with a matching caveatSpecs array
  • delegation-twin.tsmakeDelegationTwin() constructs a discoverable exo
    from a grant; interface guards are narrowed by the grant's caveatSpecs (e.g.
    an allowedCalldata caveat at offset 4 locks the first argument to a literal
    address in the M.* pattern)
  • method-catalog.ts — registry of catalog method names, ABI selectors, and
    method schemas; used by both grant builders and twins

New: coordinator vat (src/vats/coordinator-vat.ts)

Wires delegation twins into the vat layer — builds grants, instantiates twins,
and exposes them as kernel capabilities.

Design note

The local spend tracker and value pre-checks are advisory, not
authoritative
. On-chain state is the truth. The twin's job is fast
pre-rejection and a structured capability interface — not to replace the
on-chain enforcer. If spend is tracked externally (e.g., a redemption outside
this twin), the local tracker may optimistically allow a call that the chain
will reject.

Test plan

  • yarn workspace @ocap/evm-wallet-experiment test:dev:quiet — unit tests
    for delegation-grant, delegation-twin, method-catalog
  • yarn workspace @ocap/evm-wallet-experiment test:e2e:docker — docker e2e
    suite including the delegation twin flow
  • Confirm a transfer twin with a recipient restriction rejects a call to
    a different address locally (before hitting the network)
  • Confirm cumulative spend tracking blocks a second transfer that would
    exceed the grant's max

Note

Medium Risk
Adds a new delegation-grant/twin execution path and routes signed delegated transactions through it, changing how transactions are validated and dispatched. Also changes caveat term encodings and chain contract resolution, which could cause mismatches with deployed enforcers if incorrect.

Overview
Introduces a new delegation grant and delegation twin layer to bridge Gator delegations into kernel-discoverable ocap capabilities, including a METHOD_CATALOG, DelegationGrant/CaveatSpec types, and a makeDelegationTwin() factory that derives @endo/patterns interface guards and performs local pre-checks (e.g., cumulative spend tracking, valueLte, optional getBalance).

Updates delegation caveat encoding/decoding to match on-chain enforcers (packed allowedTargets/allowedMethods/erc20TransferAmount, adds allowedCalldata support) and adds runtime chain contract registration/mapping for custom environments.

Wires twins into coordinator-vat: adds APIs to build+sign grants, provision twins with local redeemFn/readFn closures, and routes matching sendTransaction calls through a provisioned twin (with safer calldata decoding). Adds unit and docker e2e coverage and improves daemon client error/bigint serialization; docker scripts now build monorepo first and bring up containers with --build.

Reviewed by Cursor Bugbot for commit fd1fecf. Bugbot is set up for automated code reviews on this repo. Configure here.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Mar 19, 2026

Coverage Report

Status Category Percentage Covered / Total
🔵 Lines 78.49%
⬆️ +0.02%
8860 / 11287
🔵 Statements 78.31%
⬆️ +0.01%
9005 / 11499
🔵 Functions 75.92%
⬆️ +0.02%
2069 / 2725
🔵 Branches 76.29%
⬇️ -0.16%
3820 / 5007
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
packages/evm-wallet-experiment/src/constants.ts 91.42%
⬇️ -5.13%
83.33%
⬇️ -6.67%
60%
⬇️ -15.00%
94.11%
⬇️ -5.89%
3, 195, 208
packages/evm-wallet-experiment/src/index.ts 100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
packages/evm-wallet-experiment/src/types.ts 88.88%
⬇️ -2.42%
0%
🟰 ±0%
25%
⬇️ -8.33%
88.88%
⬇️ -2.42%
28, 37, 332
packages/evm-wallet-experiment/src/lib/caveats.ts 100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
packages/evm-wallet-experiment/src/lib/delegation-grant.ts 83.33% 78.57% 80% 85.71% 25-34, 158, 224-236
packages/evm-wallet-experiment/src/lib/delegation-twin.ts 89.83% 80.55% 92.85% 89.83% 68, 92, 142, 166-168
packages/evm-wallet-experiment/src/lib/delegation.ts 86.25%
⬇️ -5.81%
88.88%
⬇️ -3.12%
78.57%
⬇️ -12.33%
86.84%
⬇️ -6.60%
20, 83-94, 285-290, 330-334
packages/evm-wallet-experiment/src/lib/erc20.ts 98.14%
⬆️ +0.03%
91.66%
🟰 ±0%
94.44%
🟰 ±0%
100%
🟰 ±0%
9
packages/evm-wallet-experiment/src/lib/method-catalog.ts 90% 50% 75% 100% 11
packages/evm-wallet-experiment/src/vats/coordinator-vat.ts 86.46%
⬇️ -1.64%
77.98%
⬇️ -1.44%
91.91%
⬇️ -1.56%
86.47%
⬇️ -1.58%
65, 89-91, 129-174, 371, 466, 498-500, 527-530, 558, 614-616, 705-707, 727, 731, 924, 927, 963-965, 973, 988-992, 1155-1157, 1211-1213, 1219, 1234-1236, 1316, 1340-1343, 1352-1354, 1387-1390, 1409-1411, 1439-1442, 1454-1456, 1521, 1545, 1552, 1559, 1623-1638, 1658-1660, 1789, 1824-1827, 1929, 1981-1982, 2018, 2123, 2199, 2235-2237, 2251-2253, 2283-2286, 2303, 2317, 2360-2365, 2370, 2398, 2412-2427, 2448-2452, 2486, 2592-2598, 2687, 2761, 2779, 2864, 2881-2884, 2929-2932, 2937, 2951, 3001-3002, 3044, 3052, 3055-3057, 3073, 3093, 3190-3191, 3205-3208, 3243, 3252
packages/evm-wallet-experiment/src/vats/delegation-vat.ts 92.98%
⬆️ +0.39%
85.29%
⬆️ +2.94%
92.85%
⬆️ +0.55%
94.64%
⬆️ +0.31%
25, 113, 171, 196
Generated in workflow #4303 for commit fd1fecf by the Vitest Coverage Report Action

@grypez grypez force-pushed the grypez/gator-exo branch from 6823dad to 42376f0 Compare April 1, 2026 13:23
@grypez grypez force-pushed the grypez/gator-exo branch 3 times, most recently from 9d0cbd8 to 0224318 Compare April 13, 2026 14:52
@grypez grypez changed the base branch from main to grypez/evm-e2e-logging April 13, 2026 16:53
Base automatically changed from grypez/evm-e2e-logging to main April 13, 2026 18:45
@grypez grypez force-pushed the grypez/gator-exo branch 2 times, most recently from 6ef18c5 to eb307c5 Compare April 14, 2026 17:18
@grypez grypez marked this pull request as ready for review April 14, 2026 17:26
@grypez grypez requested a review from a team as a code owner April 14, 2026 17:26
Comment thread packages/evm-wallet-experiment/src/vats/coordinator-vat.ts
Comment thread packages/evm-wallet-experiment/src/vats/delegation-vat.ts Outdated
Comment thread packages/evm-wallet-experiment/src/vats/coordinator-vat.ts
Comment thread packages/evm-wallet-experiment/src/lib/delegation.ts
Comment thread packages/evm-wallet-experiment/src/lib/delegation-grant.ts
Comment thread packages/evm-wallet-experiment/src/lib/delegation-twin.ts
Comment thread packages/evm-wallet-experiment/src/lib/delegation-twin.ts Outdated
Comment thread packages/evm-wallet-experiment/src/vats/delegation-vat.ts
Comment thread packages/evm-wallet-experiment/src/vats/coordinator-vat.ts
grypez and others added 13 commits April 14, 2026 20:09
Ensure workspace dist files are current via Turbo cache before image
assembly, so stale build artifacts don't end up in the Docker image.
…JSON requests

JSON.stringify drops BigInt fields silently; replace with a replacer
that coerces BigInt to string so numeric values survive the wire.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…llowedMethods, and ERC20TransferAmount

These enforcers expect tightly-packed bytes, not ABI head/tail
encoding. Switch encodeAllowedTargets/encodeAllowedMethods to
encodePacked and encodeErc20TransferAmount likewise. Update
explainDelegationMatch to decode packed terms the same way, and
update the caveat encoding tests to assert packed layout directly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…and enforcer key map

Add registerChainContracts() / customChainContracts so devnet chains
(e.g. Anvil at 31337) can be registered at runtime without touching the
static CHAIN_CONTRACTS table. Add ENFORCER_CONTRACT_KEY_MAP to translate
PascalCase enforcer keys in contracts.json to camelCase CaveatType names.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Represent delegations as discoverable exo objects ("twins") so an agent
can call E(twin).transfer(to, amount) instead of manually building
Execution structs. Adds a method catalog, grant builder, twin factory
with cumulative spend tracking, and wires them into the delegation and
coordinator vats.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…wins

Build typed method guards from METHOD_CATALOG entries and pass them to
makeDiscoverableExo, enabling arg-count/type validation at the exo boundary.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…win method args

Add valueLte to CaveatSpecStruct so call twins can carry a per-call
ETH value limit that is checked locally before submission. In
makeDelegationTwin, normalize args[1] to BigInt on entry (required
when values arrive as hex strings over the daemon JSON-RPC boundary),
apply the valueLte check, and pass normalizedArgs to buildExecution.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… generation

generateSalt accepts an optional entropy hex string that is mixed into
the counter hash when crypto.getRandomValues is unavailable. Thread
the same entropy option through makeDelegation and all three
buildDelegationGrant overloads (transfer, approve, call) so callers
can supply per-run entropy to avoid salt collisions across fresh vat
instances.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… caveat

Integrate with the delegation framework's AllowedCalldataEnforcer to pin
the first argument (recipient/spender) of transfer/approve at both the
on-chain enforcer level and the local exo interface guard.

- Add `allowedCalldata` to CaveatTypeValues and CaveatSpec
- Add `encodeAllowedCalldata` helper and deployed enforcer address
- Wire optional `recipient`/`spender` through grant builders
- Twin derives address restriction from caveatSpecs, not a standalone field

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Accepts a full DelegationGrant and persists only the delegation to
baggage. Used by coordinator-vat's provisionTwin, where redeemFn/readFn
closures must stay in the coordinator scope and cannot cross the CapTP
vat boundary.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ransaction

- setup-wallets: provision delegation twin via provisionTwin (not
  receiveDelegation) so the away coordinator holds an active twin
  with a cumulativeSpend caveat spec (1000 ETH, native token)
- coordinator-vat: store twins created by provisionTwin in a local
  coordinatorTwins map keyed by delegation ID
- coordinator-vat: sendTransaction routes through the twin when one
  is registered for the matched delegation, so local caveat checks
  (e.g. cumulativeSpend budget) fire before the UserOp hits the chain

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…-20 grant builders

- Export FIRST_ARG_OFFSET from erc20.ts; remove local copies in
  delegation-grant.ts and delegation-twin.ts
- Extract buildErc20Grant helper from the near-identical
  buildTransferGrant/buildApproveGrant (~90 lines removed)
- Strengthen delegation twin method guards: M.any() -> M.string() for
  Hex returns, M.bigint() for getBalance
- Clarify cast: as keyof typeof METHOD_CATALOG -> as CatalogMethodName

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…enerator factory

Replace the module-level saltCounter + entropy threading with a proper
factory: makeSaltGenerator() returns a closure with its own counter, so
each vat instance gets an independent salt sequence rather than sharing
module-level state. The entropy parameter is removed from
TransferOptions, ApproveOptions, CallOptions, and makeDelegation;
callers that need per-instance isolation use makeDelegationGrantBuilder
with a makeSaltGenerator() instance.

- Add SaltGenerator type and makeSaltGenerator() to delegation.ts
- generateSalt is now makeSaltGenerator() called once at module load
  (backward-compatible export)
- makeDelegation accepts saltGenerator? instead of entropy?
- Add makeDelegationGrantBuilder({ saltGenerator }) factory to
  delegation-grant.ts; extract dispatchGrant to share the switch
- coordinator-vat creates one saltGenerator + grantBuilder per vat
  instance inside buildRootObject
- Add --build to docker:up so stale images are detected automatically
- Export makeSaltGenerator, SaltGenerator, makeDelegationGrantBuilder
  from index.ts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
grypez and others added 15 commits April 14, 2026 20:09
… message directly

With the queueMessage RPC fix in @MetaMask/ocap-kernel, vat rejections now
surface as response.error with the actual message rather than a generic
'Internal error'. Update callVatExpectError in both the docker e2e helper
and the integration test runner to return response.error.message directly
and throw on unexpected success, replacing the dead response.result.body path.
Script invoked by the docker e2e suite to test local cumulativeSpend
enforcement and chain-side rejection of an expired timestamp caveat.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Runs run-delegation-twin-e2e.mjs inside the away container as part of
the Docker E2E suite. Covers local cumulativeSpend enforcement and
chain-side rejection of an expired timestamp delegation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ages

Include log file path in assertion failure messages so the relevant
service log is immediately identifiable without digging through output.
Explains the grant → twin → discoverable capability layering:
- Delegation grants as serializable describable delegations (redeemable
  bytestring + readable caveat specs)
- Delegation twins as local capabilities that mirror on-chain stateful
  caveats (latently; on-chain is authoritative)
- M.* patterns as the mechanism for discoverability and pre-validation

Documents the enforcer mapping table and M.*/Gator overlap.
Transfer and approve twins only expose their own method name, not .call().
sendTransaction hardcodes E(twin).call() for all twin types, so routing
through a provisioned transfer twin throws 'call is not a function on target'.

Add two tests: one for the transfer twin (fails, demonstrating the bug)
and one for the call twin (passes, control case).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
sendTransaction was hardcoding E(twin).call() for all provisioned twins.
Transfer and approve twins only expose their own method name, so this
threw 'call is not a function on target' at runtime.

Track each twin's CatalogMethodName in a parallel coordinatorTwinMethods
map. In sendTransaction, decode the ABI-encoded (address, uint256)
calldata args and dispatch to E(twin).transfer() or E(twin).approve()
for ERC-20 twins; fall through to E(twin).call() for call twins.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…n from delegation vat

Twins cannot live in the delegation vat because redeemFn/readFn closures
cannot cross the CapTP vat boundary. The coordinator builds twins locally
via provisionTwin() and uses storeDelegation() for persistence. The
addDelegation and getTwin methods were never called anywhere and represent
an abandoned design. Remove them along with the now-unused makeDelegationTwin
import and the twins map.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…r twin

A delegation with only allowedTargets (no erc20TransferAmount) matches a
no-calldata tx because delegationMatchesAction skips allowedMethods when
action.data is falsy. sendTransaction then tries to decode the twin's
calldata args with data.slice() and BigInt('0x'), which throws a
SyntaxError with no useful context.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…transfer twin args

data.slice(74, 138) returns an empty string when tx.data is missing or
short, causing BigInt('0x') to throw a SyntaxError with no useful context.
Guard the decode path with an explicit length check and throw a clear error.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ldCallGrant

buildErc20Grant pushes both the timestamp caveat and a blockWindow
caveatSpec when validUntil is provided. buildCallGrant pushed only the
timestamp caveat, so call grants with a time restriction had incomplete
caveatSpecs and consumers couldn't discover the time window from the
grant's structured description. Mirror the same caveatSpecs.push call.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Two concurrent transfer calls both pass the budget check before either
commits, so spent never exceeds max during the check and both succeed
even when their combined amount exceeds the limit. The rollback method
exists for exactly this pattern but is never used.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ck on failure

Committing after redeemFn resolved left a window where two concurrent
calls could both pass the remaining() check and together exceed the
budget. Commit before the await to atomically reserve the budget, then
rollback if redeemFn throws. This is the pattern rollback() was designed
for but was never called.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ead of delegator

The test was asserting encodeBalanceOf(BOB) where BOB is the delegate,
masking the bug. The delegator (ALICE) holds the tokens; querying the
delegate's balance is irrelevant to how much can be transferred via the
delegation. Assert ALICE so the test catches the wrong address.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… delegate

The delegator holds the tokens; the delegate only has permission to
transfer them. encodeBalanceOf(delegation.delegate) returned the
delegate's own balance, which is irrelevant to the delegation's spending
capacity. Query delegation.delegator instead.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@grypez grypez force-pushed the grypez/gator-exo branch from 4d41291 to 0c1be9b Compare April 15, 2026 00:11
Comment thread packages/evm-wallet-experiment/src/vats/coordinator-vat.ts
…nator vat

The previous comment claimed the per-vat generator prevents collisions
even in the SES fallback, which overstates the guarantee. When
crypto.getRandomValues is available (all real environments) salts are
random regardless. The counter fallback is only reached in strict SES
compartments that do not endow crypto.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Comment thread packages/evm-wallet-experiment/src/vats/coordinator-vat.ts
…TransferAmount offsets

56bbb64 switched encodeErc20TransferAmount from ABI encoding to packed
encoding to match the on-chain ERC20TransferAmountEnforcer, but
describeCaveat was never updated. It checked for 130-char ABI-encoded
terms, always found 106-char packed terms instead, and silently fell
back to the generic 'ERC-20 transfer limit' string. Update the length
check and slice offsets to match packed layout.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
TransferOptions and CallOptions no longer accept an entropy field since
the refactor moved salt isolation into makeSaltGenerator() at the vat
level. The field was silently discarded, so the per-run uniqueness
guarantee described in the comment never held. Remove the unused
entropy constant, its import, and the two call sites.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Copy link
Copy Markdown

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit fd1fecf. Configure here.

Comment thread packages/evm-wallet-experiment/src/constants.ts
Copy link
Copy Markdown
Contributor

@sirtimid sirtimid left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👌

@grypez grypez added this pull request to the merge queue Apr 15, 2026
Merged via the queue into main with commit 2e05c52 Apr 15, 2026
37 checks passed
@grypez grypez deleted the grypez/gator-exo branch April 15, 2026 15:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants