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:
- A commented-out escrow sufficiency check in the deployed
PaymentManagerimplementation (0x56c3af6c), and - A missing
updateAmountClaimedcall in the olderACPRouterimplementation (0x307e34d9), causingamountClaimedto 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:
- Inside
createMemo/signMemovia_checkForPhaseTransition→ automatic_claimBudgettrigger. - 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
| Address | Role |
|---|---|
0x79265e89feaf7e971dec75db1432795e6bd4b466 | Attacker EOA |
0xdc8c5f4a4725370a422b5c20c8a725cd0d2b97a9 | Attack Factory (pre-deployed) |
0xe00a626d66994a21cac1df5150e6e1a8465b155a | Nested Attack Contract 1 (created in tx) |
0xe02219e6978c96cc25570087393b4436fa0079f6 | Nested Attack Contract 2 / Flash Loan Recipient |
0xc1ee502dd42bb5433b3fc7b153c9334255fc3c07 | Nested Attack Contract 3 / ACP Job Provider |
0xbbbbbbbbbb9cc5e90e3b3af64bdaf62c37eeffcb | Morpho Blue (flash loan source) |
0xa6c9ba866992cfd7fd6460ba912bfa405ada9df0 | ACPRouter Proxy (victim protocol) |
0x307e34d9421e63d2ca92fab68ae720304927d6e8 | ACPRouter Implementation v1 (vulnerable) |
0xef4364fe4487353df46eb7c811d4fac78b856c7f | PaymentManager Proxy (victim — holds user funds) |
0x56c3af6c5995147f293dc756216920fd24d50684 | PaymentManager Implementation (vulnerable) |
0x9c690c267f20c385f8a053f62bc8c7e2d4b83744 | JobManager Proxy |
0x3a7bb21bc2c80737c8ceaeb2ef9969016190baf2 | JobManager Implementation |
0xe9683559a1177a83825a42357a94f61b26cd64c1 | Platform 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:
- Deploys Nested Attack Contract 3 (
0xc1ee) viaCREATE. - 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 = 0xc1eeevaluator = 0xe022budget = 97,000 USDCphase = REQUESTamountClaimed = 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:
| Call | Actor | Purpose | Phase Result |
|---|---|---|---|
createMemo(jobId, ..., nextPhase=REQUEST) | 0xe022 | Initiate job acceptance | REQUEST initiated |
signMemo(memoId1, isApproved=true, ...) | 0xc1ee | Approve REQUEST | REQUEST confirmed |
createMemo(jobId, ..., nextPhase=NEGOTIATION) | 0xe022 | Move to NEGOTIATION | NEGOTIATION initiated |
signMemo(memoId2, isApproved=true, ...) | 0xc1ee | Approve NEGOTIATION | NEGOTIATION confirmed |
createMemo(jobId, ..., nextPhase=TRANSACTION) | 0xe022 | Move to TRANSACTION | triggers budget escrow |
signMemo(memoId3, isApproved=true, ...) | 0xc1ee | Approve TRANSACTION | transferFrom(0xe022, 0xef4364, 97_000 USDC) |
createMemo(jobId, ..., nextPhase=EVALUATION) | 0xe022 | Move to EVALUATION | TRANSACTION → EVALUATION |
signMemo(memoId4, isApproved=true, ...) | 0xe022 (evaluator) | Approve EVALUATION | EVALUATION confirmed |
createMemo(jobId, ..., nextPhase=COMPLETED) | 0xe022 (as provider) | Request COMPLETED | COMPLETED pending evaluator sign |
signMemo(memoId5, isApproved=true, ...) | 0xe022 (evaluator) | Final approval → COMPLETED | First 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.jsonentries idx 43–186). The full lifecycle requires 10 calls (5 createMemo + 5 signMemo). The attacker controls both the0xe022role (client, evaluator, creator) and0xc1ee(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):
hasSufficientEscrowcheck: SKIPPED (commented out).escrowDetails[jobId].releasedAmount += 97_000e6→releasedAmount = 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) to0xc1ee(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_000e6→releasedAmount = 194,000 USDC.claimableAmount = 97,000 - job.amountClaimed(=0, still!)= 97,000 USDC.- Transfer: another 19,400 USDC to
0xe9683559; 77,600 USDC to0xc1ee.
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:
| Address | USDC Change | Role |
|---|---|---|
0xef4364fe (PaymentManager) | −97,000 USDC | Victim — pre-existing user deposits drained |
0x79265e89 (Attacker EOA) | +58,200 USDC | Net attacker profit |
0xe9683559 (Platform Treasury) | +38,800 USDC | Evaluator fees (protocol’s own address) |
0xe022 / 0xc1ee | 0 (net) | Attack vehicles — returned all funds |
| Morpho | 0 (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:
PaymentManagerproxy (0xef4364) — USDC balance slot0x859b...:- Pre-tx:
0x173109a1d1= 99,606.962641 USDC - Post-tx:
0x9b6317d1= 2,606.962641 USDC - Drained: 97,000 USDC
- Pre-tx:
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:
PaymentManagerproxy0xef4364fe4487353df46eb7c811d4fac78b856c7f(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
Restore the
hasSufficientEscrowcheck inPaymentManager.releasePayment. The commented-out guard is the primary line of defense against over-release.Always call
jobManager.updateAmountClaimed(jobId)immediately afterpaymentManager.releasePayment(...)completes, in every code path that triggers payment release. Upgrade the ACPRouter proxy to the fixed implementation.Add idempotency guard at the job level: Set
job.phaseto a terminal non-COMPLETED state (e.g., a newPAIDphase) or zero outjob.budgetafter the first successful payment release, so any subsequent_claimBudgetcall becomes a no-op before even reaching the PaymentManager.Persist
amountClaimedatomically withinreleasePayment: TheamountClaimedupdate should be a write toJobManagerstorage inside the same transaction as the token transfer — not a local memory update — so concurrent or sequential calls observe the true claimed amount.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 theupdateAmountClaimedcall. Ensure all proxies are upgraded to consistent, audited implementations.