# PrimeOTC.sol — Security Review (Sixth review, job 1093) **Target:** `PrimeOTC.sol`, Solidity `=0.8.26` (cancun), single file, no imports, not deployed. Base. **Source pin:** gist raw file `https://gist.githubusercontent.com/ASTRALDEFI/a5acb0381f2557af15a6066b9493bd6b/raw/03e88ee4bc231401b6dd5646cdba2dc37d132963/PrimeOTC.sol` **sha256:** `727d428b2c27ec721086e6868acc08cbeabd7f16ad239e5b54b282b43b7cbfd9` (38,584 bytes, 785 lines; recomputed locally, matches the client's value). This is a gist, not a git repo, so there is no commit hash. Every `file:line` below was checked against this exact file with `sed -n`. **Method:** full v3 pipeline — x-ray pre-scan → context (3 agents) → ethskills breadth (6 checklist agents) → pashov V4 depth (blind, 10 of 12 agents completed; see Coverage Notes) → hybrid reconciliation. **Severity counts:** 0 Critical · 0 High · 0 Medium · 2 Low · 7 Informational **Bottom line:** No Critical, High or Medium issue was found. The changes since job 1091 are complete and add no new exploit path. Two Low findings are both about statements in the file's own comments that do not match the code. None of them changes the contract's accounting. --- ## Answers to the client's specific questions **1. Does refunding the excess first (L510) open any path?** No. - A taker whose `receive()` reverts, refuses ETH or burns its 100,000 gas gets `ok == false` at L779. `_sendEth` then runs `WETH.deposit{value: amount}()` (L781) with only the *excess* amount and pays the taker WETH. The maker's `quoteAmount` stays in the contract's native balance, so the maker's funds are untouched. - Re-entry from the taker's `receive()` into `createOffer`/`cancel`/`buy`/`sell` hits `nonReentrant` and fails, which also sends the refund down the WETH fallback. Unlocked views see post-`_record` state, which is stated in KNOWN LIMITS. - The refund falling back to WETH happens *before* the maker's payout, but this only affects the taker's own refund. The maker's payout has its own, separate `gasleft() >= ETH_GAS_FLOOR` check at L777. A taker who burns gas can make only their own transaction revert with `LowGas`. They cannot push the maker's payout onto the WETH fallback, because the maker's call always gets the full 100,000 gas (see 2). - ETH accounting: the two sends total exactly `msg.value` (excess + price), so the native balance returns to its prior value. **2. Can the floor be met for both sends in one buy at a reasonable gas limit?** Yes. - `ETH_GAS_FLOOR = 100000*64/63 + 36,600 + 90,000 = 101,587 + 36,600 + 90,000 = 228,187` (the `/63` truncates by under one gas). - At the floor, after the worst-case call cost, about 191.5k gas remains, so 63/64 of it (~188k) exceeds 100k and the receiver always gets the full cap. If the receiver burns all of it, about 91.5k remains. We estimate the WETH fallback at ~60–62k by hand (cold WETH, empty-slot deposit, first-time-holder transfer, two events), which leaves ~30k of margin. This is a hand estimate; see I-6. - Each `_sendEth` re-checks the floor. A buy with excess therefore needs roughly (gas used before the maker send) + 228k, and far more if the taker's own receiver burns gas on the refund (≈ 400k typical, ≈ 450–600k worst case). `eth_estimateGas` converges because success is monotonic in the gas limit. The file's "about 228,000" wording (L162) is accurate only for a single send — see L-2. **3. Are the new floors applied to the right currencies?** Yes. `_min` (L615–619) maps ETH→`MIN_ETH`, USDC→`MIN_USDC`, DGUY→`MIN_DGUY`; the constructor check at L343 pairs `minEth`↔`MIN_ETH_FLOOR` (1e14), `minUsdc`↔`MIN_USDC_FLOOR` (1e6), `minDguy`↔`MIN_DGUY_FLOOR` (1,000e18) in the same order as the constructor arguments and the assignments at L347–349. The minimum is applied to `quoteAmount` in the offer's own currency in `createOffer` (L389–390) and in `_checkPart` (L673). The floors assume USDC has 6 and DGUY has 18 decimals; the constructor does not verify this (I-1). **4. Are the changes since job 1091 complete?** - *L-2 (refund first):* confirmed at L510–511; the maker's payout is the last step in all three `buy` branches (L507, L511, L514), in `sell` (L548) and in `cancel` (L457). - *L-3 (per-currency floors):* confirmed, see 3. - *I-1 (WETH_FALLBACK_GAS = 90,000):* confirmed at L319; floor = 228,187. We did not re-run your fork test (target-code execution is off by audit policy). - *L-1 (note 7 says cancel is unchecked):* confirmed; the stated limit is accurate. The stranger-chooses-order aspect is added as I-5. - *I-3 under KNOWN LIMITS:* present. - No new path was introduced by any of these changes. **5. Fresh review of `_mulGt`, `_size`, `_pushChecked`, `_sendEth`:** all four are correct. - `_mulGt` (L652–666) is the standard `mulmod(a,b,not(0))` 512-bit construction (`hi = mm − lo − (mm < lo)`), compared high word first. Both call sites are right: `buy` reverts when `quote·asked > max·filled` (L491), `sell` when `min·filled > quote·asked` (L538). `tokenAmount` still holds the *asked* amount at both sites (it is overwritten with `filled` only afterwards). - `_size` (L641–647): zero reverts; `>= left` returns the whole remainder even for non-partial offers; anything smaller needs `partialOk`. - `_pushChecked` (L721–729): receiver delta and this contract's delta must both equal `amount`; both subtractions are checked, so the wrong direction panics rather than passes. - `_sendEth` (L776–784): floor, 100k-capped no-copy call, exact-amount WETH fallback. --- ## Findings ### L-1 — KNOWN LIMITS spam-cost claim is false for Sell offers **Severity:** Low · **Confidence:** 90 · **Origin:** Phase 2 (agents 9, 11, 3 + 1 independent) and Phase 1 (dos) **Location:** `PrimeOTC.sol:148-149` (claim), `PrimeOTC.sol:417-419` (Sell escrow), `PrimeOTC.sol:440` (cancel gate) ```solidity /// - Anyone can list many small offers of a worthless token; each locks /// a currency minimum and costs gas. The open set grows with them, ... if (side == Side.Sell) { ... _pull(IERC20(token), msg.sender, tokenAmount); ``` **Description:** For a Sell offer only the offered token is pulled. `quoteAmount >= min` (L390) is a number the maker writes, not value held. An attacker can deploy a worthless token and call `createOffer(token, Sell, USDC, 1, 1e6, false, address(0), 0)` repeatedly. No USDC, WETH or DGUY is locked, and with `expiresAt == 0` only the attacker can ever cancel them (L440). The open set grows for roughly 250k gas per offer (a few cents on Base). **Impact:** No effect on any escrow or accounting. `_close` is O(1) and no state-changing function loops. The cost lands on readers: `openOffers` needs more pages, and the "read every page at one block" rule (L578–582) gets harder. Note 6's "every offer … is worth at least that currency's minimum" is likewise a price, not held value, for Sell offers. **Fix:** Correct the KNOWN LIMITS text to say a Sell offer locks no currency, and have the site filter on maker/token. Or require a bond. No logic change is needed for safety. ### L-2 — Documented gas need for an ETH payout understates a buy with excess ETH **Severity:** Low · **Confidence:** 90 · **Origin:** all phase-1 agents and phase-2 agents 10, 11, 12 **Location:** `PrimeOTC.sol:510-511`, `PrimeOTC.sol:777`, `PrimeOTC.sol:162-163` ```solidity if (msg.value > quoteAmount) _sendEth(msg.sender, msg.value - quoteAmount); _sendEth(maker, quoteAmount); ... if (gasleft() < ETH_GAS_FLOOR) revert LowGas(); ``` **Description:** Each `_sendEth` demands 228,187 gas left on its own. The second check runs after the refund and the work before it, so a buy with excess needs about 228k plus everything used up to that point, and about 160k more if the taker's receiver burns its 100k and the WETH fallback runs. KNOWN LIMITS says "about 228,000 gas left when it starts" (L162), which is true per send but not per transaction. **Impact:** Only the taker's own transaction can revert (`LowGas`), atomically; no funds are at risk and the maker cannot be pushed into the fallback. Integrators with hard-coded gas limits (relayers, 4337 bundlers, multisigs) may fail on the excess-refund path only. **Fix:** Reword L162–165 to say the floor applies per send and can total roughly twice the floor on a buy with excess. Optionally add a fork gas test of that path. ### I-1 — Constructor does not verify decimals or the identity of WETH/USDC/DGUY **Severity:** Informational · **Confidence:** 80 · **Location:** `PrimeOTC.sol:326-328`, `PrimeOTC.sol:339-343` The floors `1e6` (USDC) and `1_000e18` (DGUY) assume 6 and 18 decimals; note 6's "at least a million base units" holds only then. The constructor checks code, distinctness and floors only. The addresses are immutable. Check off-chain before deploy: WETH = the Base predeploy, the native USDC address, and DGUY's decimals (the header says DGUY was read on-chain but does not state decimals). Optional: `block.chainid` and `decimals()` checks. ### I-2 — `msg.value` check for non-ETH quotes runs after the token push **Severity:** Informational · **Confidence:** 95 · **Location:** `PrimeOTC.sol:502-504` ```solidity _pushChecked(token, msg.sender, tokenAmount); if (quote != Quote.ETH) { if (msg.value != 0) revert BadValue(); ``` The whole call reverts atomically, so nothing is lost, but a hostile offered token's code runs before a request that must fail is refused. Move `if (o.quote != Quote.ETH && msg.value != 0) revert BadValue();` above L497 to fail fast. ### I-3 — Note 7's "price limit always holds for what actually arrived" depends on the token's own `balanceOf` **Severity:** Informational · **Confidence:** 70 · **Location:** `PrimeOTC.sol:721-729`, note 7 `_pushChecked` measures the receiver and this contract through `token.balanceOf`, which a hostile offered token controls. A maker can list a token that reports a delivery that never happens, and a taker then pays real USDC/ETH for nothing. This is covered in spirit by "the contract checks amounts, not value or identity" and "a hostile token can harm the offers made in it", but note 7 reads stronger than it is. Reword it as "for an honest `balanceOf`". ### I-4 — A frozen maker of an expired USDC Sell offer cannot be refunded **Severity:** Informational · **Confidence:** 75 · **Location:** `PrimeOTC.sol:456` KNOWN LIMITS states the frozen-maker case only for a Buy offer's refund. The same applies to a Sell offer whose offered token is USDC and whose maker Circle freezes: `_push` to the maker reverts, and after expiry the offer can neither be filled (`Expired`) nor cancelled. It only strands the frozen maker's own escrow, and USDC admin freezes were already named as an unfixable trust assumption. ### I-5 — After expiry a stranger chooses the timing and order of refunds **Severity:** Informational · **Confidence:** 65 · **Location:** `PrimeOTC.sol:440` Any address may cancel an expired offer. With a token that charges a fee on top (already a stated limit), a stranger picks which makers draw the shortfall first. A contract maker that books refunds only when it calls `cancel` itself can receive tokens it did not initiate. No concrete loss found; consider one sentence under note 9. ### I-6 — The 90,000 WETH-fallback reserve rests on a hand estimate **Severity:** Informational · **Confidence:** 60 · **Location:** `PrimeOTC.sol:314-319` We estimate ~60–62k for the worst fallback (cold WETH on the `msg.value` path, empty-slot deposit, first-time holder, events), against ~91.5k left after a full burn. We did not run it. Keep the fork test you have and add the worst case: a gas-burning contract receiver, a cold `to`, a zero WETH balance for the desk, and the `buy`-with-`msg.value` path where WETH is cold. If Base reprices state access, re-check the margin. ### I-7 — Other low-impact observations **Severity:** Informational · **Location:** various - 63/64 term truncates (6,400,000/63 = 101,587.3) by under one gas; absorbed by the margins (`L323`). - `openOffers` paging can skip or repeat entries across blocks because of swap-and-pop (documented, L578–582). - Forced native ETH (selfdestruct/coinbase) is permanently stuck; it is never read, and all sends are exact amounts, so accounting is unaffected. - `WETH.deposit`/`withdraw` are not measured (L425, L770); the desk relies on canonical WETH9 behaviour, including a `receive()` that fits the 2,300-gas stipend (L355–357). - A non-ERC-20 with a `transferFrom(address,address,uint256)` (e.g. an ERC-721) can be listed with amount 1 and then cannot be paid out, which locks only the lister's own NFT. - Not verified here: that the file compiles at `=0.8.26` without a stack-too-deep error in `buy` (no `solc` available in this environment; target code is not built under audit policy). --- ## Coverage Notes **Phase 2 coverage loss (honest disclosure).** Of the 12 pashov V4 agents, 10 completed (1, 2, 3, 6, 7, 8, 9, 10, 11, 12). Execution-trace (agent 4) and invariant (agent 5) died repeatedly with an API safeguard error (`reasoning_extraction`) and returned nothing. Agents 2, 3, 4 and 5 first failed on `sonnet`; 2 and 3 completed on one re-run on `opus`; 4 and 5 failed again on `opus`. Per V4's rule they were not retried again. Model choice also departed from the scope default (sonnet for small scope): agents 7–12 and the re-runs ran on opus after the sonnet failures. I covered both missing lenses myself in the reconciliation: a manual trace of `createOffer`, `buy` (all five payment branches), `sell` and `cancel`, and a direct answer for every inferred invariant below. That is one reviewer's read, not an independent agent pass, so treat the execution-trace and invariant lenses as thinner than the others. **Coverage gate:** `Coverage: 5 entrypoints (createOffer, cancel, buy, sell, receive), 5 addressed. 9 threat rows, 9 answered. 14 invariant rows (table below), 14 answered (the two lost-lens rows were answered by a manual re-read). Holes closed this pass: 0.` (the x-ray entry-point list and the access-control inventory matched; no reconciliation was needed.) **Agreement between phases:** 6/6 phase-1 agents and 4 phase-2 agents raised the per-send gas floor (L-2); 4 raised the Sell-offer spam claim (L-1); every agent confirmed the client's four questions as sound. No phase raised a Medium or higher. ## Access-Control Inventory | Function | Guard | Caller | Moves value | |---|---|---|---| | `constructor` (L339) | argument checks L340–343 | deployer | no | | `receive` (L355) | `msg.sender == WETH` | WETH only | accepts ETH | | `createOffer` (L374) | `nonReentrant`; `taker != msg.sender` | anyone | pulls maker escrow | | `cancel` (L436) | `nonReentrant`; maker, or anyone once `expiresAt != 0 && now >= expiresAt` (L440) | maker / anyone after expiry | refund to `o.maker` only | | `buy` (L475) | `nonReentrant`; `_fillable(Sell)` | anyone, or the named taker; never the maker | token→taker, quote→maker | | `sell` (L524) | `nonReentrant`; `_fillable(Buy)` | same | token→maker, quote→seller | | 8 views + 6 immutable getters | none, unlocked | anyone | no | No owner, admin, pauser, upgrade path, setter or fee. Payout addresses (`o.maker`, `msg.sender`) can never be chosen by a third party; the desk makes no approvals and pulls only from `msg.sender`. ## Threat Model (answered) | Actor | Reach | Result | |---|---|---| | Hostile token | its own offers; lies in `balanceOf`; burns gas | harms only offers in that token (I-3 and KNOWN LIMITS); fills measured at both ends | | Quote token used as an offered token (WETH/USDC/DGUY) | commingled with other offers' quote escrow | payouts bounded by per-offer records and measured deltas; no cross-offer drain found | | Fee-on-top token owner | `cancel` is unmeasured | stated KNOWN LIMIT | | ETH receiver (maker/taker) | 100k-gas call, views, refuse/burn | falls back to WETH; floor prevents starvation; re-entry reverts | | Taker with excess `msg.value` | refund before maker payout | only their own tx can revert | | Taker sizing / rounding | over-ask, parts | buy rounds up, sell rounds down (maker's favour); `_checkPart` bounds dust; `_mulGt` exact | | Stranger canceller | expired cancel | refund only to maker (I-5) | | Front-runner | partial fill first | stated; a full-size ask takes the rest (note 13) | | Forced-ETH sender | selfdestruct | no effect (exact amounts) | ## Invariant Verification | ID | Property | On-chain | Verdict | |---|---|---|---| | I-1 | per-token balance ≥ sum of open escrow in that token | No | Holds for honest tokens; breaks only for fee-on-top tokens via unmeasured `cancel` (stated limit, I-3) | | I-2 | Filled ⇔ `tokenLeft == 0` (and `quoteLeft == 0`) | Yes | Holds (`_record`, `_checkPart`) | | I-3 | `_open[_openPos[id]-1] == id` for open ids | Yes | Holds, including `id` last in `_open` (`_openPos[last]=pos` then `delete _openPos[id]`) | | I-4 | a part and its remainder are each ≥ currency minimum; token never reaches 0 while quote remains | Yes | Holds (`_checkPart`) | | I-5 | remainder never priced worse for the next taker | No | Holds: buy rounds up, sell rounds down; drift ≤ 1 unit against a ≥1e6 floor | | I-6 | maker payout is the last interaction in buy/sell/cancel | Yes | Holds in all branches | | I-7 | native ETH balance returns to its prior value after each call | No | Holds (exact sends); forced ETH is inert | | X-1 | only WETH sends ETH to the desk | Yes | Holds | | X-2 | `msg.value` == price + refund | Yes | Holds | | E-1 | a maker filled in full receives exactly `quoteTotal` | Yes | Holds (last fill takes `quoteLeft`) | | E-2 | fill reverts if the real price is worse than the limit | Yes | Holds (`_mulGt`), for an honest `balanceOf` | | E-3 | gas starvation cannot force the WETH fallback | Yes | Holds (floor re-checked per send) | | — | a part never pays zero or takes all the quote while token remains | Yes | Holds (`_checkPart`) | | — | ids never reused; terms immutable after creation | Yes | Holds | Coverage metrics unavailable — target-code execution disabled by audit policy. x-ray git analysis not applicable (gist, no history). The Mid/High count is zero; no finding in this report has a constructible loss-of-funds path. ## Prior audits Jobs 1064, 1073, 1081, 1082, 1091 are referenced by the client. This report is an independent run; every finding above came from this job's own phases. Nothing was extracted from earlier reports.