← PROOF

Adversarial review of DealVault.sol

The report exactly as it sits in the repository, at 70f9806. It is not summarised or softened here: the critical finding, the four accepted ones and the list of what held are all below.

SOURCE: contracts/test/adversarial/FINDINGS.md · 2026-09-11

# Adversarial review of `DealVault.sol`, findings

Branch `adversarial`. Suite: `contracts/test/adversarial/`. The review itself changed nothing in
`src/`; the fixes below were applied afterwards, on the same branch.

## Status after the fix commit

| # | Finding | Status |
| --- | --- | --- |
| F1 | ETH payout return bomb | **Fixed.** `_rawSend`: raw CALL, zero-length output. `ReturnBomb.t.sol` now proves reclaim succeeds at 300k gas and at every limit up to 1e9. |
| F2 | Cap exhaustion griefing | **Fixed.** `MIN_FEE_BPS = 10` floors both fee setters and the constructor; `MAX_BORROWER_SHARE_BPS = 1000` caps one address at 10% of `maxOutstandingEth`. `Griefing.t.sol`. |
| F3 | Blocklisted lender strands collateral | **Accepted.** README, Known and accepted. Allowlist is the mitigation. |
| F4 | Sender-tax / negative rebase strands last exit | **Accepted.** README, Known and accepted. Allowlist is the mitigation. |
| F5 | Owner changes fee on a LISTED deal | **Accepted.** README, Known and accepted. Bounded at 2%; cancel is the remedy. |
| F6 | Credit stuck for a no-receive recipient | **Accepted.** README, Known and accepted. |
| F7 | Gas-burning lender, ~3.16M gas | **Accepted**, unchanged by the fix. |
| F8 | Overflows above 1e73 wei | **Ignored** by decision; the two tests were removed. |

The sections below are the original report, kept as written.

Run: `forge test --match-path 'test/adversarial/*.t.sol'`
Deep invariants: `FOUNDRY_PROFILE=deep forge test --match-path test/adversarial/Invariants.t.sol -vv`

## Succeeded

### F1, CRITICAL. A lender can force default on any deal (adaptive return bomb)
`ReturnBomb.t.sol::test_returnBombLender_blocksReclaimAtEveryGasLimit`

`_sendEth` does `(bool ok,) = to.call{value: amount}("")`. solc 0.8.24 (legacy codegen) copies the
callee's full returndata into memory even though the bytes are discarded. A lender contract whose
`receive()` reads `gas()` and returns a buffer sized to spend ~90% of it forces the caller to pay
the same memory-expansion cost again, with only the 1/64 it kept. `reclaim` therefore runs out of
gas at **every** gas limit (tested 1M .. 1,000,000,000). The README's "retry with more gas"
mitigation does not apply; this is not the gas-burner case.

Sequence: borrower `list()` → attacker contract `fund()` → borrower `reclaim()` reverts forever →
attacker waits `deadline + 24h` → attacker `claim()` takes the collateral. Borrower keeps
`askPrice - fee`; the buyback right they were promised does not exist. Every deal listed is
exposed the moment it is funded by anyone the borrower does not control.

Same primitive also hits `fund()` (borrower-side, self-harm only) and `withdrawCredit` /
`withdrawFees` (self only). It does **not** hit the token leg: OZ 5.4 `SafeERC20` copies 32 bytes
only (`HostileTokens.t.sol::test_returnBombToken_exitsSurvive`).

### F2, MEDIUM (liveness). Anyone can exhaust `maxOutstandingEth` for free
`Griefing.t.sol::test_capExhaustion_withDustCollateral`, `test_capExhaustion_permanent_selfFundedNeverClaimed`

`list()` accepts `amount = 1` wei of any allowlisted token at `askPrice = maxAskPrice`. Ten such
listings fill a 1000 ETH cap against a 100 ETH per-deal cap. Every honest `list()` then reverts
with `OutstandingEthOverCap`. The owner can only raise the cap (ratchet) and the griefer refills.
Variant: at `feeBps = 0` the griefer funds their own dust listings (ETH round-trips) and never
calls `claim()`; the `askPrice` stays in `_outstandingEth` forever and nothing, not even pause,
releases it.

### F3, MEDIUM (token-trust). Collateral stuck forever if the token blocklists the lender
`HostileTokens.t.sol::test_blocklistedLender_collateralStuckForever`

After grace, `reclaim` is closed and `claim` reverts on `safeTransfer` to a blocklisted lender. The
deal sits in FUNDED with no exit for anyone. Applies to USDC-style tokens. Borrower-side blocklist
is a forced default, not a stuck state (`test_blocklistedBorrower_forcedDefault`).

### F4, MEDIUM (token-trust). Sender-side tax or negative rebase strands the last party out
`HostileTokens.t.sol::test_senderTaxToken_lastExitIsStranded`, `test_rebasingToken_negativeRebase_strandsLastExit`

The inbound delta measurement is exact, but outbound the vault sends `deal.amount` and a token
that debits the sender extra (or rebases down) drops the vault's balance below the sum of open
deals. The last exit reverts on balance; that deal is open forever. Invariant 5 ("held >= owed")
is false for these tokens. Positive rebase / stray transfers leave a surplus nobody can move.

### F5, LOW. Owner can change the fee frozen onto a LISTED (not yet funded) deal
`Owner.t.sol::test_ownerFrontRunsFund_changesFeeOnListedDeal`, `test_ownerFlipsHouseToken_changesFeeOnListedDeal`

Fee is read at `fund()`, not `list()`. The owner can front-run any `fund()` with `setFeeBps(200)`
or flip `houseToken`, and the borrower receives up to 2% less than the terms they listed under.
`list()` has no max-fee argument. Bounded by `MAX_FEE_BPS`; the README's "cannot touch an existing
deal" claim is true only for funded deals.

### F6, LOW. A payout recipient that can never accept ETH has its credit stuck forever
`HostileRecipients.t.sol::test_noReceiveLender_ethStuckInCreditsForever`

`withdrawCredit` pays `msg.sender` only. A lender that is a contract without `receive`/`fallback`
(or a lost-key smart wallet) can never collect the buyback ETH the borrower paid.

### F7, INFO. Gas-burning lender: borrower needs ~3.16M gas to reclaim
`ReturnBomb.t.sol::test_gasBurnerLender_minimumGasToReclaim`

Matches the README's accepted vector. Cost-griefing only; under any sane block limit.

### F8, INFO. Arithmetic edges, unreachable with real values
`Arithmetic.t.sol::test_hugeBuybackPrice_overflowsInGrace`, `test_uncappedAsk_feeOverflowBlocksFund`

`buybackPrice` is unbounded: above `2^256 / graceSurchargeBps` the grace-window `reclaim` and
`amountToReclaim()` revert on overflow (pre-deadline reclaim still works). If the owner lifts
`maxAskPrice` to max, `fund()` overflows on the fee multiply above `2^256/feeBps`. Both need
> 1e73 wei.

## Held (attacked, did not break)

- Fund twice, cancel after fund, reclaim/claim twice, reclaim after claim, claim after reclaim,
  claim at every second up to and including `deadline + 24h`, reclaim one second after, wrong
  msg.value in every direction, every call from every wrong party, every mutating call from every
  terminal state, `StateMachine.t.sol`.
- Owner: random sequences of every owner call in random order (fuzz), arbitrary calldata with
  value (fuzz), settings changed after funding, pause across every exit and both pulls, delisting
  across every exit, `withdrawFees` with credits present, owner reentering from `withdrawFees`,
  non-owner access, `Owner.t.sol`. Token balance never moved; credits never moved; funded terms
  never moved.
- Tokens: recipient-side fee-on-transfer, returns-false (both directions), returns-nothing,
  reverts-on-zero, phantom `transferFrom`, reentry into all six entry points from `transferFrom`
  during `list` and from `transfer` during `cancel`/`reclaim`/`claim`, return-bombing `transfer`, `HostileTokens.t.sol`.
- Recipients: reverting lender and borrower (credit path), reentry from `receive()` into all seven
  functions during `fund` and `reclaim` payouts, double-pull on `withdrawCredit`, gas-burning
  borrower, `HostileRecipients.t.sol`.
- Arithmetic: `fee + payout == askPrice` and `due == buyback + floor(surcharge)` for all fuzzed
  inputs, fee <= 2%, surcharge <= 10%, `vault.balance == accruedFees + totalCredits` after every
  path, ETH conserved over random lifecycles, term bounds, deadline at `uint64` edge, `Arithmetic.t.sol`.
- Griefing: front-running cancel with fund and fund with cancel, deal id reuse, third party on every
  entry point, raw ETH to vault, `Griefing.t.sol`.
- Invariants (own handler, 6 contract actors with toggling receive, 2 tokens, owner in the loop,
  time steering into grace / last second / one-past): ETH == fees + credits exactly; per-token
  balance == open collateral exactly; counters == deal set; terminal absorbing; funded fields
  frozen; collateral exits <= 1 and only on terminal; ETH conserved across actors + vault + fee
  sink; credits sum, `Invariants.t.sol`, 2000 runs x 128 depth.
- Gas: no loops; `list` ~282k cold / ~213k warm, `fund` ~112k, `reclaim` ~79k (122k on the credit
  path), `claim` ~74k, `cancel` ~71k; flat in `dealCount` (list #502 <= list #1), `Gas.t.sol`.
  Only adversarial gas paths are the payout calls (F1, F7).