ERC-8226: Regulated Agent Mandate

Really great work, and thanks for testing this end-to-end!

Just one clarification from our side: we don’t currently have an ERC-7943 token deployed in the live Sepolia setup. The spec shows how RAMS composes with ERC-7943-style tokens, and we’ve implemented and tested that integration separately, but it isn’t part of the current live deployment.

The blocked case is a great demonstration of the separation though: asset compliance passes while RAMS rejects on the mandate-specific cap. Great job making this reproducible!

Good to know — added that to the artifact’s README (a short update block right after the finding, credited to this comment) so future readers get the full picture: not-deployed-here vs. not-implemented, with the separately-tested integration noted.

If it’s useful I can also run the composed case against a token that does implement canSend/canReceive (even a minimal test double), so the repo demonstrates both branches side by side — happy to build that if it adds anything beyond the separate integration you already have; let me know either way.

Thanks @thamerdridi for the clarification.

I went back through the recent discussion to make sure I am following how the different points connect. My current understanding is:

Discussion Raised / clarified by My understanding
Standardized mandate failure reasons @VladKuzR, @a-laz → clarified by @thamerdridi canExecute() is moving from a bare boolean to (allowed, ExecutionReason), with the first failing check reported in a normative evaluation order.
Funding vs mandate authorization @helmymekaoui-web → clarified by @thamerdridi Insufficient balance, allowance, or funding belongs outside the RAMS reason enum because those conditions are not evaluated by the mandate registry.
What users should do after a rejection @Anzus_GemWallet → addressed by @thamerdridi Reason codes can map to different remediation paths, such as reducing the amount, requesting a new mandate, waiting, or requiring enforcer action.
Contextual judgment @babyblueviper1 → clarified by @thamerdridi RAMS answers whether an action is authorized under the mandate. A separate judgment about whether the transaction is appropriate in context remains outside RAMS and can compose at the executor or venue layer.
Revocation and mandate persistence @a-laz → clarified by @thamerdridi Revocation should preserve the mandate record and close its authority rather than allow execution to fall back to a more permissive path.
Asset compliance vs RAMS authorization @babyblueviper1 → clarified by @thamerdridi Asset-level transfer compliance and mandate authorization are separate checks. The asset may permit a transfer while RAMS rejects it because of a mandate-specific condition such as the transaction cap.
ERC-7943 in the current Sepolia setup @babyblueviper1 → clarified by @thamerdridi The current live Sepolia deployment does not include an ERC-7943 token. The ERC-7943 composition has been implemented and tested separately.

So if I am reading the discussion correctly, the common pattern seems to be keeping each decision boundary explicit: asset compliance, mandate authorization, venue-level execution conditions, and optional contextual judgment each answer a different question.

That leaves me with one question for @thamerdridi and the others involved:

Would it be useful for the reference material to include one end-to-end example that puts these boundaries side by side, including an ERC-7943 token using canSend() / canReceive(), RAMS canExecute(), and the venue-level result, so an integrator can see which layer produced each decision without having to reconstruct it across separate examples?

1 Like

@WeissCurry yes – that end-to-end example seems useful, and your recap helps sharpen the scope. It’s also close to what I raised a couple posts up.

Concretely: a small, configurable ERC-7943 test double (canSend/canReceive returning cleared or blocked per scenario, enough to exercise both branches, not a full compliant token) deployed alongside the existing RAMS registry, run through a cleared/blocked pair, with all three decision layers (asset compliance, canExecute, and the venue-level result) cited against the same transactions side by side. I can scope this as real follow-up work – the existing example repo has no Sepolia signer/deploy path wired up today, so it needs that plumbing first, not just a contract dropped in. If it’s prioritized, I’ll post an artifact link here once it’s actually implemented, not before.

The cost question is for @a-laz and I won’t answer it — I have no deployed integration on your side to measure. But the framing above puts this as stored-expiry versus live re-check, and there is a third point on that axis that I have running, so the tradeoffs below are observed rather than predicted. Offering it before the PR closes, since after that it is a breaking change.

Store the moment of the last verification, and bound how stale it may be at execution time.

In ERC-8370’s enforcement gate, confirming an issuer key writes the epoch itself:

function confirmIssuerKey(uint256 issuerKey) external onlyGuardian {
    require(block.timestamp >= proposedAt[issuerKey] + TIMELOCK, "timelock not elapsed");
    issuerEpoch[issuerKey] = uint64(block.timestamp);   // the contract chooses it, not the issuer
}

and execution refuses anything older than a fixed window:

require(block.timestamp <= uint256(m.epoch) + uint256(MAX_WINDOW), "verdict expired");

MAX_WINDOW is 7 days, TIMELOCK is 2 days, both constants. Deployed and source-verified on Base Sepolia.

What this buys, against the two options as stated:

  • No external call at execution time. No provider-availability dependency, no revert-on-unreachable, no gas paid to a third party on every transfer.
  • Staleness is bounded and explicit. A stored expiry can be arbitrarily old and still pass. Here the maximum age is a constant anyone can read on-chain, so an auditor does not have to trust a claim about refresh cadence — they can compute it.
  • The issuer cannot choose its own freshness. The epoch is written by the contract from block.timestamp. That mattered more than I expected: it is the property that makes the window meaningful rather than declarative.

The honest cost, measured rather than assumed: somebody has to re-attest inside the window, and if nobody does, the authorization lapses. One of mine expired this morning, unused. Re-arming is not instant either — a new epoch requires re-proposing and waiting out the timelock, two days in my case. That is real operational friction and I would not present it as free.

Applied here, if a live check is adopted, one thing on the reason code. PRINCIPAL_INELIGIBLE covers a negative eligibility result. It does not cover stale eligibility evidence — nobody re-attested in time. Those two have opposite remedies: refresh the attestation, versus stop and escalate. Collapsing them into one code is the same failure the enum exists to prevent, which is the argument I made about funding a few posts up, and I think it applies again here.

Whether a bounded window fits RAMS is your call — the compliance semantics may well require liveness that a window cannot give. But it is a third point on the axis, it is running, and the friction above is what it actually costs.

Thanks all. Making each decision boundary explicit is the direction of the next revision, and @WeissCurry’s table maps where it currently stands.

@helmymekaoui-web, you’re putting the bounded window on the table now precisely because it stops being cheap once the PR lands. It arrives in time. Eligibility freshness is the one point still open on our side, and the split between a negative result and stale evidence is the part we had not separated.

@babyblueviper1, yes, treat the composed case as prioritized. We’ll carry the boundaries through the reference material; the live side is better built where it already runs.

The reason code flow and the authorization versus funding boundary go into the PR as discussed. Once it’s open, we are already working on it, how would you want each boundary surfaced so an integrator can tell which layer produced a decision?

Concretely: a small structured record per transaction, one entry per layer, each entry self-labeled with which interface/call produced it and whether it was even reached — not a merged bool. Something like {layer: "asset_compliance" | "mandate_authorization" | "venue_execution", source: "canSend()" | "canExecute()" | ..., evaluated: true|false, outcome, reason}. The evaluated field matters as much as the outcome: if mandate authorization denies first, venue execution never ran, and recording that layer’s slot as a bare false would read as “this layer also said no” when it never got the chance to say anything. Same collapsing-eight-branches-into-one problem VladKuzR named for canExecute() alone, one level up — it applies across layers too, not just within one.

The other half, learned building our own verdict provenance: separate what’s cryptographically bound from what’s informational, and say so per-field, precisely. We ship a hash of our review-model config alongside each signed verdict — bound into the same signed record, so a config swap between two verdicts is detectable. But the doc for that field is explicit it proves what we say we configured, not that the serving stack actually ran it; the hash alone, unsigned or unbound, wouldn’t give you even that. Worth the same precision here: canExecute()'s reason code should probably be part of whatever’s bound to the mandate’s own signed state, while a cited judgment layer (if one’s in the record at all) is informational only, clearly marked as outside what RAMS attests to. Presence of a field in the record shouldn’t read as a stronger claim than what actually backs it.

The composed example is still the right way to make this concrete rather than abstract — I’ll scope the plumbing (Sepolia signer/deploy path, the test double) as real follow-up and post the artifact once it exists, not before.

Built it: invinoveritas/examples/erc8226-three-record-composition @ 52dcbbe7, Part 2 in the same README — commit-pinned, not main, so the evidence linked here can’t drift. Full addresses, tx hashes, decoded events, the EIP-712 grant payload, and pinned-block re-checks are all there; chain is Sepolia (11155111) throughout.

A fresh, minimal ERC-7943-shaped test double (0x3dd1Fc46c3FAf44B46733689bAb47157b530783f, canSend/canReceive real and queryable, gated by one blocked flag) deployed alongside a real mandate self-granted on the live AgentMandate registry (0xD68E1bb972cA4EF7F5764FBf6d685a6DfC26778e, grant tx 0x946f4a9e721264bf22a76cf905f920b926a6845f707db0054413074321b2fb28, agent 0x3a260e797339f4Bc822ee67A1d52cfd04719EB07, principal 0xc5eC2960Ad560AFE09602605CBCEa060244C4178) — submission/relay turned out to be permissionless to anyone once the named principal has actually signed the EIP-712 grant, so no registry-admin or counterparty cooperation was needed, just the principal’s own signature; domain separator checked byte-for-byte against the real on-chain DOMAIN_SEPARATOR() before signing anything for real.

Two real cases, agent as tx.from, sending to the principal, cap=100,000,000 base units (6 decimals) on the mandate: a 90,000,000-base-unit transfer clears everywhere (canExecute=true, canSend/canReceive=true, venue tx 0x12e7fc69ae7660f534e6d460155b69aac3d45299f84fbe5087a28e2033968947 succeeds, decoded Transfer event amount matches exactly). A 150,000,000-base-unit transfer has canExecute=false while canSend/canReceive still say true — and the venue transaction (0xdb832e96dfc595407a76771d9e745a0d9be3fefa8e13d116be270b78665834a8, decoded amount also matches) still succeeds (status 1). That’s not a bug in the double, it’s the finding: this test double never calls out to RAMS, so nothing stops it from executing an action the mandate refuses. Record 2 having a real, honest opinion doesn’t help if nothing binds Record 1 to Record 3 — the three records still have to be checked independently, not inferred from each other, and RAMS here is a diagnostic an integrator can consult, not an execution control unless the asset or venue explicitly enforces it.

Ran both cases through our own /review as the third record too: approve_with_concerns on the cleared case, flat reject on the over-cap one — for exactly the reason above (RAMS denies, the asset let it through anyway, so treating the completed transfer as authorized would contradict the governing layer). Caught two of my own build mistakes along the way (an early draft had the deployer stand in for the agent, then a stale sentence in the review artifact contradicted the actual decoded transfer event) — both are left in the README rather than quietly fixed out of the history, same as Part 1’s own honesty about what the live token does and doesn’t implement.

Answering the surfacing question directly, from the enforcement side.

First, what already exists, because most of the shape does and the interesting part is
the remainder. Typed reason codes returned by a view function are eight years old: ERC-1404’s
detectTransferRestriction() returns a code plus a human-readable message, drawing on
ERC-1066 status codes, and it is deployed across regulated tokens. So “return a machine-
readable reason instead of a string” is not a proposal, it is settled practice, and the enum
in this thread is the right move.

What I have not found any of them doing is answering more than once. ERC-1404 returns a single code — the first
restriction hit. ERC-3643’s modular compliance calls its modules sequentially and returns a
bool, so an integrator cannot tell which module blocked. ERC-4337 returns one packed word.
And first failing check is a MUST makes that normative here. Each of these tells you
the first thing that is wrong, and nothing about the rest. If one of them does return a full
set and I missed it, I would rather be told now than after building on the assumption.

That costs a round trip per additional reason, and the round trips are not independent: fix
the first, resubmit, learn the second. A mandate that is simultaneously over its cap and out
of room takes three submissions to fully diagnose. The surgeon saying “surgically I am ready,
anaesthesia is not” is not an objection from the surgeon — and it is the answer that tells
you both what to fix and whether fixing it will be enough.

So: one record per layer, each answering within its own perimeter, five states, because
that is how many distinct remedies there are:

  • allow — evaluated within its perimeter, permits;
  • deny + reason — evaluated, refuses on its own grounds. Remedy: stop;
  • stale — the evidence aged. Remedy: refresh. Not a refusal;
  • not applicable — the question has no object at this layer. It informs, and must not
    block, because the enforcement path does not block on it either;
  • undetermined — the question exists, nothing refused it, and evaluating it failed.

That last one is @babyblueviper1’s evaluated: false given a place in the return value, and
he is right that it is load-bearing. “This layer could not speak” is not “this layer said
no”, and collapsing them produces exactly the merged-bool problem one level down. Where we
differ: I would not mark a layer unevaluated merely because an earlier one denied. If it can
answer on its own grounds, it should, and say so.

On the cost of evaluating everything: canExecute is view. Off-chain the full set
costs nothing, so the saving a short-circuit buys does not exist on the query path. The
enforcement path is where stopping early is worth something, and it should keep doing it.
Those are two different jobs sharing one function today, and separating them is free while
the order is still open.

On dating, ERC-4337 already settled the principle and this proposal would regress from
it.
validationData packs validAfter/validUntil, and the spec states how to compose
two of them: the validAfter is the maximum and the validUntil is the minimum of both. An
authorization is a claim with a shelf life, and a composed authorization holds only while
every part is simultaneously fresh. That is standard, it is deployed everywhere, and a
reason enum with no timestamp is strictly less than what a bundler already gets. Anaesthesia
clears at 08:00; at 14:00 the surgeon says the window has moved. The 08:00 answer was not
wrong, it was stale.

One thing I have found nowhere, and I looked. The principal is a party, and has no slot.
Every layer can clear and the owner may have withdrawn consent, or may want the check re-run
now rather than a stored approval reused. Neither is a technical denial — every layer stays
green — and neither is representable in any of the above. The second case is exactly the
stored-expiry versus live re-check axis already on the table, but initiated by the principal
rather than by the system.

Live, and cheap to check. Read-only, deployed beside an existing ERC-8370 enforcement
gate on Base Sepolia and reading it through public accessors. The gate is untouched, which
is also the real integrator’s situation — you inherit a gate, you do not redeploy it.

decision record 0x05544ed4823587534612cc7159019c109ed0c48b
gate it reads, unmodified 0x34a9ab58756b9a0579d9d156292412bbed87cbe8
failing transaction 0x66b02b9b4a3c020d58e994448914e4332b73214a6aea5121ee25f14c92d549b3 — status 0

The case is a real agent: existing, active, fully spent. The verdict is really signed by an
authorised issuer, and is both expired and negative. The gate returns verdict expired,
which reads as go refresh. The record returns, at the same block: freshness stale,
verdict decision deny, effective cap deny, room deny. Three real denials behind
the one string, two of which no refresh will ever lift.

Where this bit me, since that is the part worth reporting. ERC-8370’s own gate chains
eleven requires returning strings, so stale evidence and refusal leave through the same one
— the defect I am describing is one I shipped. And the first version of the record was
itself wrong: it inferred “not applicable” from an ownership check the gate never performs,
while the mandate’s isActive walks the lineage and answers true for a non-existent agent.
It blocked what the gate let through. A differential property — the reader allows if and
only if the gate succeeds, fuzzed — reproduced it in one line and now guards it. I would not
trust the shape above without that property, and I would not expect anyone else to.

On where we differ: agreed, and it sharpens what I meant rather than contradicts it. The case I had in mind was specifically execution – a layer with a real side effect, which genuinely cannot be evaluated hypothetically once an earlier layer stops the pipeline. For that layer, “undetermined” honestly means not attempted because the pipeline stopped, not attempted-and-unresolvable – worth naming which one it is explicitly rather than overloading the same label for both. For a pure view layer – asset compliance, mandate authorization – you’re right that nothing structurally stops full evaluation regardless of an earlier denial, and where the reads are available against the same relevant state, reporting all of them is usually cheap. There, “undetermined” should mean the layer was asked and genuinely could not answer, not “an earlier layer already said no so we didn’t bother.” That’s a real narrowing for the layers it applies to, not a restatement of the whole idea.

On the principal having no slot: that’s a related but distinct gap from the one above – yours is missing representation for a principal-level instruction (withdraw, or force a re-run), not an unreached evaluation. The closest thing we have is one layer earlier and only half-built: our own admission record (written the moment a /review request is accepted, before verdict computation runs) has a disposition state machine with a schema-defined cancelled terminal, but no live path reaches it – a requester cannot currently cancel a request already in flight. Related shape (schema slot with no real transition into it), not the same problem (yours is about representing principal intent inside a per-layer decision record, ours is about lacking a cancellation action at all).

If none of your five states is intended to represent whether the principal still consents to this specific evaluation, would you treat that as a sixth state on the existing record, or as a separate principal-level record that gates whether the per-layer record even gets consulted? Curious which way the live deployment is leaning.

The differential property (reader allows iff gate succeeds, fuzzed) catching a real bug in your first version is the right bar – matching against the gate directly instead of trusting your own re-derivation of its logic is exactly the class of check that would catch that specific failure mode, and did.

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.

  1. A new or upgradeable token gates its own functions.
  2. EIP-7702, where the principal delegates their account to an IAgentExecutor.
  3. 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.

2 Likes

Thanks for the update and for including the wallet-remediation perspective. Separating “not yet valid” from “expired” and connecting each reason to a possible next step should make failures much easier to explain.

From a user-support perspective, the important thing is helping someone understand whether they should wait, change something, request new permission, or seek help. Glad to see that reflected in the revision.

1 Like

Took the invitation to poke at it. Real call against the live Ethereum Sepolia deployment (AgentMandate at 0xB7e7B1ca762144A135FB43F9f543Ee76B21B8583, block 11619954): canExecute() for a fresh throwaway address with no mandate ever granted returns (false, 1) – NONEXISTENT, exactly right.

Went further and read the actual source (the PR’s assets/erc-8226/contracts/AgentMandate.sol) to try granting myself a mandate for the full cap-break/freeze test cycle, and hit a real, correct gate: grantMandate() calls checkPrincipal() on the ComplianceProvider and reverts PrincipalNotEligible() if the caller isn’t already an approved principal – and grantPrincipal() on ComplianceProvider is onlyOwner, so a throwaway test wallet genuinely can’t self-onboard. That’s the design working as intended (compliance sovereignty staying with the token issuer, not something an agent or even a mandate-granter can route around), not a gap – just means the rest of the test cycle (breaking caps, freezing an agent/principal, the delayed-activation path) needs either a compliance grant on this deployment or a self-deployed instance where I hold that role. Happy to do either if useful – let me know which is less friction on your end.

1 Like

Went all the way through the cycle you invited. Real finding first: the source tree in the PR (assets/erc-8226/contracts/AgentMandate.sol) is stale relative to what’s actually deployed – it still has the bare-bool canExecute, no ENFORCER_ROLE/freeze functions. Pulled the real deployed source straight from Sourcify’s verified-contract API instead (matches the live bytecode exactly), self-deployed fresh ComplianceProvider + AgentMandate instances on Base Sepolia so I could hold the compliance-operator role myself, and ran the full cycle for real:

Granted myself a mandate via a genuine EIP-712 GrantMandate signature (reconstructed the typehash byte-for-byte from source, not guessed – validated because the signature actually verified on first submit). Then, all via real on-chain calls, confirmed 8 of the 12 reason codes exactly:

  • NONEXISTENT(1): against your actual live Sepolia deployment, no self-deploy needed for this one
    • OK(0), WRONG_ASSET(2), ACTION_NOT_ENABLED(6): straightforward
      • OVER_TX_CAP(9): a single amount above maxTransactionValue
        • AGENT_FROZEN(7) / PRINCIPAL_FROZEN(8): via a second wallet holding ENFORCER_ROLE – tried granting the admin wallet ENFORCER_ROLE first and hit AdminEnforcerOverlap, a real deliberate separation-of-duties check, good design
          • OVER_CUMULATIVE_CAP(10): recorded three real executions to build up usage, then confirmed an in-tx-cap amount still correctly failed once it pushed cumulative past the mandate’s cap, with a same-size under-cap amount confirmed still OK right after
        • One real gotcha, not a contract bug: right after freezeAgent’s tx confirmed, canExecute and isAgentFrozen both still read the old state on the RPC I was using – purely propagation lag, resolved a few seconds later on a fresh query. Worth a note in the doc that a caller checking state immediately after their own freeze tx should query against the tx’s own block or expect a short lag, not assume same-block-instant visibility on every RPC.
      • Didn’t get to NOT_YET_VALID/EXPIRED/REVOKED/OTHER – those need either real elapsed time or another signed call, and 8/12 with the two things you specifically asked about (caps, freezes) felt like a good stopping point. Happy to finish the rest if useful.
1 Like

Great work @babyblueviper1 , and thanks for testing the full cycle with real onchain calls. On the source mismatch, it looks like you were reviewing the older PR snapshot. The updated implementation, including reason-coded canExecute(), ENFORCER_ROLE, and the agent/principal freeze functions, has since been merged in PR #1982. The current master source contains those changes and should be the reference going forward.

Thanks for the pointer — makes sense, the mismatch was PR-branch-vs-deployed, not deployed-vs-spec. Cross-checked PR #1982’s diff directly against what I actually tested on-chain: canExecute returning (bool, MandateReason), ENFORCER_ROLE-gated freeze/unfreeze for both agent and principal, the admin/enforcer separation I hit as AdminEnforcerOverlap, and the cumulativeUsed comparison rewritten to amount > maxCumulativeValue - cumulativeUsed (the overflow fix). Since my testing was against the live deployed bytecode via Sourcify rather than the stale PR source tree, those 8/12 results were already against this same logic — PR #1982 just brings the PR branch itself into agreement with what was already live, so nothing needs re-running on my end.

Still happy to run the remaining four (NOT_YET_VALID/EXPIRED/REVOKED/OTHER) against the current source if useful — those need real elapsed time or another signed GrantMandate call, which is why I stopped short last round.

We deployed an independent IComplianceProvider implementation and ran it end to end against the current registry. Posting the cost figures Thamer deferred on in post 18, plus one argument for adopting the live re-check.

What we deployed

The provider attests the registry state of a credit instrument rather than the identity of a person: whether the underlying right still exists, who holds it, whether it is encumbered. identityRef carries the reference to the evidence package. This is close to what Nicopat raised in posts 4 to 6, applied on the eligibility path itself rather than composed above it.

The asset is called directly by the agent, with no executor and no RECORDER_ROLE, through the msg.sender == m.asset branch that a-laz documented in post 14. It re-checks checkPrincipal before acting.

Ethereum Sepolia   provider 0xFeDEdF7257Bf66FAfDA3fd45FA689869a4F8b960
                   asset    0xda77f42fE0356b954625A1935b3f816aba025aDD
Base Sepolia       token    0xf04cbd6e541f1328020e751a8c01529bb5058d05
Records            https://registry.factos.co

Cost of the live re-check (post 18)

Measured against 0xB7e7B1ca762144A135FB43F9f543Ee76B21B8583:

checkPrincipal, cold                14,125 gas
checkPrincipal, warm                 3,622 gas
canExecute, already on the path      7,360 gas
full gated operation, first call    72,395 gas
full gated operation, repeated      18,993 gas

The live re-check costs roughly twice what canExecute already costs, and about 19 percent of a full gated operation on a cold call.

Why we think it earns that cost for this asset class

The expiresAt bound added on 31 August closes the case where eligibility lapses on a clock. A credit right does not fail that way. It stops backing a mandate when an event occurs: an endorsement, a pledge being constituted, an attestation revoked. Those land inside the compliance window, at an arbitrary block. A grant-time bound cannot model them. The live re-check can, and PRINCIPAL_INELIGIBLE gives the caller something actionable instead of an opaque failure.

One note from civil law

The Motivation calls the mandate analogous to a power of attorney. Under civil-law systems that analogy carries weight. For acts of disposition a determinate object is required (Colombian Civil Code 2158, Commercial Code 1263), which sits awkwardly against a mandate scoped to every identifier a contract holds. We bound our asset to a fixed set in the constructor for that reason. Happy to write it up as a Rationale note if useful.

Scope: synthetic instrument, test networks, provider written by a single key. Nothing here is a claim about a real right or a real person.

Juan E. Saldarriaga
CEO, FACTOS. Registry for the legal state of credit instruments.
1 Like

The deployment seems to answer the gas-cost half of the live re-check question from post 18, but I think it also exposes a subject boundary worth resolving before making that check normative.

As I read the current ERC, there are deliberately separate questions:

  • token/asset compliance: is this principal eligible with respect to this asset?

  • RAMS: is this agent authorized by this principal for this action?

  • IComplianceProvider: is the principal currently eligible?

What @JES-Factos.co is re-checking appears slightly different. Their provider is attesting mutable state of the underlying credit right itself — whether the right still exists, who holds it, whether it is encumbered, whether an attestation has been revoked.

Those facts can absolutely change between grant and execution, and expiresAt cannot represent an event-driven state transition. But they are not necessarily changes to the principal. So before deciding “live checkPrincipal() in canExecute(),” I think the invariant to pin down is:which mutable facts must still be true at execution, and which layer owns each fact?

If the principal becomes sanctioned, that seems naturally like a live principal-eligibility question. If the underlying right has been pledged or transferred, that seems like current asset/right state, even if a particular provider happens to expose it through checkPrincipal(). Those cases have the same temporal shape — valid at grant does not imply valid now — but they may not belong to the same interface.

Would the intended ERC-8226 architecture treat instrument-state check as a conforming IComplianceProvider responsibility, or as an asset-level condition that composes with RAMS separately?

1 Like

Good question, and honestly it’s the one that gave us trouble while we were building.

Here’s the concrete case, because the abstraction hid it from us for a while. Take a promissory note held by a lender. The lender grants an agent a mandate to act on a position backed by that note, valid ninety days. On day forty the note is endorsed to a third party. Nothing about the lender changed. They aren’t sanctioned, their KYC didn’t lapse, they are the same eligible person they were on day one. They just no longer hold the thing the mandate was granted over.

That’s the case we’re modelling, and you’re right that calling it principal eligibility is a stretch if eligibility means a property of the person.

What kept us in checkPrincipal is the second argument. The call is checkPrincipal(principal, identityRef), and we don’t read identityRef as “which identity document is this” but as “with respect to which registered right are you asking”. Under that reading the question stops being “is this person eligible” in the abstract and becomes “is this person still standing where they were standing when the mandate was granted”. Endorse the note away and the answer is no. Register a pledge over it and the answer is qualified, since their power to dispose of it is limited even though they still hold it.

That’s the same shape as a sanction, approached from the other side. A sanction is a fact about the person that stays true whatever the asset. An endorsement is a fact about the person that only means anything with respect to one asset. Both are answers to whether the principal can still do what they said they could do.

Where your line is right, and where we would have got it wrong if we’d pushed further, is everything left over. If the note matures, or is cancelled, or the issuer defaults, none of that has anything to do with any principal. It’s true for whoever holds it. Had we routed those through checkPrincipal as well, and we were close to doing exactly that, we’d have ended up with a provider answering questions about an instrument through an interface whose first argument is a principal. That’s your category error, and it would have surfaced the first time two principals held positions in the same instrument.

So we landed here: the provider answers for the principal’s position, the asset answers for the instrument. Both can change mid-mandate. They’re just not the same kind of fact.

That leaves two things open for the spec, and I’d rather ask than assume.

Is identityRef meant to be read as an object scope? We’re treating it as one and a fair amount of our design rests on that. If it’s only an identity pointer, we’ve built on a misreading and would want to correct it early.

And would it be worth the Rationale saying out loud what doesn’t belong in checkPrincipal? Not as a restriction. Just so the next implementer with instrument state to expose doesn’t take the path we nearly took.

On the mechanism, we’re treating the grant-time gate as settled. The asset does the execution-time check, and we’re building the relay from PrincipalRevoked to the enforcer freeze.

1 Like

That makes sense, and your example clears up what I was trying to separate.

The lender can still be the same valid person while no longer standing in the same relationship to the note. So I agree that checkPrincipal can reasonably cover that relationship if identityRef is meant to carry the context of the specific right the principal is being evaluated against.

Where I still see a clean boundary is with facts that belong to the instrument itself. If the note matures, is cancelled, defaults, or changes independently of whoever holds it, that seems like something the asset should answer rather than checkPrincipal.

So I think the remaining question is mostly for the ERC authors: what is identityRef actually intended to identify?

The current wording reads to me like identity evidence, but your implementation is using it more broadly—as the context in which the principal’s eligibility is being evaluated. If that broader meaning is intended, it would be useful to say that explicitly because it changes what implementations can safely put behind checkPrincipal.

If it is not intended, then the relationship between the principal and the specific right probably needs somewhere explicit to live.

Also think your point gives a good reason for the Rationale to say what does not belong in checkPrincipal, so implementations do not end up pushing instrument state into it just because the interface is available.

1 Like