Incident Report: ACP Protocol Double-Release Exploit

Transaction: 0xe94a5ed54d0a9aa317c997607d7d1ea9828ad47626d7794b0e4020ff49cdf9a0 Chain: Base (Chain ID: 8453) Block: 42832267 Date of Analysis: 2026-03-04 Debate Round: 1


Executive Summary

An attacker exploited two compounding vulnerabilities in the Modular Agent Commerce Protocol (ACP) deployed on Base to extract 97,000 USDC from the protocol’s PaymentManager escrow contract. The exploit used a Morpho flash loan of 97,000 USDC as working capital to navigate the ACP protocol’s job lifecycle, triggering releasePayment twice for the same job due to:

  1. A commented-out escrow sufficiency check in the deployed PaymentManager implementation (0x56c3af6c), and
  2. A missing updateAmountClaimed call in the older ACPRouter implementation (0x307e34d9), causing amountClaimed to be read as zero on both payment claims.

The attacker (EOA 0x7926...b466) netted 58,200 USDC after repaying the flash loan, with a further 38,800 USDC flowing to the protocol’s own treasury/evaluator address (0xe9683559). The victim is the ACP PaymentManager proxy (0xef4364fe), which had a pre-existing balance of ~99,607 USDC belonging to other users.


Vulnerability Details

Root Cause 1: Commented-Out Escrow Guard in PaymentManager (0x56c3af6c)

In the deployed PaymentManager implementation at 0x56c3af6c5995147f293dc756216920fd24d50684, the hasSufficientEscrow guard in releasePayment was commented out:

// File: contracts/acp/modules/PaymentManager.sol (impl 0x56c3af6c)
function releasePayment(
    uint256 jobId,
    address recipient,
    uint256 amount,
    address evaluator,
    string calldata reason
) external override onlyACP nonReentrant {
    require(recipient != address(0), "Zero address recipient");
    require(amount > 0, "Zero amount");
    // require(hasSufficientEscrow(jobId, amount), "Insufficient escrow");  // <-- DISABLED

    address token = escrowDetails[jobId].token;
    require(token != address(0), "No escrow token");

    // ...
    escrowDetails[jobId].releasedAmount += amount;

    uint256 claimableAmount = amount - job.amountClaimed;  // job is memory copy
    if (claimableAmount <= 0) {
        return;
    }
    job.amountClaimed += amount;  // Updates LOCAL memory only, not storage
    // ... transfer tokens
}

Without this guard, releasePayment can be called multiple times for the same jobId even after the escrow has been fully drained. The hasSufficientEscrow view function would return false for the second call (97,000 >= 97,000 + 97,000 evaluates to false), but since the check is removed, execution proceeds unconstrained.

Root Cause 2: Missing updateAmountClaimed in Old ACPRouter _claimBudget (0x307e34d9)

The ACPRouter implementation at 0x307e34d9421e63d2ca92fab68ae720304927d6e8 (the version active on the proxy 0xa6c9ba during this transaction) does not call jobManager.updateAmountClaimed(jobId) after a successful releasePayment:

// File: contracts/acp/ACPRouter.sol (impl 0x307e34d9)
function _claimBudget(uint256 jobId) internal {
    // ...
    if (job.phase == ACPTypes.JobPhase.COMPLETED) {
        paymentManager.releasePayment(
            job.id,
            job.provider,
            job.budget,
            job.evaluator,
            "Job completion payment"
        );
        // NO jobManager.updateAmountClaimed(jobId) call here!
    }
    // ...
}

Compare this to the newer ACPRouter implementation (0xb1311c10), which does include the updateAmountClaimed call:

// File: contracts/acp/ACPRouter.sol (impl 0xb1311c10 - fixed version)
function _claimBudget(uint256 jobId) internal {
    // ...
    if (job.phase == ACPTypes.JobPhase.COMPLETED) {
        paymentManager.releasePayment(...);
        jobManager.updateAmountClaimed(jobId);  // <-- Present in fixed version
    }
    // ...
}

As a result, job.amountClaimed in JobManager storage remains 0 across both releasePayment calls. The PaymentManager.releasePayment computes claimableAmount = amount - job.amountClaimed = 97,000 - 0 = 97,000 on both invocations. While job.amountClaimed += amount is executed inside releasePayment, this modifies only a local memory copy of the Job struct, not persistent storage.

Secondary Issue: No Re-Entrancy Protection for claimBudget Path

The old ACPRouter._claimBudget is called from two separate code paths that both complete successfully for a COMPLETED job:

  1. Inside createMemo / signMemo via _checkForPhaseTransition → automatic _claimBudget trigger.
  2. Via the explicit public claimBudget(jobId) function.

The nonReentrant modifier on the claimBudget function only prevents re-entrant calls within the same call frame; it does not prevent sequential calls after the first returns. Since the job phase is never reset after the first release, the condition job.phase == ACPTypes.JobPhase.COMPLETED is still true on the second call.


Attack Flow

Actors

AddressRole
0x79265e89feaf7e971dec75db1432795e6bd4b466Attacker EOA
0xdc8c5f4a4725370a422b5c20c8a725cd0d2b97a9Attack Factory (pre-deployed)
0xe00a626d66994a21cac1df5150e6e1a8465b155aNested Attack Contract 1 (created in tx)
0xe02219e6978c96cc25570087393b4436fa0079f6Nested Attack Contract 2 / Flash Loan Recipient
0xc1ee502dd42bb5433b3fc7b153c9334255fc3c07Nested Attack Contract 3 / ACP Job Provider
0xbbbbbbbbbb9cc5e90e3b3af64bdaf62c37eeffcbMorpho Blue (flash loan source)
0xa6c9ba866992cfd7fd6460ba912bfa405ada9df0ACPRouter Proxy (victim protocol)
0x307e34d9421e63d2ca92fab68ae720304927d6e8ACPRouter Implementation v1 (vulnerable)
0xef4364fe4487353df46eb7c811d4fac78b856c7fPaymentManager Proxy (victim — holds user funds)
0x56c3af6c5995147f293dc756216920fd24d50684PaymentManager Implementation (vulnerable)
0x9c690c267f20c385f8a053f62bc8c7e2d4b83744JobManager Proxy
0x3a7bb21bc2c80737c8ceaeb2ef9969016190baf2JobManager Implementation
0xe9683559a1177a83825a42357a94f61b26cd64c1Platform Treasury / Evaluator Fee Recipient

Step-by-Step Execution

Step 1 — Factory Deployment and Nested Constructor Execution

The attacker EOA calls Attack Factory (0xdc8c) with selector 0x00774360. The factory runs a CREATE to deploy Nested Contract 1 (0xe00a). Within that constructor, Nested Contract 1 deploys Nested Contract 2 (0xe022) via another CREATE. During Nested Contract 2’s constructor, the contract:

  • Sets infinite approvals on USDC, WETH, and cbETH for both Morpho (0xbbbb) and the Bebop/batch aggregator (0x5555).
  • Sets infinite approval on USDC for the ACPRouter proxy (0xa6c9ba).

Step 2 — Initiate Flash Loan

Nested Contract 1 calls 0xe022.0x687407bb, which triggers Nested Contract 2 to call Morpho.flashLoan(USDC, 97_000e6, data). Morpho transfers 97,000 USDC to 0xe022 and immediately calls back 0xe022.onMorphoFlashLoan(97000e6, data) (selector 0x31f57072).

Step 3 — Inside the Flash Loan Callback: Job Creation

Within onMorphoFlashLoan, 0xe022:

  1. Deploys Nested Attack Contract 3 (0xc1ee) via CREATE.
  2. Calls ACPRouter.createJob(provider=0xc1ee, evaluator=0xe022, expiredAt=9999999999, paymentToken=USDC, budget=97_000e6, metadata="dang").

The createJob call creates an ACP Account and an ACP Job with:

  • client = 0xe022 (msg.sender)
  • provider = 0xc1ee
  • evaluator = 0xe022
  • budget = 97,000 USDC
  • phase = REQUEST
  • amountClaimed = 0

No USDC is transferred at this stage; the budget is recorded as a data field only.

Step 4 — Phase Lifecycle Manipulation via createMemo / signMemo

The attacker rapidly advances the job through all phases using alternating createMemo and signMemo calls:

CallActorPurposePhase Result
createMemo(jobId, ..., nextPhase=REQUEST)0xe022Initiate job acceptanceREQUEST initiated
signMemo(memoId1, isApproved=true, ...)0xc1eeApprove REQUESTREQUEST confirmed
createMemo(jobId, ..., nextPhase=NEGOTIATION)0xe022Move to NEGOTIATIONNEGOTIATION initiated
signMemo(memoId2, isApproved=true, ...)0xc1eeApprove NEGOTIATIONNEGOTIATION confirmed
createMemo(jobId, ..., nextPhase=TRANSACTION)0xe022Move to TRANSACTIONtriggers budget escrow
signMemo(memoId3, isApproved=true, ...)0xc1eeApprove TRANSACTIONtransferFrom(0xe022, 0xef4364, 97_000 USDC)
createMemo(jobId, ..., nextPhase=EVALUATION)0xe022Move to EVALUATIONTRANSACTION → EVALUATION
signMemo(memoId4, isApproved=true, ...)0xe022 (evaluator)Approve EVALUATIONEVALUATION confirmed
createMemo(jobId, ..., nextPhase=COMPLETED)0xe022 (as provider)Request COMPLETEDCOMPLETED pending evaluator sign
signMemo(memoId5, isApproved=true, ...)0xe022 (evaluator)Final approval → COMPLETEDFirst releasePayment triggered

The critical phase transition TRANSACTION (signMemo memoId3) calls _updateJobPhase, which executes:

job.jobPaymentToken.safeTransferFrom(job.client, address(paymentManager), job.budget);
paymentManager.setEscrowDetails(job.id, job.budget, address(job.jobPaymentToken));

This transfers the 97,000 USDC flash-loan funds from 0xe022 to PaymentManager (0xef4364) and records escrowDetails[jobId] = {amount: 97_000e6, token: USDC, releasedAmount: 0}.

Note: Call flow derived from on-chain trace (decoded_calls.json entries idx 43–186). The full lifecycle requires 10 calls (5 createMemo + 5 signMemo). The attacker controls both the 0xe022 role (client, evaluator, creator) and 0xc1ee (provider), enabling self-approval of all memos.

Step 5 — First releasePayment (Triggered by signMemo Final Approval)

The final signMemo(memoId5, isApproved=true) call, sent by 0xe022 acting as the evaluator, triggers ACPRouter._updateJobPhase(newPhase=COMPLETED, isApproved=true). With oldPhase=EVALUATION and newPhase=COMPLETED, the router calls IJobManager.updateJobPhase(COMPLETED), which sets the on-chain phase to COMPLETED. The router then calls _claimBudget(jobId), which calls PaymentManager.releasePayment(jobId, 0xc1ee, 97_000e6, ...).

In releasePayment (0x56c3af6c):

  • hasSufficientEscrow check: SKIPPED (commented out).
  • escrowDetails[jobId].releasedAmount += 97_000e6releasedAmount = 97,000 USDC.
  • claimableAmount = 97,000 - job.amountClaimed(=0) = 97,000 USDC.
  • Transfer: 19,400 USDC (20% evaluator fee) to 0xe9683559; 77,600 USDC (80% net) to 0xc1ee (provider).

The job.amountClaimed += 97,000 executes only in local memory and is discarded.

Step 6 — Second releasePayment (Triggered by Explicit claimBudget)

After the flash loan callback concludes, 0xe022 calls ACPRouter.claimBudget(jobId) directly. The router checks job.phase == COMPLETED (still true), calls _claimBudget(jobId) again, and again calls PaymentManager.releasePayment(jobId, 0xc1ee, 97_000e6, ...).

In releasePayment (second time):

  • hasSufficientEscrow: SKIPPED.
  • escrowDetails[jobId].releasedAmount += 97_000e6releasedAmount = 194,000 USDC.
  • claimableAmount = 97,000 - job.amountClaimed(=0, still!) = 97,000 USDC.
  • Transfer: another 19,400 USDC to 0xe9683559; 77,600 USDC to 0xc1ee.

The second release drains 97,000 USDC of pre-existing user deposits from the PaymentManager.

Step 7 — Profit Consolidation and Flash Loan Repayment

0xc1ee transfers all 155,200 USDC (77,600 × 2) back to 0xe022. 0xe022:

  • Sends 58,200 USDC profit to attacker EOA 0x7926.
  • Repays the 97,000 USDC flash loan to Morpho.

Funds Flow

Morpho (0xbbbb)        -->  0xe022:                +97,000 USDC  [flash loan]
0xe022                 -->  PaymentManager (0xef4364): +97,000 USDC  [escrow via phase transition]
PaymentManager (0xef4364) --> 0xe9683559:           +19,400 USDC  [evaluator fee, release 1]
PaymentManager (0xef4364) --> 0xc1ee:               +77,600 USDC  [provider payment, release 1]
PaymentManager (0xef4364) --> 0xe9683559:           +19,400 USDC  [evaluator fee, release 2]
PaymentManager (0xef4364) --> 0xc1ee:               +77,600 USDC  [provider payment, release 2]
0xc1ee                 -->  0xe022:               +155,200 USDC  [consolidation]
0xe022                 -->  Attacker EOA (0x7926): +58,200 USDC  [profit extraction]
0xe022                 -->  Morpho (0xbbbb):       +97,000 USDC  [flash loan repayment]

Net balance changes:

AddressUSDC ChangeRole
0xef4364fe (PaymentManager)−97,000 USDCVictim — pre-existing user deposits drained
0x79265e89 (Attacker EOA)+58,200 USDCNet attacker profit
0xe9683559 (Platform Treasury)+38,800 USDCEvaluator fees (protocol’s own address)
0xe022 / 0xc1ee0 (net)Attack vehicles — returned all funds
Morpho0 (net)Flash loan repaid

Total protocol loss: 97,000 USDC (pre-existing PaymentManager balance drained from 99,607 USDC to 2,607 USDC).


Storage Evidence

From trace_prestateTracer.json:

  • PaymentManager proxy (0xef4364) — USDC balance slot 0x859b...:
    • Pre-tx: 0x173109a1d1 = 99,606.962641 USDC
    • Post-tx: 0x9b6317d1 = 2,606.962641 USDC
    • Drained: 97,000 USDC

From trace_prestateTracer.json post-state for 0xef4364 (escrow tracking):

  • escrowDetails[1002573889].amount = 0x1695a68a00 = 97,000 USDC (the set value)
  • escrowDetails[1002573889].releasedAmount = 0x2d2b4d1400 = 194,000 USDC (double of amount — proof of double-release)

Vulnerable Code Locations

PaymentManager Implementation (0x56c3af6c5995147f293dc756216920fd24d50684)

contracts/acp/modules/PaymentManager.sol, line 114:
// require(hasSufficientEscrow(jobId, amount), "Insufficient escrow");

This comment-out removes the only escrow bound enforcement.

ACPRouter Implementation v1 (0x307e34d9421e63d2ca92fab68ae720304927d6e8)

contracts/acp/ACPRouter.sol, lines 580-614 (function _claimBudget):
Missing call to: jobManager.updateAmountClaimed(jobId);

The newer implementation (0xb1311c10) adds this call at line 603.

JobManager (0x3a7bb21bc2c80737c8ceaeb2ef9969016190baf2)

contracts/acp/modules/JobManager.sol, lines 333-336:
function updateAmountClaimed(uint256 jobId) external jobExists(jobId) onlyACP() {
    ACPTypes.Job storage job = jobs[jobId];
    job.amountClaimed = job.amountClaimed + job.budget;
}

This function exists and correctly updates persistent storage, but it is never called by the vulnerable ACPRouter implementation path used in this transaction.


Impact Assessment

  • Severity: Critical
  • Type: Logic flaw — missing access guard (commented-out check) combined with missing state update (double-release)
  • Affected Contract: PaymentManager proxy 0xef4364fe4487353df46eb7c811d4fac78b856c7f (Base mainnet)
  • Direct Loss: 97,000 USDC (pre-existing user deposits)
  • Attacker Profit: 58,200 USDC (net after flash loan repayment)
  • Flash Loan Used As: Working capital to lock in escrow; the loan was fully repaid; attack exploited pre-existing protocol deposits

Remediation Recommendations

  1. Restore the hasSufficientEscrow check in PaymentManager.releasePayment. The commented-out guard is the primary line of defense against over-release.

  2. Always call jobManager.updateAmountClaimed(jobId) immediately after paymentManager.releasePayment(...) completes, in every code path that triggers payment release. Upgrade the ACPRouter proxy to the fixed implementation.

  3. Add idempotency guard at the job level: Set job.phase to a terminal non-COMPLETED state (e.g., a new PAID phase) or zero out job.budget after the first successful payment release, so any subsequent _claimBudget call becomes a no-op before even reaching the PaymentManager.

  4. Persist amountClaimed atomically within releasePayment: The amountClaimed update should be a write to JobManager storage inside the same transaction as the token transfer — not a local memory update — so concurrent or sequential calls observe the true claimed amount.

  5. Audit all proxy/implementation version combinations actively in production: This exploit was enabled by the proxy (0xa6c9ba) pointing to an older ACPRouter implementation (0x307e) that lacked the updateAmountClaimed call. Ensure all proxies are upgraded to consistent, audited implementations.