Skip to content

feat: erc-7710 sub-agent budget example#1

Open
rsynthlabs wants to merge 8 commits into
mainfrom
feat/erc-7710-sub-agent-budget
Open

feat: erc-7710 sub-agent budget example#1
rsynthlabs wants to merge 8 commits into
mainfrom
feat/erc-7710-sub-agent-budget

Conversation

@rsynthlabs
Copy link
Copy Markdown
Owner

summary

ships examples/sub-agent-budget.ts for cook-off track 1 (best x402 + ERC-7710). a main MetaMask Hybrid smart account grants a sub-agent EOA a 5.00 USDC spending cap via ERC-7710 delegation on base mainnet; the sub-agent burns through it in five 1.00 USDC x402 calls to live r402.rsynth.ai/api/verify; the sixth call's DelegationManager.redeemDelegations(...) reverts on-chain at the ERC20TransferAmountEnforcer. exits 1 with budget exhausted. 5/5 used. ok.

mainnet smoke: passed. cap fully exhausted on the live delegation against main smart account 0x09cde18.... 4 successful redeems + 1 caveat-enforced revert observed; on-chain behavior matches the demo spec.

what's in the diff

file change
examples/sub-agent-budget.ts new — full demo entrypoint
README.md new ## sub-agent budgets section between integration + architecture
examples/README.md full walkthrough section (setup, costs, exit codes, architecture notes)
.env.example MAIN_PRIVATE_KEY
package.json + lockfile @metamask/smart-accounts-kit ^1.5.0

zero changes to src/, examples/buyer.ts, src/canonical.ts, tests/verify.crosslang.test.ts, or the x402 server stack. no new x402 version handling. no bundler dep — main smart account deploys via direct SimpleFactory.deploy() from the main EOA.

architecture (path C)

  • main: MetaMask Hybrid smart account on base mainnet, deployed lazily on first run from main EOA via SimpleFactory
  • sub: EOA derived deterministically as keccak256(MAIN_PRIVATE_KEY || 'r402-sub-agent-v1') — fund ETH once, replayable across runs
  • cap: scope: erc20TransferAmount, tokenAddress: USDC, maxAmount: 5_000_000 (5.00 USDC), enforced by ERC20TransferAmountEnforcer at 0xf100b081...
  • each x402 call is preceded by contracts.DelegationManager.execute.redeemDelegations(...) pulling 1.00 USDC main → sub; sub then pays r402 via standard x402 EIP-3009 (same shape as examples/buyer.ts, intentionally duplicated)
  • caveat-enforcer revert on the 6th redeem is caught via BaseError.walk + a tolerant field-fallback matcher that survives toolkit version drift

verification

  • pnpm typecheck clean
  • pnpm test — 36/37 (one pre-existing skipped); 17 new unit tests cover key derivation, delegation struct shape, caveat layout, and 5 distinct revert-error shapes for the matcher
  • mainnet smoke: 5/5 redeems consumed + 6th caveat-enforced revert. cap fully exhausted

commit trail

8 commits:
e7c0c94 chore: pin @metamask/smart-accounts-kit; env scaffold
89c1586 feat: examples/sub-agent-budget.ts erc-7710 demo
12367cb test: unit cover sub-agent-budget derivation, caveat layout, revert matcher
14e071a docs: sub-agent budgets in README; full example walkthrough
698af01 fix(sub-agent-budget): correct @metamask/smart-accounts-kit imports
d763ea1 fix(sub-agent-budget): use contracts.DelegationManager namespace correctly
db7900e fix(sub-agent-budget): drop incorrect signer-match assertion
5dffdf4 fix(sub-agent-budget): walk fallback for simulate-wrapped revert

bugfix commits 5-8 caught at mainnet smoke (toolkit API mismatches + wrong assertion + viem error-shape mismatch); each has a focused diff and standalone commit body.

main MetaMask Hybrid smart account grants sub-agent EOA a 5.00 USDC
cap via ERC-7710 delegation. sub-agent burns through it in five x402
calls to r402; 6th DelegationManager.redeemDelegations reverts at the
ERC20TransferAmountEnforcer caveat. exits 1 with 'budget exhausted'.

- deterministic sub-agent key derived from MAIN_PRIVATE_KEY (fund ETH once)
- main smart account deployed via direct SimpleFactory.deploy from main
  EOA (no bundler dep)
- caveat-enforcer revert matched via viem BaseError.walk, with a
  tolerant pattern that survives toolkit version drift
- x402 EIP-3009 flow duplicated inline to honor 'don't touch buyer.ts'
…rt matcher

guard main() with import.meta.url check so tests can import
deriveSubAgentKey and isCaveatExhaustedRevert without firing the live
demo. pin the golden sub-agent address derived from the fixture key so
any change to SUB_KEY_TAG fails loudly (existing funded sub-agents
would be stranded by a silent change). assert ERC20TransferAmountEnforcer
+ ValueLteEnforcer caveats present and terms encode the 5 USDC cap.
encodeDelegations, encodeSingleExecution, and hashDelegation live at
the /utils subpath export, not the root. typecheck passed at scaffold
time (likely a moduleResolution: 'bundler' quirk that surfaces chunk
re-exports in TS's view of the root) but the runtime ESM namespace
doesn't carry them, so the demo crashed at module load with
SyntaxError: does not provide an export named 'encodeDelegations'.

no logic change. call sites unchanged. tests unaffected (they only
import root-level symbols).
…ectly

contracts.DelegationManager is a viem-action namespace with
{ constants, encode, execute, read, simulate } keys, not a raw ABI.
the previous code aliased the namespace as DelegationManagerAbi and
passed it to walletClient.writeContract({ abi, ... }), which made viem
call abi.filter(...) and crash with 'abi.filter is not a function' at
the first redeem.

switch both redeem call sites to
contracts.DelegationManager.execute.redeemDelegations({ client,
delegationManagerAddress, delegations, modes, executions }). the
namespace helper handles delegation + execution encoding internally
and simulates before writeContract, so caveat-enforcer reverts still
surface as viem ContractFunctionRevertedError and the existing
isCaveatExhaustedRevert matcher works unchanged.

drop encodeDelegations and encodeSingleExecution imports (the
namespace handles both); keep hashDelegation (still used for the
digest log line).
verified.signer is the historical author of the payload anchored at
KNOWN_TX (the May 23 demo anchor, signed by buyer 0x156d727f...5bf3);
it does not change based on who paid x402. subAccount.address is
today's caller. comparing them always fails unless KNOWN_TX happens
to have been anchored by the sub-agent, which it wasn't and which the
demo shouldn't depend on.

the x402-leg invariant we actually need is 'r402 accepted our payment
proof' — already enforced by callWithX402 throwing X402Rejected on
facilitator reject. so the assertion was redundant on the success path
and wrong on the failure path.

drop the signerOk check and the exit-3 mismatch path. keep
verified.signer in the output as truthful info about the anchored
payload, relabeled 'verify' + '(anchored signer)' so the next reader
doesn't mistake it for an identity assertion. README mocks updated to
match the new line shape; examples/README exit codes table drops the
now-dead exit 3 row.

partial-run note: the broken run consumed $1 USDC against the cap on
tx 0xf503b7ce..., so the next mainnet run from the current state has
4 redeems remaining + 1 overflow attempt (cap exhausts at call 4/5
instead of 5/5). fresh-account future runs return to the full 5+1.
mainnet smoke after commits 698af01 + d763ea1 + db7900e ran 4
successful redeems, then the 5th on-chain redeem reverted at the
ERC20TransferAmountEnforcer exactly as designed — but
isCaveatExhaustedRevert returned false and the catch block printed
'demo failed:' + stack trace instead of the intended 'budget
exhausted. 5/5 used. ok.' exit 1.

cause: the matcher walked for ContractFunctionRevertedError and bailed
out (returned false) if walk found nothing, even though the outer
ContractFunctionExecutionError's .message / .details already carried
the revert string ('Details: execution reverted:
ERC20TransferAmountEnforcer:allowance-exceeded' was visible in the
printed stack). depending on whether the RPC returns decoded
Error(string) data or just a message, the reason can land on
inner.reason, inner.shortMessage, or only the outer wrapper's fields.

drop the early bail. collect every plausible text field across both
the walked inner CFRE (when present) and the outer BaseError
(shortMessage, details, message), join them, run the same regex
against the blob. behavior unchanged on the happy path
(inner.reason-populated) — strictly more permissive for live
simulate-wrapped reverts. negative tests pin that unrelated ERC20
reverts (e.g. transfer-amount-exceeds-balance) still return false.

mainnet cap is now fully exhausted on the live delegation
(0x09cde18..., 5/5 redeems consumed). no further mainnet rerun is
needed to validate on-chain behavior; the four new unit tests cover
the simulate-wrapped shape that the previous matcher missed.
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.

1 participant