DEV Community

Paul Spread
Paul Spread

Posted on Originally published at agentbadge.xyz

37 Ways to Break Our Contracts, and Why None of Them Work Anymore

This post is part of the Arc Campaign series (C28) on agentbadge.xyz.

On October 10, 2026 we pointed an AI auditor at the AgentBadge contract pack and told it to be mean. It found 37 findings across identity and escrow — seven of them High, including a fully transferable "soulbound" passport and an escrow operator who could rotate themselves into the payout seat. On October 11, all eight contracts went live on Arc mainnet as hardened UUPS proxies with zero open findings.

How do you audit contracts without a security firm?

We used Arc Studio — Circle's server-side AI builder — as an adversarial auditor. Not a linter, not a checklist: a max-preset adversarial prompt that required a runnable forge PoC for every finding before it counted as a finding at all.

The prompt discipline mattered more than the model. Three rules did the heavy lifting: hunt specific bug classes (access-control bypasses, role confusion between cross-referencing contracts, upgrade/rotation gaps), produce an executable proof-of-concept test for each claim, and don't touch the source — audit only, recommended fixes as diff sketches. Two packs, two reports: identity/passport contracts (VULN-01..21) and escrow/marketplace (VULN-1..16).

The auditor-earned-its-fee numbers:

  • Identity pack: 21 findings — 7 High, 7 Medium, 6 Low, 1 informational
  • Escrow pack: 16 findings — including 3 Critical-class (operator rotation, poster self-release, global platform fee)
  • Every finding shipped with a forge PoC that compiled and demonstrated the behavior — no "might be exploitable" hand-waving

What were the worst things it found?

The kind of bugs that don't show up in unit tests because the tests were written by the same happy-path brain that wrote the code.

  • Transferable identity (High) — AgentPassportNFT had no _update override, so passports — our ERC-8004 identity documents — were fully transferable. An issued identity could be sold or stolen; holder ≠ verified identity. Fixed with the soulbound _update guard.
  • Operator rotation trap (Critical) — the escrow operator could be swapped in one call. Two-step proposeOperator/acceptOperator plus a zero-address revert closed it.
  • Poster self-release (Critical) — the task poster could call release directly. Now release is 2-of-2: poster approves, operator executes.
  • Zero-address log DoS (High) — constructing with log_ == address(0) silently bricked every mint, revoke and attest call forever. initialize now reverts on zero.
  • Revoked-slot lockout (High) — revoking a pass poisoned its passOf slot; a subsequent mint took the renewal branch on a dead token. Irrecoverable — until we made the renewal branch check !revoked[id].

Three findings were rejected with disproof PoCs — the auditor flagged "NFT owner race" and "pay() shrinks release window," and the same PoC discipline showed the invariants hold. A good audit closes bugs; a great one also kills false alarms.

What does "hardened" actually mean on-chain?

Same eight contracts, rewritten underneath. Every contract is now a UUPS upgradeable proxy (ERC1967Proxy subclass AgentBadgeProxy), with _authorizeUpgrade gated behind UPGRADER_ROLE, _disableInitializers() in every constructor, and 50-slot storage gaps for future layout.

The behavioral fixes on top:

  • REVOKER_ROLE — server operator can revoke a compromised pass without touching the admin key
  • redirectClaimer — a claimer key rotates itself without an operator roundtrip
  • platformBps timelock — fee changes are propose → 48h → commit; no instant fee rug
  • xHolds — escrow holds keyed by a separate uint256 space; an NFT tokenId can never collide with a taskId
  • MIN_DEADLINE_OFFSET = 1h — deposits must leave operators time to work
  • CLASS_FULL_COVERED whitelist — a FULL pass covers MEDIUM+HEAVY, not "every future class bit" (the audit caught CLASS_EAAS leaking through)
  • _emitLog in try/catch — a dead event log can't DoS escrow settlement

The full list is 33 fixes across Critical/High/Medium/Low — every one with a regression test. Final suite: 197 tests green.

What's it like auditing with Arc Studio?

Practically useful, with sharp edges worth documenting.

What worked. One well-specified prompt per run beat iterative chatting — the agent wrote PoCs, ran forge tests, and assembled the report itself inside the sandbox. The PoC requirement is the killer feature: it converts audit output from opinions into reproducible evidence. Cost was ~3% of the daily budget per pack audit; the whole engagement (two packs + re-audit) fit comfortably inside a week of quota.

What to watch. Arc Studio is testnet-only — all mainnet deploys stayed on our hardhat/chain-cli path. Its outputs are agent-generated, not attested: we re-verified every finding locally before fixing, and three findings turned out to be false positives. And the audit prompts are worth keeping under version control — ours live in scripts/arc-studio/prompts/ (audit-identity.txt, audit-escrow.txt), so the next run is reproducible.

Net: an AI auditor doesn't replace a security firm for mainnet money. But for "find the bugs your own tests were blind to," it earned its tokens.

How did the deployment go?

Two chains, one idempotent script, ~0.9 USDC total gas.

deploy-arc-v2.ts deploys an implementation, wraps it in AgentBadgeProxy, calls initialize with the right params (USDC, treasury, operator, platformBps), and records every address in deployments/{network}-v2.json. Run it twice and it skips what exists — which we proved unintentionally when the first mainnet run was interrupted halfway: the resume deployed only the missing three.

  • Arc testnet (5042002): 8/8 proxies, full write-path smoke PASS (registry → badge → passport → passes → escrow deposit/approve/release)
  • Arc mainnet (5042): 8/8 proxies, read-only sanity PASS — writer → passport, escrow usdc/operator/log, splitter bps=1000/treasury
Contract Mainnet v2 proxy
AgentEventLog 0x20387070057178e9629dd6c9037c52487f090a90
TrustRegistry 0x0b56d876fa0b11742e8139b0291fa5080912a222
TrustBadge 0xebcb3adb5f7def429e9c3254bcbe6cd46aef2821
AgentPassportNFT 0x1f2426b102f57ecfeac2ab75c3fd853493f00a76
AccessPassNFT 0x8e254abfd8e4691b11f8553fb9a9f180a248982f
MarketplacePassNFT 0x6e408672e56001dc24a5db68107f9abf900f87e5
TaskEscrow 0xc58dd0890a8a186b651cd02f34af629c8cc91d53
MarketplaceSplitter 0x69043c847e9ee79b7128ec6d280f5f25fc76aba9

We checked the v1 contracts first (decision D-196-5): two marketplace passports and nothing else, all owned by our deployer — no external holders — so v2 is a clean restart, not a state migration. The v1 addresses stay frozen as read-only history.

Who can upgrade these contracts?

Today, the deployer EOA — with a documented exit. UPGRADE-GOVERNANCE.md lays out the migration: when the first real USDC lands in TaskEscrow, UPGRADER_ROLE moves to an OZ TimelockController (≥24h delay) whose proposer/executor is a multisig. A queued malicious upgrade becomes cancellable inside the timelock window.

The reasoning is simple: an upgrader key is god — one compromised key swaps in a hostile implementation and repoints every reference. Day-to-day roles (MINTER, RECORDER, REVOKER, OPERATOR) deliberately carry no upgrade power.

Don't certify. Measure.


Source and deploy ledgers live in the agentbadge repo. Series index: agentbadge.xyz/blog.

Top comments (1)

Collapse
 
bloqarl profile image
Carlos (Bloqarl) •

The rule that every finding needs a runnable forge PoC is the best part of this. In audits a finding without a PoC is more of a lead than a finding, and killing 3 false alarms with disproof PoCs is exactly what you want.

The flip side I'd watch: some of the worst bugs are hard to PoC in one test, like economic or multi-step design issues, so a PoC-only filter can quietly drop them.

Also curious about the re-audit. Moving all 8 contracts to UUPS plus 33 fixes and new features (timelock, roles, try/catch) is a lot of new code. Did the re-audit run on the exact commit you deployed? And until UPGRADER_ROLE moves to the timelock, is there a cap on what TaskEscrow can hold?