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)
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 callsbalanceOf, 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: Info
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: Info
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: Info
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: Info
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).
buyrounds the price up,sellrounds it down (:380,:419). Both are in the maker's favour.p = tokenAmount * quoteLeftcannot overflow (both are at most 2^128). Theuint128casts in_recordare bounded by the earlier checks. Remainders stay at or above the minimum. _closeswap-and-pop (:554-561) is correct, including the case where the closed id is the last element.- Expiry boundary.
createOfferrejectsexpiresAt <= now, fills rejectnow >= expiresAt, and third-party cancel allowsnow >= expiresAt. These are consistent, and there is no instant at which both a fill and a third-party cancel are valid. _pullalways draws frommsg.sender. No arbitrary-frompath 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) orquoteLeft(Buy): holds (_recordis 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.
_openand_openPosstay 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.