Bounded, revocable, non-custodial spending authority for autonomous agents on Arc.
A payer grants an agent capped, rate-limited, allowlisted, expiring authority to spend the payer's USDC, and the funds stay in the payer's wallet throughout. Every spend carries an idempotency nonce and emits a reconcilable event, and the payer can revoke at any time.
Companies are handing software agents the ability to move money, and each of the controls available to them fails in a different way.
A private key in the agent's possession reaches the entire balance. Whatever limit exists is a line in the agent's own code, which is the software you are trying to constrain in the first place.
An ERC-20 allowance is the reflexive answer, and it is a budget the agent can spend
down to zero rather than a cap. It carries one number, with no rate, no recipient list,
no expiry, and no record of why any particular payment cleared. On Arc it is weaker
again, because USDC is the native asset: the same balance can leave a smart account as
msg.value, and allowance is never consulted on that path. Arc's own wallet
documentation carries this as a warning.
Unified Balance delegation is binary. A delegate either can move the balance or cannot, and there is nothing in it that expresses how much, how fast, or to whom.
Per-job escrow does stop the attack, and it stops it by asking a person to approve every payment. Across two hundred invoices a month that hands back the work the agent was hired to do.
A custodial vendor with a monthly cap enforces a real limit, and you learn what happened from that vendor's dashboard, from records only they can produce and only they can vouch for.
What makes this urgent rather than interesting is the shape of the failure. An agent has to read text from outside your company to do its job, and reading text from outside your company is how it gets talked into a payment. Prompt injection has no fix today, the number of organisations pointing an agent at a payment rail rises every month, and the person who used to hesitate at a familiar vendor with an unfamiliar account number is the person the agent replaced.
A mandate: a spending policy the payer writes once, stored on-chain, that the contract applies to every payment the agent attempts.
The payer calls createMandate, names the agent, and sets whichever of these apply.
| Bound | What it does |
|---|---|
| per-transaction cap | the largest single payment allowed |
| rolling windows | one to four, each with its own length and cap — a day, a week, a month |
| lifetime total | the most this mandate can ever spend |
| recipient allowlist | the only addresses permitted to receive money |
| start time and expiry | when the authority begins, and when it dies |
| co-signature threshold | above this amount, a second party must approve the exact payment before it can go through |
| ERC-8004 identity or credential | the agent must hold a named on-chain identity, or a fresh attestation from a validator the payer names |
At least one of the lifetime total or the expiry is required, so every mandate has a horizon.
The agent then calls spend. The contract applies every rule the mandate sets and either
moves the money or refuses with a named error saying which rule stopped it.
Three properties separate this from the controls above.
The payer keeps custody. Funds never enter this contract. The payer holds their own
USDC and grants an ERC-20 allowance; a spend is transferFrom(payer → recipient)
straight through. The allowance is the outer bound, so a bug in this contract can never
cost more than it, and approve(usdc, remit, 0) is a hard stop the payer applies alone,
enforced by USDC rather than by anything here.
The record is public and reads the same for everyone. Every spend emits an event carrying the amount, the recipient, a reference and an idempotency nonce, and every refusal is a named error rather than a generic failure. The payer, the agent, the recipient and an auditor each read the same history without having to trust one another.
Revocation settles in one confirmation. The payer or the agent may call revoke, and
Arc has deterministic finality, so once the revocation is included it cannot be undone. A
spend already sitting in the mempool can still be included ahead of it, which makes
revocation immediate on inclusion rather than on submission.
A payer who does not know these will over-trust the primitive. THREAT-MODEL.md §2 states all seven in full, with the reasoning.
Remit bounds what the agent can do through this contract. It cannot bound what the payer's own account does. If the payer is a smart account, any module with execution rights on it can move the same USDC without consulting Remit at all.
A mandate names an address, so whoever holds that key is the agent. Without an allowlist, the agent can pay itself, up to the caps.
Circle can freeze either side of a payment, and both ERC-8004 registry addresses are fixed at deployment and can never be changed.
Everything is public: every cap, every recipient, every payment.
This has not been audited. scope.md states what an auditor would read.
The protocol is Remit; the object it issues is a mandate. That
split is deliberate. "Mandate" is the existing term of art for standing, bounded
authority to take money from someone else's account — SEPA direct debit runs on
mandates — so the on-chain type keeps a name that a payments person already
recognizes, and the contract stays MandateManager. "Remit" is the outward-facing
name because within your remit is precisely what the primitive expresses: authority
that is real, and also has an edge. One known sharp corner, recorded rather than
hidden: to remit also means to send payment, which is the one thing this protocol
does not do — it never holds or moves money on its own behalf, it bounds someone
else's ability to move yours.
New to this? Read START-HERE.md — it assumes no development experience, explains what state each piece of the project is in, and gives the exact commands for getting from here to a working testnet deployment.
Otherwise read DESIGN.md first. It argues the four alternatives above at length, works a full attack through a configured mandate step by step, and records the decisions behind each field.
Two documents are about the limits rather than the capabilities, and they are the ones to read before trusting anything here with money. THREAT-MODEL.md is the adversarial review — what Remit protects, what it does not, and twenty-eight findings against the current surface. IMMUTABILITY.md answers the question a payer should ask second: the deployed contract has no admin, no pause and no upgrade path, so what happens when something is wrong with it? It includes the migration runbook and the two kill switches, one of which lives in USDC rather than in this codebase.
reference/policy.js the normative spec — executable model of every decision
reference/policy.test.js 116 tests, including boundary-aiming fuzzers
reference/ also holds the seven checkers, and the house style they enforce
contracts/MandateManager.sol on-chain implementation; v1 is live on Arc Testnet
test/ 323 Forge tests in 13 files, against the real storage layout
test/ArcParity.t.sol the matched local control for the real testnet transactions
test/Deploy.t.sol the deploy script's own checks, exercised against mocks
script/Deploy.s.sol deploys with the constructor arguments pinned per chain
demo/playground.html browser simulation with 7 scripted attacks
evidence/ the testnet logs every gas and receipt figure is quoted from
scope.md what an auditor is being asked to read, and what they are not
DESIGN.md rationale, worked examples, verification worksheet
THREAT-MODEL.md the adversarial review: assets, boundaries, 51 findings
IMMUTABILITY.md what "no upgrade path" means for a payer, and the migration runbook
PRIVACY.md what a mandate leaks, and the layers that could stop it
CONFIDENTIAL-USDC.md the unanswered question underneath them, and who owns it
L3-VAULT.md the shielded-vault spec, kept for its contract findings
GAS-ABSTRACTION.md who can pay for a spend, across 3009, 4337 and 7702
CHANGELIST.md every signature and behaviour change since v1 shipped
FORGE.md how to run the Forge suite, and what to expect
START-HERE.md the on-ramp, if this is your first project
The tree here is v2 in progress. The contract deployed on Arc Testnet is the tag
v1.0.0-arc-testnet, and main no longer reproduces its bytecode.
Node 18+, no dependencies, no network.
node --test reference/policy.test.js
Expected: # tests 116 / # pass 116 / # fail 0.
The tests are the actual correctness evidence for this project. They include named
attack cases (tumbling-window boundary burst, backwards clock, co-signature
redirect, identity-NFT transfer, wrong-validator attestation, wrong-agent
attestation) and property-based fuzzers that check accepted spends against a
brute-force exact ledger across K ∈ {2,3,4,6,12,24} × 25 seeds × 200 steps.
A second suite now exists in Solidity — 323 Forge tests in test/ — covering the same
ground plus the three properties a JavaScript model structurally cannot express: a failed
spend consumes nothing (real transaction rollback), rewinding onto the same physical ring
slot accumulates rather than overwrites (real storage aliasing), and totalSpent panics
rather than wrapping near 2^96 (real packed arithmetic). All 323 pass, under solc 0.8.28
— 2,048 fuzz runs and 49,152 invariant calls. forge coverage puts the contract at
100% of lines (334/334), statements (598/598), branches (152/152) and functions
(30/30), measured on the v2 tree at 320 tests rather than carried from an earlier one, so
three tests behind the current suite and reproducible only at that count.
Repo-wide the four columns read 99.85%, 98.61%, 92.92% and 100.00%. One of the functions
behind that last column is the USDC mock's plain transfer, which had no caller anywhere
in the tree until the invariant handler's donate move called it. The deploy script was
the one file with no coverage at all until test/Deploy.t.sol was written; the branches
still unreached inside it are the eight comparisons that hold for every input while the
source is correct, which the same file explains one by one.
FORGE.md covers setup, the two commands that check the suite is not
passing vacuously, and what the first build and the first run actually found.
open demo/playground.html # macOS
xdg-open demo/playground.html # Linux
start demo\playground.html # Windows
Pure client-side, no build step, no network. Grant a mandate, then run the scripted attacks and watch the ledger. Every request is also evaluated against a naive calendar-day cap so you can see what that implementation leaks — it is exactly 2× in every configuration tested.
MandateManager is live at 0x3744E93B9e796E05CB66311d897559B6F3860196,
source verified, deployed 2026-08-24 in block 58558548 by tx
0x5cb3fd0b…24900849f.
A mandate has been granted and a real spend executed against it — see
Live on Arc below for the four transaction hashes and the reconciliation.
Chain ID 5042002, explorer https://testnet.arcscan.app, faucet https://faucet.circle.com
(select Arc Testnet). USDC is the gas token, so an unfunded account cannot transact at
all — fund before anything else. That applies to the delegate too, which is easy to
forget: an agent with a valid mandate and a zero balance cannot spend, because it cannot
pay for the transaction that spends.
To reproduce from scratch:
Arc's own tutorial uses cast wallet new and a PRIVATE_KEY in .env. This uses the
encrypted keystore instead: the key is never printed and never written in plaintext.
forge build # clean; forge-std 1.16.2 vendored in lib/
forge test # 323 tests, all passing
cast wallet new ~/.foundry/keystores remit-testnet # prompts for a password;
# prints the address, never the key
forge create contracts/MandateManager.sol:MandateManager \
--rpc-url https://rpc.testnet.arc.io \
--account remit-testnet \
--broadcast \
--constructor-args \
0x3600000000000000000000000000000000000000 \
0x8004A818BFB912233c491871b3d84c89A494BD9e \
0x8004Cb1BF31DAf7788923b405b754f57acEB4272
--account remit-testnet names a keystore in Foundry's default directory; the
--keystore <path> form works identically if the file lives elsewhere.
The positional PATH is not optional in spirit. cast wallet new with no path prints the
private key to the terminal; given a path it writes an encrypted keystore and prints only
the address. Verified against cast wallet new --help on Foundry 1.7.1 — an earlier draft
of this file invented a --keystore-dir flag that does not exist.
--broadcast is required. Without it forge create simulates and exits, printing what
looks like success while deploying nothing.
Then publish the source, so the explorer shows an ABI and a read/write UI rather than bytecode. Arc Testnet Explorer runs Blockscout, and the compiler settings must match the ones used to deploy:
cast abi-encode "constructor(address,address,address)" \
0x3600000000000000000000000000000000000000 \
0x8004A818BFB912233c491871b3d84c89A494BD9e \
0x8004Cb1BF31DAf7788923b405b754f57acEB4272
forge verify-contract <deployed-address> \
contracts/MandateManager.sol:MandateManager \
--chain-id 5042002 \
--verifier blockscout \
--verifier-url https://testnet.arcscan.app/api/ \
--constructor-args <output of the command above>
A payer then calls USDC.approve(mandateManager, budget) — approve the intended
budget, not type(uint256).max, so the allowance is a hard outer ceiling — followed
by createMandate(salt, params), and then the delegate can spend. That ceiling
has two purposes: it caps what a bug in this contract could cost, and since USDC
is also Arc's gas token, it keeps the rest of the balance — the part that pays
for revoke — outside the delegate's reach for as long as the mandate lasts.
THREAT-MODEL.md F42 has the arithmetic.
Verified. The reference model runs and passes 99 tests. It found six real
cap-bypass bugs during development: four in the window algorithm (K-bucket
undercount, backwards-clock refill, commit overwriting live history, sentinel
collision at low timestamps) and two in the credential check (unchecked validator,
unchecked agent id), and each of the six is now a named regression test. The K/(K+1)
throughput cost was measured, not assumed: ~92% of nominal at K=12, ~96% at K=24, both
within 0.5% of prediction. The demo's engine was extracted from the HTML and exercised
headlessly to confirm it behaves as the page claims.
Verified against primary source. All nine Arc-specific claims the design rests
on were checked in the Arc docs; the worksheet at the end of DESIGN.md names the page
for each. One of them changed the design: the reason an allowance is not a spending
cap is that USDC is the native asset and value can leave via msg.value, which means
a mandate only enforces anything when this contract is the spender's sole path to the
payer's funds. That limitation is now stated in the contract header and in DESIGN.md
rather than left implicit.
Verified on a live chain, as of 2026-08-24. The contract is deployed to Arc Testnet
with verified source, and a delegate has spent a payer's USDC under policy. Four
transactions, all successful, all at an effectiveGasPrice of 21 Gwei:
| tx | gas | |
|---|---|---|
fund the agent (bare transfer, 1.00) |
0x122eb209…166250e4 |
73,950 |
approve 2.00 |
0x6fc4a422…31b41754 |
55,438 |
createMandate |
0xe286718d…57376b73 |
152,243 |
spend 0.10 |
0x52e87867…f8930919 |
216,458 |
Four things were learned that no amount of local testing could have produced.
Non-custody is now observable rather than argued. Both Transfer logs on the spend
carry from = the payer. MandateManager appears in that transaction only as the emitter
of Spend. Every prior statement that funds never enter the contract was an inference
from source; this is a block explorer showing money going straight from payer to vendor
with the policy engine as a bystander.
Every USDC movement emits two Transfer logs. One from Arc's EIP-7708 native system
emitter at 0xfffffffffffffffffffffffffffffffffffffffe in 18 decimals, one from the
ERC-20 interface at 0x3600…0000 in 6 decimals — the same payment, both decimal views,
one transaction. approve emits only one log, so the doubling is specific to value
movement. Any indexer that reconciles payments by counting Transfer logs will
double-count every spend. Remit's own Spend event is the deduplicated authoritative
record; that was a design preference and is now a documented requirement.
Two identifiers are predictable off-chain, and both were checked against the chain
rather than assumed. The mandate id computed from (domain, chainid, contract, payer, salt) matched the emitted MandateCreated topic exactly, and the spendHash returned by
a dry run matched the one in the emitted Spend event exactly. So a payer can reference a
mandate, and a cosigner can approve a specific spend, before either is mined.
Six policy refusals were re-checked against the deployed bytecode, not the mock, by
dry-running each rejection with cast call, which costs nothing. Replaying a used nonce
returns NonceAlreadyUsed(), a non-allowlisted recipient RecipientNotAllowed(), an
over-cap amount OverPerTxCap(), the payer signing in the delegate's place
WrongSpender(), a zero amount ZeroAmount(), an unknown id UnknownMandate(). A
legitimate spend with a fresh nonce still simulates clean afterwards, so the contract
refuses the duplicate rather than the mandate seizing up.
Not verified. It has not been audited. Five mandate shapes have now been exercised
live — one with no requirement beyond its caps, one cosigned, one with an identity
requirement, and two with a credential requirement, one of them with a staleness bound and
one without — and both ERC-8004 checks fired against Arc's real registries, with a passing
control beside them that carried neither requirement. revoke was exercised on 2026-08-25,
once by a payer and once by a delegate revoking its own authority, and withdrawCosign the
same day, so all five state-changing functions now have live transactions across
thirty-one of them, every one with status 1. Both of those numbers were wrong in an
earlier version of this paragraph, which said revoke was the only untried path and then
said five of six functions rather than five of five; the surface is five, obtained by
parsing every declaration in the contract for external or public without view. What
stays untestable rather than merely untried is the identity check's positive path, until
an identity NFT is minted to our delegate. Sub-second blocks sharing a timestamp and the
CallFrom precompile remain asserted from documentation rather than observed.
Measured against Arc's real USDC, as of 2026-08-24. The steady-state marginal spend,
measured inside an already-written window bucket, costs 177,429 gas ≈ 0.0037 USDC, and
a bare transfer to a fresh account on the same chain costs 73,950. The entire apparatus
— per-transaction cap, lifetime cap, 24-bucket rolling daily window, expiry, allowlist,
idempotency nonce, audit event — therefore costs about 103,479 gas, roughly 0.217 cents
per payment, and a policed payment is about 2.4× a bare one. A first-ever spend on
a fresh mandate, with every slot cold and every counter virgin, costs 216,458; quoting that
against a bare transfer produced the ~142,500 figure this paragraph used to carry, charging
one-time initialisation to the recurring cost. One caveat survives: transferFrom touches
the allowance slot and transfer does not, worth ~5,000 gas, so read the floor as ~98,000.
Arc's native USDC is more expensive than a plain ERC-20, and the premium is very nearly a
per-call constant. Both halves of that sentence used to read differently, and both were
wrong. The premium is now measured directly, with no test harness in the comparison:
test/mocks/MockUSDC was deployed to Arc, and the same operation run against both tokens
from the same wallet with byte-identical calldata, so intrinsic gas cancels exactly and the
whole receipt difference is Arc's own accounting.
| mock | Arc USDC | premium | |
|---|---|---|---|
approve, zero slot |
46,138 | 55,438 | 9,300 |
transferFrom, three overwrites |
46,688 | 59,798 | 13,110 |
approve, live slot |
29,038 | 38,338 | 9,300 |
approve costs the same 9,300 whether it writes a virgin slot or overwrites a live one. A
transferFrom touching three slots costs 13,110, not the ~27,900 a per-slot model predicts —
1,756 of the difference is Arc's second Transfer log, emitted by the native system emitter
in 18-decimal wei alongside the 6-decimal ERC-20 one.
The superseded figures were 17,100 and 32,700, too high by 1.8× and 2.5×. They came from
using createMandate's harness deviation as a flat additive constant, which
evidence/cosign-parity.log had already shown it is not. Worse, 17,100 is not a premium at
all: it is the EIP-2200 zero-versus-non-zero storage gap, which shows up identically on both
tokens — 46,138 − 29,038 and 55,438 − 38,338 both equal exactly 17,100 — and therefore
cancels. The old route reached 10,757 + 6,337 = 17,094 and rounded, landing six gas from a
constant that has nothing to do with Arc, which is what made a broken number look
corroborated. DESIGN.md has the full derivation and the itemisation that closes the mock's
baseline to the gas.
The intrinsic-gas half of those predictions came out exact rather than approximate — 22,304
predicted and 22,304 charged for the spend's 164 bytes of calldata, and 24,828 against the
test's 24,816 for createMandate, a 12-gas gap caused by a single byte of the spender
address. Deployment reconciled to 0.009%: 2,557,681 predicted against 2,557,453 charged.
Two numbers here were wrong in earlier drafts of this file and are worth naming. The
deployed v1 contract is 11,572 bytes of runtime code, against the 11,964 this file
published for weeks — 11,572 is confirmed twice, by forge build --sizes at the v1 tag
and by the contract's own on-chain code length, whereas 11,964 came from a single
unverified source and matches neither the runtime nor the initcode (11,868). The v2 tree
on main measures 17,888 runtime and 18,370 initcode, so an unqualified 11,572 anywhere
in this repository means v1. And Arc's 20 Gwei base fee is a floor, not a price: every
transaction so far settled at 21 Gwei, so every cost figure computed at the floor ran ~5%
low. Deployment cost 0.0537 USDC, not the 0.051 published.
K=24 is affordable — the open question is closed. Each extra bucket adds one cold
SLOAD, ~2,150 gas or 0.000045 USDC at 21 Gwei. Widening a window from K=12 to K=24
costs about 25,800 gas, 0.00054 USDC — a twentieth of a cent — and buys a rise in the
K/(K+1) throughput floor from 92% to 96% of nominal. K=24 should be the default
wherever the rate limit is one of the bounds a payer relies on. The caveat that used to sit
here — that this was measured against a mock and Arc's real token would cost more by an
unknown margin — is now answered: the margin is 13,110 gas on the transferFrom, and it
is a per-call constant, not a per-bucket cost, so it does not change the K decision at all.
Writing the tests did surface real work: four places where the contract was right and
the reference model was wrong. The co-signature threshold is strictly greater, so an
at-threshold spend needs no signature. The staleness guard treats maxStaleness == 0 as
no requirement at all rather than maximum strictness. An amount above 2^96-1 is
refused outright, before any cap is consulted, because every cap is a packed uint96
and an unchecked downcast would truncate a large amount into a small one that passes. The
spender may revoke as well as the payer: an agent that has finished its work or detects it
is compromised can surrender its own authority, which cannot hurt the payer because the
only power it removes is the agent's own.
Writing the documentation then surfaced a fifth, which is the more interesting one.
Explaining the credential check's agent binding carefully enough to caveat it exposed
that the model resolved the expected agent with ??, so agentId: 0n meant "require
the attestation to be about agent 0" — a state the on-chain uint256 cannot express,
where zero is the only available spelling of "unset". The divergence was invisible to
every existing test and sat on a security path.
Generalising that — auditing every field whose zero doubles as "unset" — found five
more, all in the same direction, with the model's createMandate accepting configurations
the contract refuses: a window with cap == 0; an expiresAt at or before notBefore;
the zero address on an allowlist; a credential with no validator, which the on-chain flag
rules cannot represent; and minResponse == 0, which is the one that matters. ERC-8004
encodes a failed validation as a low response and 100 as passing, so a zero threshold
inverts the credential check into one that accepts precisely the attestations it exists to
reject. The contract refuses all five, and Creation.t.sol already asserted all five.
The model had none of them, so a first forge test would have caught the lot — this
reconciliation just got there without a compiler, by reading.
One more fix came out of it that is about the model's job rather than its logic. The
credential's defaults were applied at read time, so a constructed mandate carried
undefined in fields that become uints, and a client encoder turning an omitted
minResponse into 0 would emit exactly the value createMandate refuses. The
credential is now materialised at construction in its on-chain spelling, so the object
the model returns encodes directly into the contract's struct with no second layer of
defaults to disagree with. A specification that needs a translator has moved the bug
rather than fixed it.
The model now matches on all ten, which took its suite from 41 tests to 46 — and to 99 over
the course of v2, which added the counter-ceiling denial, three co-signature refusals, six
joint-ceiling tests, the lifetime-bound narrowing, and every later fix's own regression.
One Forge test was also rewritten for proving nothing — it tripped two BadWindow
conditions at once, so it would have passed with either check removed.
The first real run was informative in the way that matters: seven things had to be fixed
and all seven were in the tests, not the contract. Three were compile failures — a
malformed foundry.toml key, reference used as an identifier when it is a reserved
word, and a fuzz helper whose frame exceeded the EVM's 16-slot stack reach. Four were
failing assertions: two tests let a nested spendHash call consume the vm.prank
intended for approveCosign, one had arithmetic in its comment that did not match its
own threshold, and one asserted via vm.recordLogs that a reverted frame leaves no
event behind — which is true of the chain and not true of the recorder, so the assertion
was unanswerable rather than merely wrong. It was replaced with the observable form of
the same claim.
v1's three known soft spots, and what v2 has done with them. None was something the
compiler or a green suite would flag, because they are behaviours rather than errors —
two of the three were in fact pinned by passing tests, which is the point. All three
remain permanent properties of the deployed v1 at
0x3744E93B9e796E05CB66311d897559B6F3860196, which has no upgrade path. The fixes below
are in this tree, for a contract that has not been deployed. All three are now fixed, and
the third one turned up two further holes that no list anywhere had recorded — so read
"three" as the number that had been written down, not the number that existed.
Fixed. m.totalSpent + uint96(amount) was computed before the F_TOTAL check, so the
addition ran even for a mandate with no lifetime cap, and a cumulative total near 2^96
base units — roughly 7.9e22 USDC — produced an arithmetic panic rather than a
graceful stop. v2 consults the lifetime cap without performing the addition, then guards
the counter with a named TotalSpentCeiling(). The mandate still stops at the ceiling,
which is inherent to a counter that is bounded and must stay exact because the audit
trail carries it; what changed is that it now says why.
Fixed. revoke reverted with NotPayer() even though the spender is also permitted to
call it. Four places in this repo recorded that the name was misleading and kept it
anyway, on the grounds that it was already in a deployed ABI. Tagging
v1.0.0-arc-testnet retired that reason — v1's ABI is pinned at v1's address — so v2
calls it NotAuthorised(), moving the selector from 0x1435e357 to 0x1648fd01.
Fixed, and it grew. A mandate whose co-signature threshold sits at or above the largest
single spend its other bounds permit makes the co-signature branch unreachable, producing
a policy that looks supervised and never asks for a signature; v1's createMandate
accepts it. Two details this paragraph previously got wrong: the condition is <= rather
than <, because spend tests amount > cosignThreshold strictly, so a threshold exactly
equal to the ceiling is dead too — and perTxCap is not the only ceiling, since with
F_PER_TX unset the effective one is the smaller of the lifetime cap and the window caps.
v2 refuses the grant, comparing the threshold against min(2^96 - 1, perTxCap, totalCap, every window cap), which still accepts a mandate that bounds no amount at all — an
expiry-only mandate is legal in v2 and the 2^96 - 1 term is what keeps the guard
meaningful there, since AmountTooLarge is then the only ceiling on a single spend.
Fixing it properly meant asking the general question — in how many ways can a mandate
display a co-signature requirement and not have one? — and the answer was five, not one.
Two were already refused in v1, this was the third, and two more had never been written
down anywhere. A threshold with F_COSIGN unset is stored and shown by getMandate and
measured against nothing. Worse still, approveCosign authorises on msg.sender == m.cosigner alone, so a mandate whose cosigner is its own spender lets the agent
approve its own spend hash and then spend it: the supervision requirement becomes two
transactions and no second party. Neither is a divergence between the contract and
reference/policy.js — the model accepted both too, so no amount of cross-checking the
two implementations would have surfaced them. v2 refuses all three, while
cosigner == payer remains legal and is the ordinary case: it is what live mandate 2 does
on Arc today, and a rule that condemned it would have contradicted a receipt. (v2's
approveCosignFor authorises the same way, which is the point: the fix belongs at grant
time, because no check inside an approval can tell a cosigner who is legitimately also the
payer from one who is illegitimately also the spender.)
Two more were on no list at all, found by writing the threat model. Neither
belongs to the three above; both came out of a sweep asking which fields a mandate can
display without anything measuring against them, and both are grant-time refusals in v2.
The first is the bigger change. v1 called a mandate "bounded" if it carried a
per-transaction cap or a lifetime cap or an expiry or any window — but a
per-transaction cap bounds one spend and permits unlimited spends, and a window bounds a
rate and permits unlimited cumulative spending given enough time. So perTxCap = 100
and nothing else is a standing instruction to spend 100 USDC forever, and v1 accepts it
while its own comment beside the check claims otherwise. v2 requires a lifetime cap or an
expiry specifically, and refuses everything else with Unbounded(). The second: with
F_EXPIRY unset, spend never reads expiresAt, so a payer could set a deadline, see it
returned by getMandate, and have it enforce nothing — v2 refuses that pairing at grant
time, which closes the last field in the struct that could be shown and unread.
The cost of the first one is real: it invalidates worked examples in this repository's own
documentation, including the flagship one in DESIGN.md, which bounds a rate and a blast
radius but never a lifetime. Those are being corrected rather than grandfathered.
Every new grant-time refusal re-audits every configuration the project has ever printed, and
that sweep is the expensive part, not the two lines of Solidity.
Not audited. No third party has looked at this. Do not put money behind it.
This is a blocker rather than a disclaimer: Remit is intended to hold real money, not to
be a testnet demonstration. That intent is what makes the audit a scheduled line item and
what makes FOUNDRY_PROFILE=deep forge test (20,000 fuzz runs, 2,000 invariant runs at
depth 256) the bar to clear before it, rather than the default profile used during development.
It is also what reopened the three soft spots listed above as decisions rather than
curiosities, and what settled each of them the strict way — the dead co-signature branch
in particular, which had been left legal on the reasoning that a merely useless
configuration does not deserve a validation rule. Real money turns "useless" into
"advertises a control it does not have", and immutability means a combination left legal is
legal forever at that address.
Unresolved factual questions. Whether Circle's agent-wallet-policy already
implements equivalent caps off-chain in its custodial API is unverified, so the claim to
make is "non-custodial, on-chain, independently verifiable", not "first". A second question
is whether an EIP-7702-delegated EOA still counts as an EOA for the Memo path, which
matters for smart-account agents and, given the real-money intent, needs an answer before
launch. The gas question that used to sit here is answered above.
The Forge port is written and runs — 323 tests, including exact-ledger property tests and
a stateful invariant that lets the fuzzer choose the call sequence, and all of them pass.
Gas is measured against Arc's real USDC, K=24 is settled as affordable, and the contract
is deployed and exercised on testnet.
Raising optimizer_runs was the obvious next move and it has been tried and rejected. The
reasoning was that 200 is low, deployment costs five cents once, and spend runs forever —
so 200 must be optimising the wrong end. Rebuilding the whole tree at 10,000 bought six
gas on a spend, 0.02%, for 27% more bytecode. A spend's cost is cold SLOADs and an
external transferFrom, both priced by the EVM rather than by codegen, so there was never
anything there for the optimizer to win. The live measurements neither strengthen nor weaken
this much: 13,110 of a real spend is Arc's own token accounting, which no compiler setting
can touch — 6% of a 216,458-gas spend, not the 15% this line used to claim at ~32,700. 200
stays on the strength of the six-gas measurement alone. DESIGN.md has the table and the seed
caveat that nearly made this look like a 40% improvement.
What remains, in order:
Four items that used to head this list are done and are struck from it rather than deleted,
since the order they came off in is itself a record. FOUNDRY_PROFILE=deep forge test — 20,000
fuzz runs, 2,000 invariant runs at depth 256 — has been run as the bar to clear before the
audit. The three soft spots have been resolved as decisions, all three the strict way, and
the third one uncovered two more. The live exercises are closed: a cosigned spend and a
revocation both have receipts on Arc Testnet, along with approveCosign and
withdrawCosign, which is all five state-changing functions that v1 exposed — v2
renamed one of them, so see the note below. The identity and credential checks are the
exception and are still unexercised on chain, blocked on something no amount of care in
this repository can supply: an ERC-8004 identity minted to our agent wallet, and an
attestation that passes rather than the one real attestation on Arc Testnet, whose response
is a failing 1.
One of those five no longer exists. #28 deleted approveCosign(bytes32,bytes32) in favour of
approveCosignFor(mandateId, recipient, amount, ref, nonce, validUntil), so the sentence above
is a closed statement about v1 and not a claim that every path in the current source has a
receipt. Four of v2's five do, for the same signature; the cosign approval path has none, and
cannot until v2 deploys.
So: finish v2 — the merkle allowlist is the last change, the joint-ceiling view having landed
as spendableAcross, which exposes the shared-allowance overlap the live 2026-08-24 run found
and cost three new refusals and a 2^96 − 1 clamp on top of the sum the changelist described.
Then re-measure
the whole baseline against v2's bytecode, since the published gas figures describe v1's.
Then answer whether an EIP-7702-delegated EOA counts as an EOA for the Memo path. Then a
viem client. Then the audit, which is mandatory rather than optional, because this is meant
to hold real money.
The model has already been reconciled against every place the contract was right and it was not — ten of them — so the two suites now agree on every question both of them ask.