abortPending refunds reserve-prefunded tokens to the buyer
abortPending sends the escrow's entire balance to the buyer regardless of who supplied it, and anyone can call it once the window elapses. Tokens pre-funded from the platform reserve can therefore be refunded to a buyer who never paid them.
Description
activatePrefunded exists for escrows funded from a reserve rather than by the buyer. It transfers nothing — it attests that the balance is already present and flips Pending to Active:
if (IERC20(token).balanceOf(address(this)) < totalAmount) revert InsufficientBalance();_activateFunding();deposited = true;status = Status.Active;
abortPending sends the escrow's entire balance to buyer, regardless of who supplied it, and is permissionless:
if (block.timestamp < createdAt + PENDING_ABORT_WINDOW) revert AbortWindowActive();status = Status.Refunded;uint256 balance = IERC20(token).balanceOf(address(this));if (balance > 0) { IERC20(token).safeTransfer(buyer, balance); ... }
The two overlap. A prefunded escrow that has not yet been activated still reads deposited == false and status == Pending, which is exactly the state abortPending treats as "never funded". No state records who supplied the balance.
Vulnerable Scenario: The following steps illustrate the issue:
- The reserve transfers
totalAmountinto a newly created escrow ahead of activation. - Activation is delayed past
PENDING_ABORT_WINDOW— a relayer outage, a stalled buyer signature, a failed onboarding step, or an abandoned checkout. - Any address calls
abortPending. All three guards pass, becausedepositedis still false. - The reserve's tokens transfer to the buyer, who supplied nothing on-chain.
statusbecomesRefunded, which is terminal.activatePrefundedrevertsNotPendingthereafter and the deal cannot be recovered.
Impact
Direct loss of platform float to a party that did not fund the escrow, with no recovery path once the terminal state is set. It requires no collusion and no privileged access — any observer can trigger it after the window elapses — and it scales with how much prefunding is outstanding concurrently.
Recommendation
Record the funder at the point of transfer rather than at attestation. Attestation is precisely the step that does not happen in this scenario, so a prefunder written inside activatePrefunded is unset in every case where abortPending becomes reachable and the balance still falls through to buyer. Recording a depositor on the funding paths and defaulting to buyer when it is unset was implemented against this scenario and the reserve's balance still reached the buyer in full.
An ERC-20 transfer gives the recipient no record of its sender, so the reserve needs an explicit funding entry point that pulls the tokens and stores the payer. abortPending then returns the balance to that address when set, falling back to buyer otherwise, which keeps the stray-token recovery path intact for escrows the buyer did fund.
The same record closes F-2026-0008 and removes a hazard in F-2026-0018: it lets deposit reject a clone that is already funded, and lets sweepExcessTokens treat a prefunded balance as protected rather than as surplus.
Resolution
Mitigated. Both shapes were tested. Routed through the new prefundEscrow entry point, the float returns to the reserve. Pushed as a plain ERC-20 transfer, prefunder stays unset and the float still goes to the buyer.
The fix is real but conditional: it holds only if the orchestrator always moves reserve funds through prefundEscrow. Any path still using a bare transfer keeps the original behaviour.
Affected files
contracts/Escrow.sol#L391-L406contracts/Escrow.sol#L452-L467