abortPending at zero balance permanently freezes funds that arrive afterwards
abortPending sets the terminal Refunded status even when the escrow holds nothing. Any funding transfer that lands afterwards is permanently frozen, since no function can move tokens out of a terminal escrow.
Description
abortPending is permissionless and callable once createdAt + PENDING_ABORT_WINDOW has
elapsed while deposited == false. It sets status = Status.Refunded unconditionally,
including when the escrow balance is zero:
status = Status.Refunded;uint256 balance = IERC20(token).balanceOf(address(this));if (balance > 0) {IERC20(token).safeTransfer(buyer, balance);emit PendingAborted(buyer, balance);} else {emit PendingAborted(buyer, 0); // status already terminal}
Refunded is terminal — PROTOCOL_SPEC.md §6.2.1: "No transition out of Completed /
Refunded" — and the clone cannot be re-initialised (initialized is already true). The
escrow nevertheless remains able to receive ERC-20 transfers forever, because a plain
transfer to its address consults no contract state.
After a zero-balance abort, any tokens sent to that address at any future point are therefore
unrecoverable. Every path that could move them out is gated on either Pending or
deposited, and neither can ever be reached again. This is an unbounded state hazard, not a
race against a clock: the window length is irrelevant once the abort has fired.
This contradicts the client's own documented guarantee. Two adjacent rows of the
abortPending edge-case table in docs/OPERATION_BEHAVIORS.md are individually
true but cannot both hold:
| Case | Documented behavior |
|---|---|
| Zero balance | "Status still Refunded; event with 0" |
| Someone sent tokens before deposit flow | "Recoverable to buyer" |
PROTOCOL_SPEC.md §8 repeats it — "abortPending refunds any stray tokens to buyer" —
which holds only for tokens present at abort time. The two states were enumerated
independently and never composed.
Vulnerable Scenario: The following steps help understand the issue:
-
The buyer creates an escrow; funding has not yet landed. Fiat rails settle off-chain —
GO_TO_MARKET_PLAN.mdnotes the Paystack on-ramp precedes the reserve credit, with the failure mode "escrows unfunded stayPending". -
PENDING_ABORT_WINDOWelapses (30 days on theproductiontiming profile). -
Any address — a cleanup keeper, or any passerby — calls
abortPending(). The balance is 0, so it emitsPendingAborted(buyer, 0), setsstatus = Refunded, and leavesdeposited == false. -
The buyer's funding transfer lands:
totalAmountarrives at the escrow address. -
The funding relayer calls
activatePrefunded(), which revertsNotPending. -
Every other exit is closed as well:
Call Revert abortPending()NotPendingsweepExcessTokens()NotDeposited(depositedis stillfalse)deposit()NotPendingdepositFor(sig)NotPendingactivatePrefunded(sig)NotPending -
The full principal is permanently locked.
No attacker is required. abortPending is free to call and open to anyone, so a routine
cleanup keeper plus a slow bank settlement produces this unaided. It is reachable in normal
operation, not gated on someone choosing to grief.
Impact
Permanent, unrecoverable loss of the full totalAmount for the affected escrow. No partial
loss and no recovery at any price — the tokens cannot be moved by buyer, seller, arbitrator,
relayer, or owner.
Medium. Impact is High; likelihood is Low–Medium because the funding transfer must land after the abort, which the 30-day production window makes uncommon but which fiat settlement latency makes far from impossible.
This sits against AUDIT_GATE.md DD-3, "Arbitration is always able to make progress
(anti-freeze)", whose C-1 resolution claims "freezes impossible short of total network
death". The docs are otherwise explicit about freezes they accept — dispute deadlock is marked
"accepted" in PROTOCOL_DESIGN_CANVAS.md and "a conscious trade-off" in AUDIT_GATE.md.
This freeze appears on no accepted-risk list; DD-3's reasoning only ever covered the
arbitration path, never the pending/abort path.
Proof of Concept
Self-contained Hardhat test. Save as test/Escrow.abortPendingFreeze.poc.test.js —
it needs no edits to any existing test file and relies only on the suite's existing
test/helpers.js fixtures.
const { expect } = require("chai");const { ethers } = require("hardhat");const {TOTAL_AMOUNT,PENDING_ABORT_SEC,Status,deployPoolAndFactory,createEscrow,signAction,advance,} = require("./helpers");/*** PoC — stray tokens are permanently frozen when they arrive after a zero-balance abort.** docs/OPERATION_BEHAVIORS.md `abortPending` edge cases table states both:* "Zero balance | Status still Refunded; event with 0"* "Someone sent tokens before deposit | Recoverable to buyer"** The two rows are individually true but cannot both hold. Once a zero-balance abort has* moved the escrow to the terminal Refunded state, every path that could move tokens out* is gated on Pending or on `deposited`, neither of which can ever be reached again.*/describe("PoC — abortPending freezes late-arriving funds", function () {let factory, token, escrow;let owner, buyer, seller, other;beforeEach(async function () {[owner, buyer, seller, , , , other] = await ethers.getSigners();const MockToken = await ethers.getContractFactory("MockUSDT");token = await MockToken.deploy();await token.waitForDeployment();await token.mint(buyer.address, ethers.parseUnits("10000", 6));({ factory } = await deployPoolAndFactory(token));escrow = await createEscrow(factory, buyer, seller, { useMilestones: false });});it("locks the full principal when funding lands after a zero-balance abort", async function () {const escrowAddr = await escrow.getAddress();await advance(PENDING_ABORT_SEC + 1);// Any address may close a never-funded escrow (PROTOCOL_SPEC.md 6.2.3: actor "Anyone").await expect(escrow.connect(other).abortPending()).to.emit(escrow, "PendingAborted").withArgs(buyer.address, 0);expect(await escrow.status()).to.equal(Status.Refunded);expect(await escrow.deposited()).to.equal(false);// Buyer's funding lands afterwards — the prefunded path, where the transfer is a plain// ERC-20 send to the escrow address and nothing on-chain couples it to the abort clock.const buyerBefore = await token.balanceOf(buyer.address);await token.connect(buyer).transfer(escrowAddr, TOTAL_AMOUNT);expect(await token.balanceOf(escrowAddr)).to.equal(TOTAL_AMOUNT);// Every exit is now closed.await expect(escrow.connect(buyer).abortPending()).to.be.revertedWithCustomError(escrow, "NotPending");await expect(escrow.connect(buyer).sweepExcessTokens()).to.be.revertedWithCustomError(escrow, "NotDeposited");await expect(escrow.connect(buyer).deposit()).to.be.revertedWithCustomError(escrow, "NotPending");const depositSig = await signAction("DEPOSIT", escrowAddr, buyer);await expect(escrow.connect(owner).depositFor(depositSig)).to.be.revertedWithCustomError(escrow, "NotPending");const activateSig = await signAction("ACTIVATE_PREFUNDED", escrowAddr, buyer);await expect(escrow.connect(owner).activatePrefunded(activateSig)).to.be.revertedWithCustomError(escrow,"NotPending");// Principal sits in a terminal escrow with no withdrawal path.expect(await token.balanceOf(escrowAddr)).to.equal(TOTAL_AMOUNT);expect(await token.balanceOf(buyer.address)).to.equal(buyerBefore - TOTAL_AMOUNT);});it("control — same funds are recovered when they arrive before the abort", async function () {const escrowAddr = await escrow.getAddress();await token.connect(buyer).transfer(escrowAddr, TOTAL_AMOUNT);await advance(PENDING_ABORT_SEC + 1);const buyerBefore = await token.balanceOf(buyer.address);await escrow.connect(other).abortPending();expect(await token.balanceOf(buyer.address)).to.equal(buyerBefore + TOTAL_AMOUNT);expect(await token.balanceOf(escrowAddr)).to.equal(0);});});
Run it:
npm installnpx hardhat test test/Escrow.abortPendingFreeze.poc.test.js
Output:
PoC — abortPending freezes late-arriving funds
✔ locks the full principal when funding lands after a zero-balance abort
✔ control — same funds are recovered when they arrive before the abort
2 passing
The first test walks the scenario above and asserts all five reverts, ending with
balanceOf(escrow) == TOTAL_AMOUNT and the buyer down by the same amount. The abort is issued
by other, an unrelated signer, to show no privilege is needed. Both signature paths use real
personal_sign payloads from the buyer, so neither passes for the trivial reason of a
malformed signature — in depositFor the signature check precedes the status check.
The second test is the control: identical fixture and amounts, tokens transferred before the abort instead of after, buyer made whole. The ordering is the entire defect, which is what makes this a bug rather than a design choice.
The PoC advances PENDING_ABORT_SEC (30 days), clearing the window under both timing
profiles — the checked-in 5-minute development value and the 30-day production value. No
profile regeneration is needed, and the finding survives the production profile.
Evidence: reproduced. The contract under test is the real Escrow. The settlement token is
MockUSDT, a plain ERC-20, but no token behavior is load-bearing — the defect is in escrow
state transitions and reproduces with any compliant token.
Recommendation
Preferred — do not make the abort terminal when nothing was deposited. Refund any stray
balance but leave status == Pending, so the escrow stays both fundable and recoverable:
function abortPending() external nonReentrant {if (status != Status.Pending) revert NotPending();if (deposited) revert AlreadyDeposited();if (block.timestamp < createdAt + PENDING_ABORT_WINDOW) revert AbortWindowActive();// status stays Pending — the escrow remains fundable and stray tokens stay recoverableuint256 balance = IERC20(token).balanceOf(address(this));if (balance > 0) {IERC20(token).safeTransfer(buyer, balance);}emit PendingAborted(buyer, balance);}
This is the only option under which the documented "Recoverable to buyer" guarantee holds in every ordering, and it matches the function's own NatSpec purpose: "Recover stray tokens sent to a never-activated escrow."
Alternatives, if the deal genuinely must be closed:
- Add a buyer-callable rescue valid in
Refunded && !deposited, so late arrivals stay withdrawable. Fixes the freeze; the escrow is still closed to funding. - Restrict
abortPendingto the buyer. Funds go to the buyer regardless, so permissionless access buys nothing beyond letting a third party pay gas, while exposing the state transition to anyone.
Whichever is chosen, correct the OPERATION_BEHAVIORS.md edge-case table, since its two rows
cannot both be satisfied by the current code.
Resolution
Fixed. abortPending no longer sets Refunded. Status stays Pending, so the escrow remains fundable and the sweep stays callable against tokens that arrive afterwards.
Affected files
contracts/Escrow.sol#L392-L405—abortPendingcontracts/Escrow.sol#L411—sweepExcessTokens, gatedonlyDepositedcontracts/Escrow.sol#L459—activatePrefunded, gatedPendingcontracts/Escrow.sol#L472—depositFor, gatedPendingcontracts/Escrow.sol#L650—deposit, gatedPending