ERC-8226: Regulated Agent Mandate

Following up on Vlad’s post above, same team. The reason-code proposal didn’t come from reading the spec, it came from integrating it. Here is that work.

What we built

We wrote a token-side 8226 integration from the published spec on July 8, no contact with the Brickken team beforehand. It is live on Ethereum Sepolia and transacting against the reference registry at 0xD68E1bb972cA4EF7F5764FBf6d685a6DfC26778e. Three contracts, all source-verified:

  • GatedUSDRams, a RAMS-aware ERC-20: 0xd501D68214503Fa03B5179F556029CD15D7f7cAa

  • VARComplianceProviderAdapter, an IComplianceProvider: 0x7302C8ee3E3f53cD85E0BAF1bDe8479DD19575EB

  • DelegationMirror, the delegation registry the adapter reads from: 0x415e267C3C2B1835667b4aDda731599a4B847A3b

The end-to-end flow

A mandate granted on the reference AgentMandate with our adapter as complianceProvider, then an agent-initiated transferFrom through our token, then a blocked one.

grantMandate   0xe5dfe2fbf900d41e0122743bf7a36ab7c4b1bfdd4aa82af6ee3a9ebd9b78ec54
transferFrom   0x796a690853f9c79b71c6dd52892c9e42da447eac9a08fca7329528236869ec6c   (cleared)
transferFrom   0xdfd1877a8e5fed2c910f9ec0bcab93c8409d015ff38ea2cb80feb40931f51d74   (blocked, status 0)

cumulativeUsed read at block 11411043, after the grant and before the transfer: 0. Read at latest: 90000000. The block boundary isolates the transfer as the cause, and the registry’s own ExecutionRecorded event at 11411044 corroborates it. The blocked call reverts RamsBlocked carrying RAMS_OVER_TX_CAP, cumulativeUsed stays put, the sink balance does not move.

We also rebuilt the reference implementation out-of-tree with the deployed toolchain (solc 0.8.30, optimizer 200, OZ 5.6.1) and diffed runtime bytecode against eth_getCode. All three contracts identical apart from the CBOR metadata tail. So the Sepolia deployment everyone can test against is the reference, byte for byte. We found no vulnerability in the contract code.

One property worth stating in the spec: hasRole(RECORDER_ROLE, ourToken) is false and recordExecution succeeded anyway, through the msg.sender == m.asset branch. An outside integrator needs no privileges on the registry to conform. That is a good property and it is not obvious from the spec text.

What the bare bool cost us

This is the concrete case for the enum. canExecute collapses eight failure branches into false, and those branches demand opposite responses from an agent operator: re-issue, wait, fix a bug, retry smaller, or stop until the next mandate.

Our workaround, GatedUSDRams.ramsDiagnose, re-derives the failing check in the registry’s evaluation order and returns one of ten bytes32 codes. It is what produced RAMS_OVER_TX_CAP in the blocked receipt above. The cost: a second getMandate read plus up to three more external calls on the revert path, it only works because the mandate struct is fully public, and it silently rots if the check order ever changes. An integrator should not have to mirror the registry’s control flow to tell a user why a transfer failed.

Given Thamer’s note that 8226 is still draft, we’d change the signature rather than add a second view:

function canExecute(...) external view returns (bool allowed, ExecutionReason reason);

One function, one implementation of the checks. The spec should also require reporting the first failing check in the documented order. Leave that open and two registries answer differently for the same call when several conditions fail at once.

Three more observations from the integration

checkPrincipal’s expiresAt is discarded at grant time. AgentMandate.sol:69 drops both reason and expiresAt, and the Mandate struct has no field for the latter. Storing it and checking it in canExecute would bound the revocation-to-freeze window to the KYC expiry the provider already publishes, no enforcer required, no extra runtime external call. Our integration takes the other route and re-checks checkPrincipal live on the execution path, which closes the window to zero blocks at our asset but helps nobody else. Both seem worth having.

Record persistence on revoke. The reference revokeMandate flags rather than deletes, which is what makes the permissive integration pattern safe. The spec does not require it. The reference canTransfer falls through to plain allowance rules when getMandate(...).principal == address(0), so an implementer who deletes the struct on revoke (the obvious gas optimization) turns revocation into a permission upgrade for any agent still holding an allowance. We hit exactly this trap in our own registry design. One normative line closes it: MUST NOT delete the mandate record on revoke.

No per-agent aggregate across principals. An agent holding mandates from N principals has N independent budgets and no ceiling on the sum. This may be the right design, since per-principal caps mean each principal controls only their own risk. Raising it as a question, not a defect. If deliberate, one sentence in Rationale saves integrators the derivation.

Our own limits, stated plainly

Symmetry matters in a review like this, so the same bar applies to us. Our compliance provider is personhood-attested by a registered attestor key, not trustlessly World-ID-verified. AgentBook lives on World Chain and no canonical state root of chain 480 exists on Sepolia, so the stronger claim was never available. The attestor is a single EOA, no multisig, so every eligibility verdict downstream of it is bounded by one key. grantPrincipal and revokePrincipal revert unconditionally in our adapter, a deliberate deviation since the spec declares both without optionality. None of it is audited.

During this work we also caught a bug in our own code: revoke() left the mandate nonce unchanged, so a dead mandate could be re-bound and the revocation latch reset. Fixed, with regression tests. Same class of trap as the record-persistence item above: state that looks current because one field was not advanced.

Offers

Happy to PR any of these, in whatever order is useful:

  • expiresAt in the mandate struct plus the check in canExecute, with tests

  • the reason-code change, based on what ramsDiagnose already does

  • the agent-custodied integration pattern as a second documented example, with tests (the published pattern only demonstrates principal-custodied, and agent-custodied, where the agent holds the funds and msg.sender == from, is the primary case for autonomous agent payments)

  • text for the record-persistence clause

The full review with a paste-and-run verification appendix went to Ludovico and Thamer on August 3. One caveat if you rerun it: the historical read at block 11411043 needs an archive node. publicnode returns a historical-state error, drpc serves it.

1 Like