Blog · 2026-06-19 · Michael Moffett

Authentication is not authorization: the TrustedVolumes access_control class.

On May 7, 2026, TrustedVolumes lost a reported $5.87M (rekt.news) to a bug that reads as obvious after the fact: the contract checked whether a signer was authorized for the wrong principal. The code that validated the signature was doing exactly what it appeared to be doing — looking up a key in a map — and was wrong because the map was indexed on the caller’s address instead of the order’s maker address. That is the load-bearing bug for the access_control class. The full attack chained three more bugs the QuillAudits writeup linked below breaks down (permissionless registration, the contract’s use of the taker field as the from address combined with unlimited ERC-20 approvals on the proxy, and a broken replay guard); this post stays on the access_control leg.

This post is not a postmortem. banteg published a runnable Foundry reproduction (block-pinned to 25039669) that lets you replay the exploit sequence on the actual pre-exploit state. That is the definitive technical artifact for the attack. What this post covers is the other leg: the invariant property the contract should have enforced, what it asserts, and how a stateful test that carries that property would have caught the class before mainnet.

What happened

The contract asked the wrong question.

TrustedVolumes is an off-chain order book with on-chain settlement. Makers sign orders off-chain. Takers bring those signed orders on-chain to execute. The settlement contract verifies the maker’s signature before moving funds.

The signature verification looked up a per-principal allowed-signers map: _allowedSigners[key_1][key_2], where the presence of key_2 under key_1 means “key_2 is an authorized signer for key_1.” The bug was in which address played the role of key_1.

Per SlowMist’s root-cause breakdown, quoted in the rekt.news writeup:

“Signature validation checks _allowedSigners[msg.sender][signer] using caller (taker) instead of order’s maker as key…”

The contract asked “is signer an allowed signer for msg.sender?” where msg.sender is the taker executing the settlement. It should have asked “is signer an allowed signer for the order’s maker?” The question is structurally identical; the lookup subject is wrong.

The second leg made the first leg drainable. registerAllowedOrderSigner was a permissionless entry point — any address could register any key as an allowed signer for itself. So the attack required no privileged access and no prior position:

  1. Call registerAllowedOrderSigner to register the attacker’s own key as an allowed signer for the attacker account.
  2. Sign a fake order with that key, authorizing transfers from a victim maker’s balance.
  3. Call settle with the attacker as msg.sender. The contract looked up _allowedSigners[attacker][attacker_key], found a match, and processed the transfer.

The victim maker never signed anything. The contract asked the wrong question.

The pattern

Authentication used as authorization.

The contract had two distinct functions: a registration function (“tell me which key you control — authentication”) and a settlement function (“this key is allowed to spend this maker’s balance — authorization”). The settlement function keyed its authorization lookup on msg.sender, which is correct for authentication (“am I who I say I am?”) and wrong for authorization (“am I allowed to act on this resource owned by someone else?”). Authentication answers “who are you”; authorization answers “are you allowed to do this thing to this resource.” The contract substituted the first for the second.

This is the pattern the access_control invariant class is built to catch: an unauthorized principal causing a state change reserved for an authorized one. The state change here is a maker’s balance moving without the maker’s genuine authorization.

The property

What the invariant asserts.

The property the settlement contract should have enforced is:

No caller can cause a maker’s balance to move unless the maker’s own key — not the caller’s key — has signed the order.

Expressed as a stateful test property:

The violating sequence is short. A stateful fuzzer that carries an attacker keypair in its call space — one the authorized principal never granted anything to — is designed to find this shape quickly. registerAllowedOrderSigner is available in the callspace; settle is available in the callspace; the attacker keypair is the unauthorized subject. The fuzzer picks both up, sequences them, and the property fires.

banteg’s Foundry repro is exploit-shaped: reconstruct the exact attacker call sequence and replay it against block 25039669. That is a valuable artifact — it confirms the attack surface on the real pre-exploit contract. Our framing is orthogonal. State the property the contract should have enforced. Show a reference where the property holds on the clean variant (zero markers) and fires on a planted twin where the constraint is removed (at least one marker). Wire both legs in CI. The property fires before you get to the replay step.

CI-verified evidence

Map to CI-verified reference pairs.

Three reference artifacts carry an access_control reference pair verified in CI today. All three are CI-verified per the build squad’s proof register. The two incidents are EVM; the reference pairs are Anchor/Solana and Cairo/Starknet — the property is rail-agnostic, and we note the gap on the Solidity rail honestly at the end.

cf-invariants-anchor — Anchor / Solana, access_control class (admin_ref / admin_ref_planted). The clean variant holds a vault with Anchor’s has_one = depositor constraint and PDA seeds derived from depositor.key(). The planted variant drops both constraints — Anchor will then accept any account owned by the program in the vault slot regardless of the signer. The access_control invariant fuzzes with a fresh attacker keypair every round; any successful unauthorized call sets a sticky flag the invariant asserts against. Clean = 0 markers, planted ≥ 1 marker. CI-verified, commit aee4136. Repo: github.com/caliperforge/cf-invariants-anchor.

The shape of what the planted admin_ref drops is structurally equivalent to what TrustedVolumes’s settlement function did not check: the planted version omits the binding between “the account slot accepted” and “the authorized principal.” The invariant then finds the call sequence that violates the property, same as the TrustedVolumes shape. The bug class is the same; the language and runtime are different.

cf-invariants-jito — Anchor / Solana, admin_gating class on the real Jito tip-distribution program. The planted twin drops the UpdateConfig::auth(&ctx)? call from update_config, so any signer (not just the recorded Config.authority) can rewrite the Config’s authority field. This is the same invariant class: an unauthorized principal causing a state change reserved for an authorized one. The invariant invariant_update_config_requires_authority fires on the planted variant and stays silent on the clean. Clean = 0, planted ≥ 1, CI-verified at commit e683c5a. Repo: github.com/caliperforge/cf-invariants-jito.

The Jito reference harnessed a real on-chain program’s access-control gate, not a synthetic example. That is the same framing the TrustedVolumes invariant would need: harness the real settlement contract’s settler → verify the signer-validation logic → plant the wrong-principal lookup → show the invariant fires.

cf-invariants — Cairo / Starknet, 12-class matrix. The 12-class reference suite includes governance and multisig under the access-control / monotonicity pattern. Twelve reference contracts × twelve invariant classes, each with a planted bug, each compile-twinned to a clean variant via a clean scarb feature, each asserting planted ≥ 1 AND clean = 0 in CI. PR #2 squash-merged 2026-06-07 at commit 003e33c, CI run 27075213962 26/26 green. All twelve references deployed and source-verified on Starknet Sepolia. Repo: github.com/caliperforge/cf-invariants. Developer cookbook (public since 2026-06-11, PR #3 squash-merged at 7f94b08): github.com/caliperforge/cf-invariants/tree/main/docs/cookbook.

Design

What a TrustedVolumes-specific invariant harness would look like.

The exercise is design rather than a shipped harness. TrustedVolumes is an EVM contract; our CI-verified access_control reference pairs are Anchor/Solana and Cairo. A TrustedVolumes-specific twin would live on the Solidity/Foundry rail, and a Solidity-rail access_control bundle does not exist in the current toolkit (hyperevm-safety v0.1 ships six HyperCore-boundary classes: oracle staleness, oracle deviation, decimals scaling, gas DoS, solvency window, broken Chainlink adapter — none access_control-shaped).

The invariant design is clear regardless:

  1. Identify the principal whose authorization gates settlement: the order’s maker, not the caller.
  2. Express the property: after any call to settle, if the maker’s balance decreased, the signer must be present in _allowedSigners[maker], not _allowedSigners[msg.sender].
  3. Test setup: one clean maker M, one attacker A. Include registerAllowedOrderSigner and settle in the call space. Include A’s keypair as the unauthorized signer.
  4. Assert after every transition: no decrease in M’s balance without M-keyed authorization.

The violating sequence in step 4 is exactly the attack sequence. A stateful test reaches it without being told the exact sequence; it only needs to know the property and the callspace.

This is “invariant design, not yet harnessed” framing. A Solidity-rail access_control reference pair does not exist today, and we will not stretch v0.1’s bundles to claim shape they do not have. The design above is offered as a reference for teams building Solidity-rail harnesses against this class.

Calibration

What this writeup does not claim.

Sources

Primary post-mortems and CaliperForge artifacts.

CaliperForge artifacts cited:

Operator of record: Michael Moffett — michael@caliperforge.comteam@caliperforge.com. This writeup was drafted with AI assistance; the invariant framing, the same-source reference mapping, and this post were all reviewed by the operator. See caliperforge.com/ai-disclosure for the full disclosure register.