# PrimeOTC.sol — Re-review After Fixes (Job 1081) **Target:** `PrimeOTC.sol`, Solidity ^0.8.26 (compiled with 0.8.26 / cancun), single file, no imports, 626 lines, 28,297 bytes **Source (pinned):** https://gist.githubusercontent.com/ASTRALDEFI/1963d5de91dcb093e9767ad4cb5620ee/raw/61dbe465c3613cbbd98963935434fe96600b0d1e/PrimeOTC.sol **sha256:** `b8a070ee765464d71ac8c5c84236a98cb5b210d1b6715c8de78fbe963547f8ba` (re-computed locally; matches the hash in the job description) **Deployment:** none (not deployed yet). Prior reviews by this service: jobs 1064 and 1073 (older file). Findings below come only from this job's own analysis of the new file. **Severity counts:** 0 Critical · 0 High · 0 Medium · 4 Low · 4 Informational ## Method and scope disclosure Small scope (~400 code lines), so I did a single-auditor manual pass, not the full multi-agent pipeline. Concretely: - No x-ray pre-scan, no 12-agent pashov fan-out, no ethskills sub-agent fan-out. A full line-by-line read of the file replaced them. - The pass covered the ERC-20 (non-standard return handling), escrow/AMM-style accounting, reentrancy, ETH/WETH handling, and rounding/dust domains. - The file compiles clean under solc 0.8.26 with the cancun EVM, and I ran a small Foundry harness against it. Only the ETH-delivery path was probed, and that probe did not exercise the gas-starved fallback (see L-1). Everything else is by reading. - Treat this as a thorough manual review, not a multi-agent audit. Findings are stated at the confidence the reading supports. ## Verdict on the requested fix checks | Item | Verdict | |---|---| | H-1: quote payouts in fills use `_pushChecked` | **Fixed.** `buy()` line 394 (`_pushChecked(q, maker, …)`) and `sell()` line 431 (`_payQuote(…, true)` → line 607) now measure the receiver. ETH legs are exact by construction. | | Checked payout cannot block a cancel | **Holds.** `cancel()` uses only `_push` (line 352) and `_payQuote(…, false)` (line 353 → line 608). Neither measures a balance. The only cancel failures are the token itself refusing the transfer (blocklist, pause), which is the documented KNOWN LIMIT. | | No new way to strand escrow | **Holds.** The checked paths are reachable only from `buy()` and `sell()`, which revert atomically. A failed fill cannot leave state behind. It can make an offer unfillable (e.g. a token that started charging a fee), but `cancel()` still returns escrow. See I-1. | | `_call` assembly: memory safety | **Holds.** The call writes its output to scratch space `0x00–0x20` only (line 595). `mload(0)` at line 597 reads that scratch word. The free-memory pointer and allocated memory are untouched, so `"memory-safe"` is valid. | | `_call` assembly: revert, short, empty replies | **Holds.** A revert gives `!ok` and reverts. An empty reply passes only if the target has code (line 599 re-checks `token.code.length`, so an EOA or self-destructed target fails). A reply of 1–31 bytes reverts. A reply of 32 or more bytes passes only if the first word is exactly 1. Only the first 32 bytes are copied, so a return bomb is defused here. | | `_sendEth` assembly | **Holds.** The call has no input or output buffer, returns nothing, and is capped at 100,000 gas. On failure it wraps and sends WETH. In the `msg.value` path of `buy()` the contract holds `msg.value`, so the `deposit{value: amount}` fallback is always funded, including for the excess refund. | | Self fill | **Fixed, with the expected limit.** `_fillable` line 516 and `createOffer` line 291. A maker using a second wallet still defeats it, as stated in note 12. | | Constructor code checks | **Fixed** (lines 239–241). See I-2 for what they do not check. | ## Findings ### [L-1] Taker-controlled gas can turn a maker's ETH payout into WETH **Severity:** Low · **Confidence:** [75] **Location:** `_sendEth`, `PrimeOTC.sol:618-625` (`ok := call(ETH_GAS, to, amount, 0, 0, 0, 0)` at line 620) ```solidity assembly ("memory-safe") { ok := call(ETH_GAS, to, amount, 0, 0, 0, 0) } if (ok) return; WETH.deposit{value: amount}(); _push(WETH, to, amount); ``` The call gets `min(100_000, 63/64 of remaining gas)`. A taker who sets the transaction gas limit tightly can leave a maker contract with less than it needs to receive ETH, so the payout falls through to WETH. The maker still receives full value, and the `PaidAsWeth` event is emitted. A maker contract that handles only ETH, not WETH, would hold WETH it does not expect. This is not a fund loss. **Fix:** If an ETH-only maker contract matters, require `gasleft() > ETH_GAS * 64 / 63 + margin` before the call and revert otherwise. Or document that "ETH is paid as ETH" holds only when the caller supplies enough gas. Not PoC'd. ### [L-2] Return-data bomb is closed on transfers but not on `balanceOf` reverts **Severity:** Low · **Confidence:** [70] **Location:** `_pull` `:567,569`, `_pushChecked` `:581,583` (high-level `token.balanceOf(...)`) Note 10 says a huge reply cannot make the desk run out of gas copying it. That is true for `_call` and `_sendEth`. The high-level `balanceOf` calls are different. When such a call reverts, Solidity bubbles the full revert payload with `returndatacopy` + `revert`, and the cost is chosen by the token. A hostile token can make any `createOffer`, `buy` or `sell` involving it run out of gas. - This only hurts callers who chose to transact with that hostile token, and the call reverts anyway. - `cancel()` never calls `balanceOf`, so exits are unaffected. The claim in note 10 is slightly broader than the code. **Fix:** Either narrow the wording, or read balances through a bounded assembly `staticcall` that copies at most 32 bytes. Not PoC'd. ### [L-3] Third-party cancel of an expired hostile-token offer can burn the caller's gas **Severity:** Low · **Confidence:** [65] **Location:** `cancel`, `PrimeOTC.sol:334-355`; `_push` `:573-575` → `_call` `:595` (`gas()` forwarded) Note 9 lets anyone return an expired offer. `_call` forwards all gas, so a token that burns gas on `transfer` makes that call expensive for a keeper or bot. The escrow still goes only to the maker, so there is no theft. It only deters third-party cleanup of offers in hostile tokens. **Fix:** None required. Keepers should simulate first. ### [L-4] Escrow locked if the maker is frozen in the quote or token currency (documented) **Severity:** Low · **Confidence:** [90] **Location:** `cancel` `:352-353` (documented in KNOWN LIMITS lines 106-114) If a Buy offer is priced in USDC and Circle freezes the maker, every `cancel()` reverts. Takers can still fill, which drains the escrow to takers at the offer's price, and the maker is paid in the offered token via `_pushChecked` (which also fails if that token freezes the maker, but only the fill reverts). If nobody fills, the escrow stays locked. The same applies to a Sell offer of a token that blocklists the maker. This is the documented design choice. I agree that redirecting the refund would route around the freeze. ### [I-1] Fee toggled on after listing makes the offer unfillable but not unexitable **Severity:** Informational After a token starts charging a fee, every fill reverts with `Shortfall` (`:570`, `:584`). Cancel still works, because it neither pulls nor measures (`:352`). The maker receives the net-of-fee amount, which is the token's own behaviour. Behaves as designed. ### [I-2] Constructor does not check that the three currencies are distinct or that `weth` is WETH **Severity:** Informational **Location:** `PrimeOTC.sol:238-248` Only `address(0)` and `code.length == 0` are rejected. If `usdc == dguy`, or `weth` is not WETH9-compatible (`deposit`, `withdraw`, 2,300-gas ETH send), quote logic and the SameToken check mix currencies. This is a deploy-time risk only. Verify the three addresses on Base before deploying (`0x4200…0006` for WETH, and the real USDC and DGUY). ### [I-3] `_checkPart` revert arguments are misleading when the part takes all of the quote **Severity:** Informational **Location:** `PrimeOTC.sol:537` When `quoteAmount >= left`, the revert reports `TooSmall(0, min)`. The guard itself is correct (it blocks the note-5 rounding corner). The error only confuses a front end. ### [I-4] Open-set spam **Severity:** Informational **Location:** `createOffer` `:293-313` Minimums are measured in quote value only, so anyone can list many tiny offers of a worthless token. Each costs gas and a recoverable minimum escrow. They enlarge `_open` and `openOffers` pages (`:465-471`). The site must filter, as note 3 says. The cost to the attacker is gas alone. ## Items examined, no issue found - **Reentrancy.** Transient-storage lock on all four state-changing functions (`:229-236`). It is cleared after the body, and a revert clears it automatically. State (`_record`, `_close`) is written before any external call. Views are unlocked, as documented. - **Cross-offer isolation (note 3).** Payouts come only from per-offer records, and the contract has no approvals to give. A hostile token can only misreport its own balances. One caveat is the documented rebasing and reflection limit. - **Rounding and dust (notes 5, 6).** `buy` rounds the price up, `sell` rounds it down (`:380`, `:419`). Both are in the maker's favour. `p = tokenAmount * quoteLeft` cannot overflow (both are at most 2^128). The `uint128` casts in `_record` are bounded by the earlier checks. Remainders stay at or above the minimum. - **`_close` swap-and-pop** (`:554-561`) is correct, including the case where the closed id is the last element. - **Expiry boundary.** `createOffer` rejects `expiresAt <= now`, fills reject `now >= expiresAt`, and third-party cancel allows `now >= expiresAt`. These are consistent, and there is no instant at which both a fill and a third-party cancel are valid. - **`_pull` always draws from `msg.sender`.** No arbitrary-`from` path exists, so nobody else's allowance can be spent. - **ETH handling.** `receive()` accepts only WETH. Forced ETH (selfdestruct) does not affect accounting, since every payout is an explicit amount. ## Access-control inventory | Function | Access | Reviewed | |---|---|---| | `createOffer` | any caller | yes | | `cancel` | maker, or anyone once expired | yes | | `buy` / `sell` | any caller (private taker, not the maker) | yes | | views (`getOffer`, `getOffers`, `openOffers`, `recentOffers`, `offersOf`, `offerCount`, `openCount`, `minimums`) | public | yes | | `receive` | WETH only | yes | There is no owner, admin or upgrade path. ## Invariant checks - Escrow per offer equals `tokenLeft` (Sell) or `quoteLeft` (Buy): **holds** (`_record` is the only mutator, and fills and cancel pay out exactly the recorded amounts). - Token leaves the contract only via a fill at the offer price or a cancel to the maker: **holds**. - `_open` and `_openPos` stay mutually consistent: **holds**. - A fill is atomic, with no state where one side paid and the other did not: **holds**. - Cancel cannot be blocked by anything the contract itself does: **holds**. Token-side freezes are the documented exception. Coverage notes: every external entry point listed above was read. Invariants were checked by reading, not by fuzzing.