All incidents

ACP Router/PaymentManager Double-Claim Drain on Base

Share
Mar 02, 2026 12:18 UTCAttackLoss: 97,000 USDCManually checked1 exploit txWindow: Atomic
Estimated Impact
97,000 USDC
Label
Attack
Exploit Tx
1
Addresses
2
Attack Window
Atomic
Mar 02, 2026 12:18 UTC → Mar 02, 2026 12:18 UTC

Exploit Transactions

TX 1Base
0xe94a5ed54d0a9aa317c997607d7d1ea9828ad47626d7794b0e4020ff49cdf9a0
Mar 02, 2026 12:18 UTCExplorer

Victim Addresses

0xef4364fe4487353df46eb7c811d4fac78b856c7fBase
0xa6c9ba866992cfd7fd6460ba912bfa405ada9df0Base

Loss Breakdown

97,000USDC

Similar Incidents

Root Cause Analysis

ACP Router/PaymentManager Double-Claim Drain on Base

1. Incident Overview TL;DR

In Base tx 0xe94a5ed54d0a9aa317c997607d7d1ea9828ad47626d7794b0e4020ff49cdf9a0 (block 42832267), an unprivileged adversary created an attacker-controlled ACP job, progressed it to COMPLETED, and caused the same budget to be released twice. The second release drained pooled USDC from ACP PaymentManager beyond the job’s escrow.

Root cause is the combination of two historical implementation flaws active at the incident block:

  • PaymentManager implementation 0x56c3af6c5995147f293dc756216920fd24d50684 had escrow sufficiency check disabled in releasePayment.
  • ACPRouter implementation 0x307e34d9421e63d2ca92fab68ae720304927d6e8 allowed non-idempotent payout invocation through _claimBudget and explicit claimBudget(jobId).

Observed outcomes in the same transaction were deterministic: payment vault net -97,000 USDC, attacker EOA +58,200 USDC, and treasury +38,800 USDC.

2. Key Background

ACP uses proxy modules. At pre-state σ_B (before block 42832267, i.e., B=42832266), incident-relevant proxies pointed to historical implementations:

Router proxy 0xa6c9ba866992cfd7fd6460ba912bfa405ada9df0 -> 0x307e34d9421e63d2ca92fab68ae720304927d6e8
PaymentManager proxy 0xef4364fe4487353df46eb7c811d4fac78b856c7f -> 0x56c3af6c5995147f293dc756216920fd24d50684
JobManager proxy 0x9c690c267f20c385f8a053f62bc8c7e2d4b83744 -> 0x96268da235ad5d41870fe753273d5ecf5394461d

PaymentManager stores per-job escrow accounting, but transfers are paid from the manager’s pooled USDC balance. Therefore strict per-job payout bounds are required to prevent cross-job fund bleed.

3. Vulnerability Analysis & Root Cause Summary

This is an ATTACK ACT opportunity where escrow conservation and payout finality failed in combination. The required invariant is that cumulative released value for a job never exceeds job escrow. In historical PaymentManager code, releasePayment retained transfer logic but had hasSufficientEscrow check commented out, so over-release did not revert. In historical Router code, completed-job payout path was callable again through explicit claimBudget, with no one-time guard that prevents duplicate terminal payout. The attacker controlled creator/provider/evaluator roles on a synthetic job, so could deterministically drive phase progression to COMPLETED and then invoke an additional claim path. Because token transfers are from pooled vault liquidity, repeated release for the same job consumed third-party funds. This mechanism matches trace, logs, and balance deltas for the incident tx.

4. Detailed Root Cause Analysis

4.1 Invariant and code-level breakpoint

Expected safety invariant:

assert(escrowDetails[jobId].releasedAmount + amount <= escrowDetails[jobId].amount);

Historical PaymentManager_56c3.sol breakpoint:

function releasePayment(...) external override onlyACP nonReentrant {
    require(recipient != address(0), "Zero address recipient");
    require(amount > 0, "Zero amount");
    // require(hasSufficientEscrow(jobId, amount), "Insufficient escrow");
    ...
    escrowDetails[jobId].releasedAmount += amount;
    IERC20(token).safeTransfer(platformTreasury, platformFee);
    IERC20(token).safeTransfer(recipient, netAmount);
}

Historical ACPRouter_307e.sol duplicate payout path:

function claimBudget(uint256 jobId) external nonReentrant {
    ACPTypes.Job memory job = jobManager.getJob(jobId);
    ...
    _claimBudget(jobId);
}

function _claimBudget(uint256 jobId) internal {
    ACPTypes.Job memory job = jobManager.getJob(jobId);
    if (job.phase == ACPTypes.JobPhase.COMPLETED) {
        paymentManager.releasePayment(job.id, job.provider, job.budget, job.evaluator, "Job completion payment");
    }
}

4.2 On-chain mechanism and deterministic evidence

Decoded tx summary / trace shows:

  • createJob(... budget=97000000000)
  • setEscrowDetails(jobId=1002573889, amount=97000000000, token=USDC) once
  • releasePayment(jobId=1002573889, amount=97000000000, ...) twice
  • PaymentReleased(... amount=77600000000 ...) twice

Representative decoded evidence:

paymentManager.setEscrowDetails(jobId=1002573889, amount=97000000000, token=USDC)
paymentManager.releasePayment(jobId=1002573889, amount=97000000000, ...) [called twice]
PaymentReleased(jobId=1002573889, recipient=0xc1ee..., amount=77600000000, token=USDC) [emitted twice]

Balance-diff evidence for tx:

  • PaymentManager USDC delta: -97000000000 (-97,000 USDC)
  • Attacker EOA USDC delta: +58200000000 (+58,200 USDC)
  • Treasury USDC delta: +38800000000 (+38,800 USDC)

4.3 ACT predicate and fee accounting

Success predicate is attacker monetary gain in USDC:

  • before 0, after 58200000000, delta 58200000000.

fees_paid_in_reference_asset is deterministically 0 because reference asset is USDC while execution fees are paid in native ETH. Native fee derivation from receipt:

gasUsed(0x94e39c = 9,757,596)
* effectiveGasPrice(0x6ded0a = 7,204,106 wei)
+ l1Fee(0xa1757d42f = 43,341,304,879 wei)
= 70,338,097,194,055 wei

This equals sender native delta in balance diff (-70338097194055 wei).

5. Adversary Flow Analysis

Adversary-related accounts in the incident:

  • EOA: 0x79265e89feaf7e971dec75db1432795e6bd4b466 (sender and profit receiver)
  • Attacker contract: 0xe02219e6978c96cc25570087393b4436fa0079f6
  • Helper/provider contract: 0xc1ee502dd42bb5433b3fc7b153c9334255fc3c07

End-to-end on-chain sequence in one tx:

  1. Flash loan funding (97,000 USDC) from Morpho.
  2. Create attacker-controlled account/job and set budget.
  3. Drive phases REQUEST -> NEGOTIATION -> TRANSACTION -> EVALUATION -> COMPLETED via attacker-controlled memos/signatures.
  4. First payout through completion path (_claimBudget -> releasePayment).
  5. Second payout via explicit claimBudget(jobId) (same budget released again).
  6. Sweep payout, repay flash loan principal, keep residual profit.

This is ACT-feasible with public contracts and unprivileged addresses only.

6. Impact & Losses

Measured impact for tx 0xe94a5ed5...:

  • Vault loss: 97,000 USDC (PaymentManager net depletion)
  • Attacker EOA gain: 58,200 USDC
  • Treasury gain from duplicated fee path: 38,800 USDC

Affected victim contracts:

  • PaymentManager proxy 0xef4364fe4487353df46eb7c811d4fac78b856c7f
  • ACP Router proxy 0xa6c9ba866992cfd7fd6460ba912bfa405ada9df0

7. References

  1. Root cause artifact: /workspace/session/root_cause.json
  2. Exploit tx metadata/trace/balance diff: /workspace/session/artifacts/collector/seed/8453/0xe94a5ed54d0a9aa317c997607d7d1ea9828ad47626d7794b0e4020ff49cdf9a0
  3. Decoded tx summary: /workspace/session/artifacts/auditor/iter_0/evidence/tx_summary.txt
  4. Call trace: /workspace/session/artifacts/auditor/iter_0/evidence/calltrace.json
  5. Receipt logs: /workspace/session/artifacts/auditor/iter_0/evidence/receipt.json
  6. Implementation slot snapshot: /workspace/session/artifacts/auditor/iter_0/evidence/implementation_slots.txt
  7. Historical Router source: /workspace/session/artifacts/auditor/iter_0/evidence/ACPRouter_307e.sol
  8. Historical PaymentManager source: /workspace/session/artifacts/auditor/iter_0/evidence/PaymentManager_56c3.sol