PrimeOTC.sol — Re-review after job 1081 (Job 1082)
Target: PrimeOTC.sol, single file, 660 lines (about 330 nSLOC), Solidity ^0.8.26 (cancun), no imports, not deployed.
Source pin: https://gist.githubusercontent.com/ASTRALDEFI/a4beb16cc72c8ece5c6c30f6797fd601/raw/6cbc3a54a59c950ecf3efea2c7dba9a7d7b1c5c3/PrimeOTC.sol
sha256: d213310ba77a0876bda6982bb43fa174e4865ab0dc8ef4e066bb06cd23a19120 (30,355 bytes). I re-computed it on the fetched file and it matches the hash in the job description. This is a gist, not a git repo, so there is no commit hash.
Chain: Base (8453), not yet deployed.
Method: I read the whole contract and built a protocol map and an access-control inventory myself. Three independent hunting agents then reviewed it, each blind to the others and each with a different focus: gas and assembly, offer accounting and rounding, and hostile tokens with cross-offer isolation. I reconciled their results, re-derived every citation against the real file, and answered your specific questions.
Limits of this review: this was a static review only. I did not compile the file or run any tests, because target-code execution is disabled by audit policy. The gas figures below are hand-computed from the EIP-150, EIP-2929 and EIP-7702 schedules, not measured. I recommend a Foundry test for each Low finding. The x-ray pre-scan scripts were not run. The map and entry-point inventory were built by hand, and the coverage gate below covers all 4 state-changing entry points (all permissionless).
Severity counts: 0 Critical · 0 High · 0 Medium · 4 Low · 11 Informational
Verdict on the changes since job 1081
| Change | Verdict |
|---|---|
L-1 ETH_GAS_FLOOR |
It does what it says: the callee always gets the full 100,000 gas. It does not guarantee gas for the WETH fallback (L-1 below), and it makes the minimum gas limit about 100k higher than real use (L-2 below). |
L-2 _balanceOf |
Correct and memory safe. Details in the answer to your question 2. |
I-2 SameToken in the constructor |
Complete. It checks all three pairs (line 259). |
| L-3 and I-4 in KNOWN LIMITS | Stated accurately. |
No new Critical, High or Medium issue was introduced.
Your specific questions
1. Is the 40,000 margin enough for a cold, value-carrying call to a new account?
Yes, but only by about 3,400 gas, and only for the call itself.
ETH_GAS_FLOOR = 100000*64/63 + 40000 = 141,587(line 245).- The worst case is a cold, empty receiver. The call is charged 2,600 (cold access) + 9,000 (value transfer) + 25,000 (new account) = 36,600. EIP-150 applies the 63/64 cap after these costs, and the callee also gets a free 2,300 stipend.
- The callee gets the full 100,000 when the gas left after those costs is at least 101,587. So the call needs 36,600 + 101,587 = 138,187 gas at the CALL opcode.
- The margin is 141,587 − 138,187 = 3,400 gas. The roughly 50–100 gas of opcodes between
gasleft()and the CALL fit inside it. - An EIP-7702 delegated receiver is not an "empty" account, so it skips the 25,000 charge. Its cost is 14,200, well under the worst case.
- The margin holds at today's gas schedule but is only about 9% of the extra cost (I-1).
2. Can the floor block a cancel or a fill that a reasonable gas limit would allow?
Yes, in two ways (L-1 and L-2).
- L-2: the floor is checked against the gas remaining at the payout, not against the transaction limit. In
selland in the cancel of an ETH Buy offer, the send is the last expensive step, and the real cost after it is about 41k. The floor demands 141.6k at that point. A fixed gas limit of about 150k, or a limit of 1.2x a simulated gas used, can therefore revert withLowGas.eth_estimateGasbinary-searches and accounts for this, and unused gas is refunded. - L-1: the floor reserves gas for the call, not for what follows if the callee burns all 100,000.
3. Is _balanceOf memory safe and correct for all reply sizes? Yes (lines 623-633).
- It writes only scratch space 0x00–0x1f, which
memory-safeassembly may use. - It never touches the free-memory pointer at 0x40 or the zero slot at 0x60.
- No Solidity code runs between the staticcall and
mload(0), so the scratch word cannot change before it is read. - More than 32 bytes: the output is capped at 32 by the staticcall itself, with no return-data copy. Exactly 32 bytes: passes. Fewer than 32, including 0: reverts through
size < 32, so a stalemload(0)is never used. A revert:!okreverts and the payload is not bubbled up. A call to an address with no code returns size 0 and reverts. _call(lines 609-619) behaves the same way, and matches OpenZeppelinSafeERC20semantics: no reply passes, a first word of 1 passes, anything else reverts.
Findings
L-1. The gas floor does not reserve gas for the WETH fallback
Severity: Low · Confidence: 80 · File: PrimeOTC.sol:652-658
652: if (gasleft() < ETH_GAS_FLOOR) revert LowGas();
654: assembly ("memory-safe") { ok := call(ETH_GAS, to, amount, 0, 0, 0, 0) }
655: if (ok) return;
656: WETH.deposit{value: amount}();
657: _push(WETH, to, amount);
Scenario:
- A maker contract's
receive()burns all the gas it is given. - A taker sends a transaction with just over the floor, say 142k, at line 652.
- The callee burns 100k. The caller then has about 141,587 − 36,600 − 100,000 ≈ 4.9k left.
- The fallback needs roughly 35–50k (WETH
deposit, a coldtransferwith a zero-to-nonzero SSTORE, and thePaidAsWethevent). It runs out of gas, and the whole transaction reverts with a generic out-of-gas error rather thanLowGas.
There is no loss or lock of funds, since everything reverts. The effect is that note 8's "never blocked" holds only if the sender supplies about 50k more than the floor implies, and the contract gives no signal about that. A receiver that burns gas only in real execution (for example by branching on gasprice) can make a tightly estimated transaction fail after a passing simulation. The cost is the failed transaction's fee, paid by the caller.
Fix: reserve the fallback cost in the floor and split the constant so the arithmetic is auditable.
uint256 private constant CALL_EXTRA = 2_600 + 9_000 + 25_000;
uint256 private constant FALLBACK_RESERVE = 60_000;
uint256 private constant ETH_GAS_FLOOR = ETH_GAS * 64 / 63 + CALL_EXTRA + FALLBACK_RESERVE + 3_000;
Otherwise, document that clients must simulate with a margin.
L-2. The floor can block a cancel or fill that the real gas cost allows
Severity: Low · Confidence: 75 · File: PrimeOTC.sol:652
In a cancel of an ETH Buy offer, my estimate of the cost before _sendEth is about 85k and the real total about 126k. To pass the floor the transaction needs a limit of about 227k. In buy with excess msg.value there are two sends, and each is checked separately.
Scenario: a keeper or smart wallet that hardcodes a limit of about 150k to clean up expired offers (note 9), or that calls cancel through a gas-limited sub-call, reverts with LowGas although the ETH send would have worked.
Mitigating factors: eth_estimateGas accounts for the floor, and unused gas is refunded, so the cost is only a larger limit.
Fix: keep the guarantee and document the roughly 100k extra gas-limit requirement for integrators. A cheaper alternative is to check only after a failed call that gas was not the cause. That is not equivalent, because it does not prove the callee was starved.
L-3. Any partial fill invalidates every pending whole-fill transaction
Severity: Low · Confidence: 85 · File: PrimeOTC.sol:540-545
541: uint256 left = o.tokenLeft;
542: if (tokenAmount == 0 || tokenAmount > left) revert BadAmount();
543: if (tokenAmount == left) return true;
Scenario:
- A Sell offer is open for 1,000 T at 10,000 USDC with
partialOk = true. The USDC minimum is 10. - A victim sends
buy(id, 1000e18, 10_000e6). - The attacker, or the maker through a second wallet, front-runs with
buy(id, 100e18, 1_000e6). They pay the fair price and receive the tokens, so only gas is lost. tokenLeftis now 900e18, and the victim's transaction reverts withBadAmount. This can be repeated up toquoteTotal / MINtimes.
Nothing is filled at a worse price, because maxQuote and minQuote still protect the taker. It is a grief only.
Fix: let tokenAmount >= tokenLeft mean "fill what is left", guarded by the price limit, or add a buyUpTo and sellUpTo variant. Makers who need certainty can use partialOk = false.
L-4. The sender-side balance drop is never measured in a payout
Severity: Low · Confidence: 60 · File: PrimeOTC.sol:599-604 (and 592-594)
599: function _pushChecked(IERC20 token, address to, uint256 amount) private {
600: uint256 before = _balanceOf(token, to);
601: _push(token, to, amount);
602: uint256 got = _balanceOf(token, to) - before;
603: if (got != amount) revert Shortfall(got, amount);
Note 7 says a payout "cannot short either side". The code measures only what the receiver gained, not what this contract lost.
Scenario:
- A token T has an owner who can switch on a fee charged on top of the amount, taken from the sender's balance.
- Two Sell offers sit in T: one by the owner and one by an honest user H.
- The owner switches the fee on. A fill of the owner's offer passes the receiver check, because the receiver gets exactly
amount, but the pool's T balance falls byamount + fee. - Repeated create and cancel (the cancel at line 371 is unchecked by design) drains H's T escrow, and the last withdrawer's cancel reverts.
The damage stays within offers in T, which note 3 already accepts. It reaches other offers only if T is DGUY, USDC or WETH, which are pooled across all offers.
Fix: also snapshot balanceOf(address(this)) around the push in fills and require the drop to equal amount. For cancel this trades away the "always get out" guarantee, so either accept that or document the limit.
Informational
I-1. The margin is tied to the current gas schedule. The 3,400 gas margin is about 9% of the 36,600 extra cost. A future Base or OP Stack repricing of new-account creation or cold access could make the callee silently receive less than 100,000. The consequence is a failed call and a WETH payout, not a loss. PrimeOTC.sol:245. Use a margin of 50,000 or more, or mention the fork risk.
I-2. Floating pragma. pragma solidity ^0.8.26; (line 2) lets a newer compiler with a later default EVM version build the contract. Pin it (=0.8.26) and set evm_version = cancun, so the deployed bytecode is the audited one.
I-3. Comments overstate the guarantee. Note 8 says "The call always gets the full 100,000", and the constant's comment says "with room to spare" (lines 81-83, 242-245). Both rest on the 3,400 margin in the answer to question 1. Soften the wording or enlarge the margin.
I-4. Expired offers stay in the open set. Nothing removes an expired offer from _open until someone calls cancel, so openCount() and openOffers() include it (lines 458, 484-490). The site must filter on expiresAt. The note 3 spam concern grows with this.
I-5. openOffers paging is not stable across blocks. _close swaps the last offer into the vacated slot (lines 573-580). A client paging across several eth_calls at different blocks can miss an offer or see one twice. Pin the reads to one block, or read the whole set in one call.
I-6. A cancelled offer keeps its tokenLeft and quoteLeft. cancel changes only status and closedAt (lines 365-366), so views show nonzero "left" amounts on cancelled offers. A UI that sums them without filtering on status over-counts. Zero them on cancel if the extra gas is acceptable.
I-7. Unfillable bait offers. createOffer only requires token.code.length != 0 (line 303). token == address(this) or any non-ERC-20 contract is accepted, and a Buy offer on it can never be filled but can be cancelled. Only the maker's own escrow is affected, and the site should filter these.
I-8. A hooked token lets a maker make their own offer unfillable. If a token (ERC-777 or ERC-1363 style) calls the receiver during a transfer, the receiver's code runs inside the balance window of _pushChecked (lines 600-603). A maker can then make got != amount, so every fill of their own offer reverts and takers waste gas. No funds are lost, and the maker can cancel. Plain USDC and WETH have no hooks.
I-9. DGUY is a trust assumption that spans all tokens. DGUY, like USDC, is a quote escrow for offers in every token. If DGUY is fee-bearing, owner-togglable, hooked or blacklistable, L-4 and I-8 apply to every DGUY-priced offer. I did not inspect DGUY. Verify it before deployment.
I-10. The minimum values are the only limit on rounding error. Each partial fill can move the price by up to one quote base unit (lines 399 and 438), so the relative error is at most 1/MIN. The constructor only requires non-zero minimums (line 260). With MIN_USDC = 1, tokenLeft = 3 and quoteLeft = 4 make a buy of 1 token cost 2, a 50% overcharge. The sum over a whole offer stays exact. Enforce a floor in the constructor (for example minUsdc >= 1e4) or verify the deployed values. Note 5's "exact totals" holds for fully filled offers. For an offer cancelled with a remainder, the maker has received the sum of rounded amounts.
I-11. Documentation nits.
expiresAtis documented as "after which it can no longer be filled" (line 168). The code treatstimestamp == expiresAtas expired, so "at or after" is accurate. The fill check (line 533) and the stranger-cancel check (line 357) are consistent, with no overlap or gap.TooSmallis reused for the remainder check with the remainder in the first slot (line 556). Off-chain decoders should know this.
Examined and found sound
- Isolation. Every
_pullusesfrom = msg.sender, and the contract never approves anything. Every payout is bounded by the offer's owntokenLeftorquoteLeft. A hostile token can distort only its own offers, plus the pooled quote tokens as in L-4 and I-9. - Checks and effects.
_record,_closeand the status change incancelall run before any external call. The transient-storage lock is set and cleared correctly, and a revert rolls it back. - Arithmetic.
tokenAmount * quoteLeftis bounded by 2^128 × 2^128, so it cannot overflow. Theuint128casts in_recordcannot truncate. After a partial filltokenLeft > 0andquoteLeft >= MIN. Rounding favours the maker, so a split cannot reduce the total paid. - Open set.
_closeis correct for the first, middle, last and only element. Double close is unreachable, and ids are never reused. - ETH paths. The
msg.valuepath never touches pooled WETH. The WETH-pull path pulls first, then withdraws the same amount. Forced ETH is harmless, because no accounting reads the contract's ETH balance.receive()accepts only WETH. - Self-dealing.
maker == msg.senderandtaker == msg.senderare both refused (lines 310 and 535).token == quoteis refused (line 304). - Return-data handling. There is no return-data bomb, because only the first word is copied.
Coverage gate
- Entry points: 4 state-changing entry points (
createOffer,cancel,buy,sell) plusreceive(), all permissionless or WETH-only, and all examined. There is no owner, admin or upgrade path. - Threat rows: 9 of 9 answered: reentrancy (holds, lock), fee-on-transfer (L-4), gas starvation (L-1, L-2), rounding (I-10), front-running (L-3), token and quote overlap (holds), hostile receiver (holds), freeze and blacklist (documented), spam (I-4).
- Invariants: 6 of 6 answered. 5 hold: escrow covers each offer's record; payouts are bounded by the record;
tokenLeft == 0means Filled;_openis consistent with status; no state-changing path reads the contract's ETH balance. 1 has a gap: the payout is measured at the receiver but not at the sender (L-4). - Holes closed this pass: 0.
Automated review by LeftClaw. This report does not replace a manual audit of the final deployed bytecode.