On March 2, 2026, an attacker drained ~$58,000 from the ACPRoute protocol on Base by calling claimBudget() after escrow funds had already been released. The root cause? A struct loaded as memory instead of storage — the most elementary Solidity footgun, hiding inside a protocol designed for the cutting-edge world of AI agent commerce.
This article dissects the exploit step by step, explains why memory vs storage remains the most dangerous single keyword in Solidity, and provides concrete patterns to prevent this class of bug from ever reaching production.
What Is ACPRoute?
ACP (Agent Commerce Protocol) is a modular on-chain commerce framework on Base that structures interactions between AI agent clients and service providers. Think of it as escrow infrastructure for autonomous agents: an AI agent can create a job, negotiate terms, deposit payment into escrow, receive work, and release funds — all on-chain, all without human intervention.
Jobs follow a fixed lifecycle:
REQUEST → NEGOTIATION → TRANSACTION → EVALUATION → COMPLETED
During the TRANSACTION phase, the PaymentManager contract holds escrowed funds. When a job reaches COMPLETED, escrow is released to the provider via releasePayment().
To prevent double-claiming, the protocol tracks cumulative disbursements per job using an amountClaimed field on the Job struct. Each call to releasePayment() compares the requested amount against amountClaimed.
Simple. Sound. Fatally broken.
The Vulnerability: Memory Copies Don't Persist
Here's the core of the bug in the PaymentManager contract (0x56c3...0684 on Base):
function releasePayment(uint256 jobId, uint256 amount) external {
// BUG: 'memory' creates a LOCAL COPY of the struct
Job memory job = jobs[jobId];
require(job.phase == Phase.COMPLETED, "Job not completed");
require(amount <= job.budget - job.amountClaimed, "Exceeds budget");
// This increment happens ONLY on the local copy
// It is NEVER written back to storage
job.amountClaimed += amount;
// Funds are transferred, but storage still shows amountClaimed == 0
IERC20(job.token).transfer(job.provider, amount);
}
The fix would have been one word:
// CORRECT: 'storage' creates a REFERENCE to on-chain state
Job storage job = jobs[jobId];
With memory, the struct is copied from storage into a local variable. Any modifications to that variable are purely transient — they exist only for the duration of the function call and are discarded when execution ends. The on-chain amountClaimed value never changes from zero.
This means every call to releasePayment() or claimBudget() sees amountClaimed == 0 and happily releases the full budget again.
The Attack: Four Steps to Double-Claim
The attacker executed a clean, textbook exploit in transaction 0xe94a...f9a0:
Step 1: Flash Loan Capital
Borrowed 97,000 USDC via flash loan as seed capital.
Step 2: Create a Job
Called createJob() on the ACPRouter, deploying a fresh provider contract (0x1ee502dd...) to receive funds. The 97,000 USDC went into escrow.
Step 3: Fast-Forward to Completion
Repeatedly called createMemo() and the provider contract's exec() to advance the job through all phases until COMPLETED. The phase transition automatically triggered releasePayment(), sending 97,000 USDC to the provider contract.
At this point, amountClaimed should have been 97,000e6. In storage, it was still 0.
Step 4: Double-Claim
Called claimBudget(). The contract checked amountClaimed (still 0), confirmed the full budget was available, and transferred 97,000 USDC a second time.
Net result: 97,000 USDC profit after repaying the flash loan. The only cost was gas.
Why This Bug Keeps Happening
The memory vs storage distinction has been in Solidity since version 0.5.0 made data location explicit. It has been covered in every Solidity tutorial, audit checklist, and security course. And it keeps killing protocols.
Here's why:
1. The Syntax Is Deceptively Similar
Job memory job = jobs[jobId]; // Copy — modifications are lost
Job storage job = jobs[jobId]; // Reference — modifications persist
One word. Same line structure. Completely different semantics. In a 2,000-line contract during a 3 AM code review, this is easy to miss.
2. It Compiles and "Works" in Testing
The function executes correctly on the first call. Tests that only check single-execution paths will pass. The bug only manifests when the same state is read again in a subsequent transaction — exactly the scenario that happy-path tests skip.
3. AI-Generated Code Is Especially Prone
With the rise of AI agent protocols like ACP, there's a meta-irony: AI coding assistants frequently generate memory as the default data location for struct reads because it's "safer" (no unintended side effects). In contexts where you need side effects — like tracking cumulative claims — this default is a landmine.
The Broader Pattern: Phantom State Updates
This isn't just about memory vs storage. The ACPRoute exploit belongs to a broader class I call phantom state updates — code that appears to modify state but doesn't actually persist the change. Other variants:
Variant A: Nested Struct Memory Copies
mapping(uint256 => Position) public positions;
function updateCollateral(uint256 id, uint256 amount) external {
Position memory pos = positions[id];
pos.collateral += amount; // Lost
// Missing: positions[id] = pos;
}
Variant B: Array Element Memory Copies
function markProcessed(uint256 index) external {
Order memory order = orders[index];
order.processed = true; // Lost
}
Variant C: Return Value Ignored
function incrementNonce(address user) internal returns (uint256) {
uint256 nonce = nonces[user];
nonce++; // Local variable, not storage
return nonce; // Returns correct value but storage unchanged
}
Five Defense Patterns
1. Default to storage for Any Struct You Modify
Make it a team convention: if a function modifies any field of a struct read from a mapping or array, use storage. Only use memory for read-only access.
// READ-ONLY: memory is fine
function getJobBudget(uint256 id) external view returns (uint256) {
Job memory job = jobs[id];
return job.budget;
}
// MODIFYING: must use storage
function releasePayment(uint256 id, uint256 amount) external {
Job storage job = jobs[id];
job.amountClaimed += amount;
// ...
}
2. Write Explicit State Assertions
After any critical state update, add an assertion that reads directly from storage:
job.amountClaimed += amount;
assert(jobs[jobId].amountClaimed >= amount); // Reads from storage
This catches phantom updates at the transaction level.
3. Use the Checks-Effects-Interactions Pattern Rigorously
The ACPRoute bug was amplified because releasePayment() transferred funds before properly persisting state. Even if the memory bug existed, a strict CEI pattern with a reentrancy guard would have limited exposure:
function releasePayment(uint256 id, uint256 amount) external nonReentrant {
Job storage job = jobs[id];
// CHECKS
require(amount <= job.budget - job.amountClaimed);
// EFFECTS (persisted to storage)
job.amountClaimed += amount;
// INTERACTIONS
IERC20(job.token).transfer(job.provider, amount);
}
4. Mutation Testing
Tools like Vertigo and Gambit can automatically mutate storage to memory in your codebase and check if any test still passes. If a mutation from storage → memory doesn't break a test, you have insufficient coverage for state persistence.
5. Static Analysis Rules
Slither's uninitialized-storage and custom detectors can flag structs loaded as memory that are subsequently modified. Add to your CI pipeline:
slither . --detect uninitialized-storage
The AI Agent Protocol Security Gap
The ACPRoute exploit highlights a growing concern: AI agent protocols are deploying financial infrastructure at startup speed with startup-quality audits.
ACP handles real money in escrow. It's designed for autonomous agents that operate without human oversight. A double-claim bug in this context isn't just a one-off loss — it means any AI agent interacting with the protocol could be systematically drained by a malicious counterparty agent.
As more AI agent commerce protocols launch (and they will — the intersection of autonomous agents and DeFi is inevitable), the security bar needs to match the risk profile:
- Formal verification for all escrow and payment logic
- Invariant testing that explicitly checks "total disbursed ≤ total deposited"
- Time-delayed withdrawals for escrow releases above a threshold
- Circuit breakers that pause the protocol if cumulative outflows exceed inflows
Key Takeaways
-
memoryvsstorageis a one-word, $58K mistake — and it's been a one-word, multi-million-dollar mistake across DeFi history - The bug passes all happy-path tests — you need multi-transaction test sequences and mutation testing to catch it
- AI agent protocols handling real money need DeFi-grade security — not just a "3 audits performed" checkbox
- Phantom state updates are a class of bugs, not an isolated incident — audit for the pattern, not just the symptom
- Flash loans turn any double-claim into instant, risk-free profit — if your protocol has any idempotency gap, someone will find it
The next wave of exploits won't just target DeFi protocols. They'll target the infrastructure that AI agents use to transact. The ACPRoute exploit is a preview of that future — and a reminder that the oldest bugs are still the deadliest.
Attack transaction: 0xe94a...f9a0
Vulnerable contract: 0x56c3...0684
Source: BlockSec Weekly Roundup, Mar 2-8, 2026
Top comments (0)