DEV Community

ohmygod
ohmygod

Posted on

The $58K ACPRoute Exploit: How a Single `memory` Keyword Let an Attacker Double-Claim Every Escrow on an AI Agent Commerce Protocol

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
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

The fix would have been one word:

// CORRECT: 'storage' creates a REFERENCE to on-chain state
Job storage job = jobs[jobId];
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

Variant B: Array Element Memory Copies

function markProcessed(uint256 index) external {
    Order memory order = orders[index];
    order.processed = true;  // Lost
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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;
    // ...
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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 storagememory 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
Enter fullscreen mode Exit fullscreen mode

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

  1. memory vs storage is a one-word, $58K mistake — and it's been a one-word, multi-million-dollar mistake across DeFi history
  2. The bug passes all happy-path tests — you need multi-transaction test sequences and mutation testing to catch it
  3. AI agent protocols handling real money need DeFi-grade security — not just a "3 audits performed" checkbox
  4. Phantom state updates are a class of bugs, not an isolated incident — audit for the pattern, not just the symptom
  5. 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)