Skip to content

Latest commit

 

History

86 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Remit

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.

The problem

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.

The solution

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.

The limits

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.

On naming

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.

Where to start

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.

Layout

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.

Run the tests

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 the demo

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.

Deployed on Arc Testnet

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.

Status

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.

Next

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.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages