Hello everyone! We have pushed a fairly large revision of ERC-8226, deployed on two testnets, Ethereum Sepolia and Base Sepolia. Here is what changed and where to poke at it.
Reason codes
canExecute used to return a bare bool. It now returns (bool ok, MandateReason reason). The parameters are unchanged and the selector is the same, so a caller built against the old ABI keeps compiling and keeps reading the boolean correctly. It just stops seeing why.
enum MandateReason {
OK,
NONEXISTENT,
WRONG_ASSET,
NOT_YET_VALID,
EXPIRED,
REVOKED,
ACTION_NOT_ENABLED,
AGENT_FROZEN,
PRINCIPAL_FROZEN,
OVER_TX_CAP,
OVER_CUMULATIVE_CAP,
OTHER
}
This list supersedes the one earlier in the thread, so please read the values rather than the positions.
The rules around it are normative now, not descriptive. The reason is the first failing check. Values are append only. OTHER is reserved for implementation specific checks and must not stand in for a listed reason. A registry that cannot evaluate a check must revert rather than return false, so the check said no and the check could not run stay distinguishable. And since the registry holds no funds, it must not return OTHER for balances, allowances or custody.
The validity window is split into NOT_YET_VALID and EXPIRED, because a single OUTSIDE_VALIDITY_WINDOW pushes the wait or reissue decision off chain, which is the thing the enum exists to avoid.
Two freezes, and one thing that breaks
freezeAgent halts every mandate an agent holds. freezePrincipal halts every mandate a principal granted, including mandates granted while the freeze is in place, so nothing needs enumerating.
Neither revokes. Revocation stays with the principal, because a freeze has to be reversible and re granting needs a fresh signature from the principal, which is exactly what you do not have in the compliance scenarios a freeze exists for. It also means a registry operator cannot permanently unwind someone’s delegations.
Both freezes are evaluated before the mandate specific checks, so an enforcement freeze is not masked by an unrelated failing check on one mandate. Only NONEXISTENT comes first, since a pair with no mandate has nothing to halt.
Splitting agent and principal freeze breaks one thing. isFrozen is now isAgentFrozen, so anyone reading agent freeze state through the old selector needs to switch. canExecute itself is unaffected, same selector, same first return value, an old caller simply does not see the reason.
Compliance window
A mandate can no longer outlive the principal’s compliance window. grantMandate and extendMandate bound validUntil under the provider’s expiresAt, and extendMandate re checks checkPrincipal.
canExecute still does not call the provider. It sits in the transfer path of every gated asset, and an external call there would let a provider that reverts or becomes unreachable halt everything referencing it. Eligibility lost inside the window is handled by the enforcer freeze and by the asset’s own checks.
A provider must also not shrink the window of a principal that stays eligible, since the registry does not read expiresAt again while a mandate is live. If you need to narrow someone’s authority, call revokePrincipal and issue a new grant, so the change shows up in the logs instead of silently applying to mandates already outstanding.
This is also where we are landing on the open question from a few weeks back, whether canExecute should call checkPrincipal again during execution. We are keeping it as grant time plus bound rather than live. A live check adds an external call and a provider availability dependency to every gated transfer, and turns a provider outage into a halt on every asset that references it. The bound under expiresAt converts mandate length into the interval at which eligibility gets revisited, which is the tradeoff we would rather make explicit than hide behind a call that fails closed silently. The real gap this leaves: a negative eligibility result and a merely stale one both surface as nothing until the bound expires or an enforcer acts. If that gap matters more than the cost of a live call in your deployment, that is the argument to make here.
Token integration
A token now applies one modifier per gated function and passes that function’s own selector as the label.
function transferFrom(address from, address to, uint256 value)
public override
gatedByMandate(IERC20.transferFrom.selector, from, value)
returns (bool)
{
return super.transferFrom(from, to, value);
}
Every function an agent can perform for a holder carries its own label, and the label cannot drift with the call path. A balance update hook cannot do this, because it only sees (from, to, amount) and cannot tell which entry point it was reached from, so it can only ever carry one label. RAMS is a per action standard, so the gate belongs on the function.
The spec also pins how a bytes4 selector becomes a bytes32 label. It is left aligned as bytes32(selector). The principal computes that label off chain and the token computes it on chain, and padding the two ends differently makes the mandate silently never match, which surfaces as ACTION_NOT_ENABLED for an action the principal did authorize.
canTransfer and canExecute answer different questions. canTransfer is about whether from and to may hold the asset. canExecute is about whether this caller has authority to act for the holder, and it takes the agent and the action explicitly. So an asset’s canTransfer can say true for a transfer canExecute refuses. If you are integrating, check both.
Venues
The three venues now split on whether you control the asset.
- A new or upgradeable token gates its own functions.
- EIP-7702, where the principal delegates their account to an IAgentExecutor.
- A standalone executor the principal approves.
Two and three exist because an asset already deployed without RAMS awareness cannot be gated. They do not combine: a gated token evaluates msg.sender, so a call forwarded by an executor gets checked against the executor rather than the agent.
One asymmetry worth flagging for anyone building on venue three. recordExecution only accepts a call from the mandate’s asset, from the principal, or from an address holding the registry’s recorder role. A standalone executor is none of those by default, so every execute call reverts with UnauthorizedRecorder until that role is granted by the registry operator. Venues one and two clear without any registry side grant. Venue three does not.
Smaller normative changes
Every event in both interfaces is tied to its trigger. Nothing is emitted for the actions a new mandate clears, so consumers reading logs must treat MandateGranted as resetting the action set for the pair.
Revocation keeps the record. Deleting it would let an agent that still holds a token allowance fall through to plain allowance rules, turning revocation into a silent permission upgrade.
The cumulative cap comparison was cumulativeUsed + amount > maxCumulativeValue. With no per transaction cap to bound amount first, a large enough amount overflowed the addition and the call panicked instead of answering. It is now amount > maxCumulativeValue - cumulativeUsed, in the prose as well as the code, which cannot overflow.
grantMandate rejects a bytes32(0) action label, a validUntil at or before validFrom or the current block, and a past expiresAt from a provider reporting the principal eligible.
Each signed operation must use a distinct nonce, and the caller rule for operators on revokeMandate and extendMandate is stated.
IComplianceProvider.ReasonCode gets the same stability rules as MandateReason, since it crosses the same ABI boundary.
Revert conditions are normative, error selectors are left to implementations, so no errors are declared in the interfaces.
The enabled action set must be enumerable on chain, otherwise reissuing a narrower mandate silently keeps the wider one’s actions.
The Rationale groups every reason code into five responses: wait, retry with different parameters, the principal must act, an enforcer must act, or no remediation can be inferred. Security Considerations covers the recorder role as a trusted surface, an ungated function admitting an agent on its bare allowance alone, EIP-7702 enforcement belonging to the executor since the token cannot see the agent, and the runtime gap when an executor drives an asset with no compliance logic of its own.
Reference implementation
RamsGated is a small base contract holding the registry and the gatedByMandate modifier. RamsGatedURWA20 is an ERC-7943 asset that inherits it and gates three functions with three labels: transferFrom, approveFor and mintFor. mint, burn, forcedTransfer and setFrozenTokens stay ungated, since the token’s own roles authorize those.
105 tests, full line and branch coverage on the new contracts.
Deployments
| Contract |
Ethereum Sepolia |
Base Sepolia |
| AgentMandate |
0xB7e7B1ca762144A135FB43F9f543Ee76B21B8583 |
0x315e8Cbbac2Edeae29c5A2bFa3E498185e504B26 |
| ComplianceProvider |
0x58F4A2cb61e90682a71Cae28a59539C0e5CA43A5 |
0x87503D38C8fe5507e3EF819b7650ffBF36057ea7 |
| AgentExecutor |
0xBf646039716809db23f3D660219d8afF0bb8478B |
0xbD5514dC63090F1d60550aB938cedFeD9f88fDb1 |
| RamsGatedURWA20 |
0x8DBDa3cF2874CF297fD5F552236a976783A74037 |
0xd716e0AB9B3EF3647Cd1377915Ea0d7b078A182e |
Grant yourself a mandate, try to break the caps, freeze an agent, freeze a principal, call a gated function without a mandate and check the reason you get back. If a reason code comes back that does not tell you what to do next, that is a bug in the design and we want to hear about it.
Same for the gate. It assumes every gated function has one action and one holder you can name at the call site. If you have a function where that does not hold, a batch call moving several amounts under one selector, or one where the holder is only known once the body runs, tell us. That is the part most likely to need another pass.
Thanks to BlueCore Studio, @VladKuzR and @a-laz for the reason code design and the integration findings this revision is built on, and to @helmymekaoui-web , @Anzus_GemWallet and @babyblueviper1 for the funding boundary, wallet remediation and scope discussion that shaped it. Most of what is above came out of that back and forth.