# Audit report

> Audit the contracts in Czyzuu/squeeky-market-contracts as they are.

| | |
|---|---|
| Repository | https://github.com/Czyzuu/squeeky-market-contracts.git |
| Commit | `249ed720737450d88bb1d7da239ff061094700e9` |
| Job | `bc92d588-9ac0-478e-8538-a4e73ea93727` |
| Judged | 2026-11-03 15:47 UTC |
| Findings | 1 high · 1 medium · 3 low · 4 info |

Four agents audited the code as it is at `249ed72`, each in one area (math, permissions, economics, control flow),
and a judge reproduced, merged and ranked what they found, then read the code once more itself. Nothing in the repository was changed or deployed.

## Findings

### 1. High: Guardian cannot correct or invalidate a wrong early report: overrideReport inherits the reporter's 'Too early' gate, so the wrong outcome finalizes

`contracts/src/DistanceOracle.sol:40`

```
require(block.timestamp >= m.end || (m.early && distance > m.line && !invalid), "Too early");
```

Merged from audit_flow, audit_math, audit_permissions and audit_economics (same root cause, same fix). overrideReport (lines 33-36) is the guardian's correction path ('A guardian can override reports during a dispute period before final payouts are set'), but it calls the shared _report, whose line-40 check only admits a pre-end report when m.early && distance > m.line && !invalid. So during the dispute window of an early report, the guardian can only replace a YES report with another YES report. The two corrections the role exists for (distance <= line, or invalid=true) revert 'Too early' until m.end. The dispute window is disputeSeconds from the report time (line 41), not from m.end. When disputeSeconds is shorter than the time left before m.end (the normal case for daily markets with early reporting), finalize, which anyone can call, pays outcome 0 (YES) once finalizeAt passes, and NO holders lose their whole stake. The only workaround is for the guardian to re-file a distance > line override before every deadline until m.end and then correct it. That is about 11,000 transactions at disputeSeconds=10 over a 100,000 s window, and missing one finalizes the wrong result. testEarlyStrictAndDispute only overrides 1001 -> 1100 (same side of the line), so this path is untested. Fix that keeps the design: add an isOverride flag to _report and use require(isOverride || block.timestamp >= m.end || (m.early && distance > m.line && !invalid), "Too early"), keeping the m.end>0 / start checks. Optionally also compute finalizeAt for early reports as max(now, m.end) + disputeSeconds. All roles are immutable and setOracle is one-shot, so deploying the fix means redeploying ConditionalTokens, DistanceOracle and Exchange.

**Reproduction**

State: DistanceOracle(ctf, reporter, guardian, disputeSeconds=10); at t=100000 createMarket(M, 99999, 200000, line=1000, early=true, []); holder splitPosition(M, 1, 10e6) and keeps NO (mask 2). (1) reporter report(M, 1001, h, false) -> ok, finalizeAt=100010. (2) guardian overrideReport(M, 900, h2, false) at t=100000: expected accepted; actual revert 'Too early'. (3) guardian overrideReport(M, 0, h3, true): expected accepted; actual revert 'Too early'. (4) at t=100010 anyone finalize(M) -> payout(M,0)=1, payout(M,1)=0; the NO holder redeems 0 instead of 10e6. Reproduced: all four specialist proof files (8 tests) fail with 'Too early' on the current code. With the isOverride fix applied to a scratch copy of the source, all 8 pass and the 13 existing Wheel tests still pass. The attached proof is the audit_flow one.

**Proof**: a Foundry test that fails on this code and passes once it is fixed.

```solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {MockUSDC} from "src/MockUSDC.sol";
import {ConditionalTokens} from "src/ConditionalTokens.sol";
import {DistanceOracle} from "src/DistanceOracle.sol";

interface Vm {
    function warp(uint256) external;
    function prank(address) external;
}

/// Finding: DistanceOracle.overrideReport routes through _report, whose "Too early" gate only
/// admits (distance > line && !invalid) before market end. A guardian therefore cannot replace a
/// false early YES report with a NO or invalid report until the window ends, and the dispute
/// clock (10s here) expires long before that. This test fails on the current code because the
/// override reverts with "Too early"; it passes once overrides are exempt from the early gate.
contract GuardianOverrideEarlyTest {
    Vm constant vm = Vm(address(uint160(uint256(keccak256("hevm cheat code")))));
    MockUSDC u; ConditionalTokens c; DistanceOracle o;
    address reporter = address(0xA11CE); address guardian = address(0xB0B);
    address holder = address(0xC0FFEE);
    bytes32 constant M = keccak256("day");

    function setUp() public {
        vm.warp(100000);
        u = new MockUSDC(); c = new ConditionalTokens(u);
        o = new DistanceOracle(c, reporter, guardian, 10); c.setOracle(address(o));
        uint256[] memory empty = new uint256[](0);
        vm.prank(reporter); o.createMarket(M, 99999, 200000, 1000, true, empty);
        // A NO holder funds one complete set and keeps the NO side (mask 2).
        vm.prank(holder); u.faucet();
        vm.prank(holder); u.approve(address(c), type(uint256).max);
        vm.prank(holder); c.splitPosition(M, 1, 10e6);
    }

    function testGuardianCanCorrectFalseEarlyReportBeforeWindowEnd() public {
        // Reporter files a wrong early report: distance 1001 > line 1000, well before end (200000).
        vm.prank(reporter); o.report(M, 1001, keccak256("bad telemetry"), false);
        (, , uint64 finalizeAt, , ) = o.reports(M);
        require(finalizeAt == 100010, "dispute window set");

        // Guardian, inside the dispute window, corrects it to the true distance 900 (<= line).
        // Expected: accepted. Actual on current code: reverts "Too early".
        vm.prank(guardian); o.overrideReport(M, 900, keccak256("corrected"), false);

        vm.warp(100021); o.finalize(M);
        // Corrected outcome is NO (index 1).
        require(c.payout(M, 0) == 0 && c.payout(M, 1) == 1, "NO should win after correction");
        uint256[] memory masks = new uint256[](1); masks[0] = 2;
        vm.prank(holder); c.redeemPositions(M, masks);
        require(c.claimable(holder) == 10e6, "NO holder is made whole");
    }

    function testGuardianCanInvalidateFalseEarlyReportBeforeWindowEnd() public {
        vm.prank(reporter); o.report(M, 1001, keccak256("bad telemetry"), false);
        // Expected: accepted. Actual on current code: reverts "Too early" because !invalid fails.
        vm.prank(guardian); o.overrideReport(M, 0, keccak256("void"), true);
        vm.warp(100021); o.finalize(M);
        require(c.payout(M, 0) == 1 && c.payout(M, 1) == 1, "invalid pays both sides equally");
    }
}
```

### 2. Medium: Complementary-mint match reverts 'Market closed' on every fresh market: positions(tokenId) is only written by a prior split or refine

`contracts/src/Exchange.sol:64`

```
require(market != bytes32(0) && market == otherMarket && oracle.isTradable(market), "Market closed");
```

Exchange._match finds (market, mask) only through ctf.positions(a.tokenId) / ctf.positions(b.tokenId) (lines 62-63). ConditionalTokens writes positions[id] only in _id, which is called from splitPosition (mask and full^mask) and refine (singleton bits). prepare writes nothing there. So right after createMarket, positions(tokenId(M,1)) and positions(tokenId(M,2)) return (0x0, 0), and line 64 rejects the match. The complementary-mint path (kind 1), which exists to create the first positions from two opposing buys, cannot run until someone calls splitPosition directly with that exact mask. The same applies to union masks in bucket markets, and to the complement of a singleton that was only ever produced by refine. The existing tests hide this because they split before any match. Impact is liveness only: every new market is untradeable on the exchange until an out-of-band split, and no funds are lost. Fix: in ConditionalTokens.prepare, register the singleton and complement ids (for i < outcomes: _id(market, 1<<i); _id(market, full ^ (1<<i))). Optionally add a permissionless registerPosition(market, mask) guarded by 0 < mask < fullMask for arbitrary unions. Alternatively, carry market/mask in the signed Order and check tokenId == ctf.tokenId(market, mask).

**Reproduction**

State: fresh deployment; at t=100000 createMarket(M, 99999, 200000, 1000, true, []); a and b funded and approved; nobody has split M. Operator matchOrders([{maker: a buy tokenId(M,1) @600000 size 20e6, taker: b buy tokenId(M,2) @450000 size 20e6, amount 5e6}]). Expected: kind-1 mint, a holds 5e6 YES, b holds 5e6 NO, locked[M]=5e6. Actual: revert 'Market closed' because ctf.positions(tokenId(M,1)) == (0x0, 0). Reproduced: the attached proof fails with 'Market closed' on the current code and passes once prepare registers singleton and complement ids (checked on a scratch copy of the source).

**Proof**: a Foundry test that fails on this code and passes once it is fixed.

```solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {MockUSDC} from "src/MockUSDC.sol";
import {ConditionalTokens} from "src/ConditionalTokens.sol";
import {DistanceOracle} from "src/DistanceOracle.sol";
import {Exchange} from "src/Exchange.sol";

interface Vm {
    function warp(uint256) external;
    function prank(address) external;
    function startPrank(address) external;
    function stopPrank() external;
    function addr(uint256) external returns (address);
    function sign(uint256, bytes32) external returns (uint8, bytes32, bytes32);
}

/// Finding: Exchange._match derives (market, mask) from ctf.positions(tokenId), which is only
/// written when someone has already split or refined that exact mask. On a freshly created
/// market nobody has, so positions(tokenId) == (0, 0) and the complementary-mint match reverts
/// with "Market closed". The first trade on every new market is impossible through the
/// exchange until a user calls splitPosition directly. This test fails on the current code and
/// passes once positions can be resolved for a tokenId that has not been minted yet.
contract FreshMarketMintTest {
    Vm constant vm = Vm(address(uint160(uint256(keccak256("hevm cheat code")))));
    MockUSDC u; ConditionalTokens c; DistanceOracle o; Exchange e;
    address a; address b; bytes32 constant M = keccak256("fresh-day");

    function setUp() public {
        vm.warp(100000); a = vm.addr(11); b = vm.addr(22);
        u = new MockUSDC(); c = new ConditionalTokens(u);
        o = new DistanceOracle(c, address(this), address(this), 10); c.setOracle(address(o));
        e = new Exchange(c, o, address(this), address(this), address(this));
        uint256[] memory empty = new uint256[](0);
        o.createMarket(M, 99999, 200000, 1000, true, empty);
        fund(a); fund(b);
        // Deliberately no splitPosition here: this is the state of every new market.
    }

    function fund(address who) internal {
        vm.startPrank(who); u.faucet(); u.approve(address(e), type(uint256).max);
        c.setApprovalForAll(address(e), true); vm.stopPrank();
    }

    function order(address who, uint256 mask, uint8 side, uint256 price, uint256 nonce) internal view returns (Exchange.Order memory) {
        return Exchange.Order(who, c.tokenId(M, mask), side, price, 20e6, 190000, nonce, 25, 0);
    }

    function sig(Exchange.Order memory x, uint256 key) internal returns (bytes memory) {
        (uint8 v, bytes32 r, bytes32 s) = vm.sign(key, e.hashOrder(x)); return abi.encodePacked(r, s, v);
    }

    function testFirstComplementaryMintOnFreshMarket() public {
        Exchange.Order memory x = order(a, 1, 0, 600000, 1);   // buy YES at 0.60
        Exchange.Order memory y = order(b, 2, 0, 450000, 2);   // buy NO at 0.45
        Exchange.Match[] memory ms = new Exchange.Match[](1);
        ms[0] = Exchange.Match(x, sig(x, 11), y, sig(y, 22), 5e6);
        // Expected: 5e6 complete sets minted and split between a and b.
        // Actual on current code: revert "Market closed" because positions(tokenId) is unset.
        e.matchOrders(ms);
        require(c.balanceOf(a, c.tokenId(M, 1)) == 5e6, "a holds YES");
        require(c.balanceOf(b, c.tokenId(M, 2)) == 5e6, "b holds NO");
        require(c.locked(M) == 5e6, "collateral locked");
    }
}
```

### 3. Low: Signed FOK/IOC time-in-force is enforced only on the taker leg; the same order in the maker slot is partially filled or stays live

`contracts/src/Exchange.sol:53`

```
if (taker.timeInForce == 2) require(filled[h] == taker.size, "FOK incomplete");
```

Merged from audit_flow, audit_permissions and audit_economics. timeInForce is part of the signed EIP-712 Order, but the maker/taker role is not signed: the operator chooses the slot. _check validates both legs identically, but the post-match loop (lines 50-56) reads only matches[i].taker. So a fill-or-kill (2) order in the maker slot can be partially filled, and an immediate-or-cancel (3) maker order is not cancelled and can be filled again in a later transaction. The post-only (4) rule is correctly taker-only. Severity is low because only the trusted operator chooses the slots, and the signer's price limit still holds. Fix: apply the FOK (filled == size) and IOC (cancel) rules to matches[i].maker as well, and keep the post-only revert for the taker only.

**Reproduction**

State: market M tradable, a holds 10e6 YES (mask 1). (1) a signs sell YES @600000 size 5e6 timeInForce=2; b signs buy YES @700000 size 5e6 tif=0. Operator submits Match{maker: a's FOK, taker: b, amount 3e6}. Expected: revert 'FOK incomplete'. Actual: success, filled[hash(a)] == 3e6. The same pair with the FOK order as taker does revert 'FOK incomplete'. (2) a signs the same sell with tif=3, nonce 3. Match{maker: IOC, taker: b, amount 2e6} -> cancelled[hash] == false, and a second matchOrders for 1e6 succeeds (filled == 3e6). Reproduced in scratch tests testFokMakerPartiallyFilled and testIocMakerStaysLive (both pass, confirming the behaviour).

### 4. Low: Fill notional amount*price/1e6 floors without a minimum: small fills move tokens for zero collateral and zero fee, and in the mint path the taker can pay above its signed limit

`contracts/src/Exchange.sol:65`

```
uint256 av = m.amount * a.price / 1e6; uint256 bv; uint8 kind;
```

Merged from audit_math and audit_economics. av = m.amount * a.price / 1e6 rounds down, and nothing requires av > 0 or amount to sit on a grid that makes the product exact. Same-token path: a seller delivers outcome tokens and receives floor(amount*price/1e6), which can be 0, and both fees floor to 0. Mint path: bv = amount - av = ceil(amount*(1e6 - a.price)/1e6), which can exceed amount*b.price/1e6, so the taker pays more than its signed price. Only the trusted operator picks amount, and the loss is at most 1 unit (1e-6 tUSDC) per fill without compounding, hence low. Fix: require(m.amount % 100 == 0) (prices are multiples of 10_000, so the notional is then exact), or require av > 0 and round toward the party whose limit would otherwise be violated.

**Reproduction**

Same-token: a holds YES; a signs sell YES @10000 (fee 100 bps), b signs buy YES @10000; operator matches amount=99. Expected: seller receives about 0.99 units, or a revert. Actual: USDC balances of a, b and feeRecipient are unchanged, and b receives 99 YES. Mint: a buys YES @600000, b buys NO @400000, amount=1. Expected: b pays at most floor(0.4)=0. Actual: a pays 0, b pays 1. Reproduced in scratch tests testZeroNotionalFill and testMintTakerPaysAboveLimit (both pass, confirming the behaviour).

### 5. Low: No resolution path if the reporter never reports: the guardian cannot start one, and one-sided positions stay locked forever

`contracts/src/DistanceOracle.sol:30`

```
require(msg.sender == reporter && reports[id].finalizeAt == 0, "Reporter or duplicate");
```

Merged from audit_permissions (info) and audit_economics (medium). Every resolution starts with report(), which only the immutable reporter can call. overrideReport requires an existing report (finalizeAt != 0), and there is no timeout fallback. If the reporter key is lost or the service stops, finalize reverts 'Not final' and redeemPositions reverts 'Unresolved' forever. isTradable is false after end, so holders of only one side, the normal result of exchange trading, cannot exit; only complete-set holders can mergePositions. The reporter is a designed trusted role, so this is a missing-liveness-fallback issue rather than a bypass, rated low. Fix that keeps the design: let the guardian file the first report (or an invalid one) once block.timestamp >= end + grace and reports[id].finalizeAt == 0, or allow a permissionless invalid resolution after a long timeout.

**Reproduction**

State: early market M (end 200000); a split 10e6 (mask 1). No report is filed. At t=300000: isTradable(M) == false; guardian overrideReport(M, 0, 0, true) reverts 'Outside dispute window'; finalize(M) reverts 'Not final'; a redeemPositions(M,[1]) reverts 'Unresolved'. Expected: some actor can resolve or invalidate after a grace period. Reproduced in scratch test testNoReportNoResolution.

### 6. Info: Trust assumption: each guardian override restarts the dispute clock, so the guardian can postpone finalization indefinitely

`contracts/src/DistanceOracle.sol:41`

```
reports[id] = Report(distance, dataHash, uint64(block.timestamp + disputeSeconds), invalid, false);
```

Merged from audit_flow, audit_permissions and audit_economics. _report always stores finalizeAt = now + disputeSeconds, and overrideReport only requires now < finalizeAt. Repeated overrides, even with identical values, push finalization out without bound. Meanwhile trading is closed and redeemPositions reverts 'Unresolved'. This is a power of the designed guardian role and is documented as a trust assumption. If a bound is wanted: keep the original finalizeAt on override, extend it at most once, or cap it at max(original, m.end + disputeSeconds).

**Reproduction**

disputeSeconds=10; at t=200000 the reporter reports M (finalizeAt 200010). The guardian calls overrideReport(M, 900, 0, false) every 9 s, 50 times; finalizeAt becomes 200460 and finalize(M) still reverts 'Not final' at t=200459. Reproduced in scratch test testGuardianExtendsIndefinitely.

### 7. Info: uint64 cast of block.timestamp + disputeSeconds truncates; a delay of 2**64 or more wraps finalizeAt (zero dispute window or never finalizable)

`contracts/src/DistanceOracle.sol:41`

```
reports[id] = Report(distance, dataHash, uint64(block.timestamp + disputeSeconds), invalid, false);
```

disputeSeconds is a uint256 bounded only by >= 1 (line 18). The stored finalizeAt is uint64(block.timestamp + disputeSeconds), which truncates silently, while the event emits the untruncated value. A delay of 2**64 makes finalizeAt equal the report time, so finalize succeeds in the same block. A delay of 2**64 - ts gives 0, which leaves the report re-fileable and makes finalize unreachable. This is reachable only through an absurd deployment argument; the audit_math specialist reports the live Sepolia oracle uses 300, which I did not re-check. So it is hardening, not low. Fix: bound delay in the constructor (e.g. <= 365 days) or use SafeCast.toUint64.

**Reproduction**

Deploy DistanceOracle(ctf, reporter, guardian, 2**64); at t=100000 createMarket(M, 99999, 200000, 1000, true, []); report(M, 1001, 0, false). Expected finalizeAt = 100000 + 2**64. Actual: reports(M).finalizeAt == 100000, and finalize(M) succeeds in the same block with payout(M,0)=1. Reproduced in scratch test testFinalizeAtWrap.

### 8. Info: Per-mask floor division in redeemPositions strands dust in locked[market] after invalid or multi-outcome resolution

`contracts/src/ConditionalTokens.sol:84`

```
_burn(msg.sender, id, amount); value += amount * num / c.denominator;
```

Merged from audit_math and audit_economics. Each mask's value amount*num/denominator is floored separately, so a complete set split across masks redeems for less than par when denominator > 1. Up to (masks-1) units stay in locked[market] with no tokens left to claim them. mergePositions, which has no resolved guard, still returns par for the same tokens. Collateral is never over-paid, so this is conservation-safe and dust-sized. Fix: sum the numerators over all masks before one division, i.e. value = sum(amount_j*num_j)/denominator.

**Reproduction**

createMarket(id, 99999, 200000, 0, false, [500,1000]) (3 outcomes); a splitPosition(id, 1, 10e6), holding masks 1 and 6. At t=200001 report(id, 0, 0, true); at t=200011 finalize -> payouts [1,1,1], denominator 3. a redeemPositions(id, [1,6]). Expected claimable 10_000_000. Actual 9_999_999, and locked[id] == 1 permanently. Reproduced in scratch test testRedeemDustStranded.

### 9. Info: Trust assumption: the unsigned maker/taker role lets the operator decide which crossing order gets the price improvement

`contracts/src/Exchange.sol:73`

```
bv = m.amount - av;
```

The execution price is always the maker leg's price (av = amount*a.price/1e6), and in the mint/merge path the taker gets the whole surplus (bv = amount - av). The operator assigns the maker and taker slots freely, so it decides who pays their full limit and who gets the spread. This is within the stated operator-matching design and is documented as a trust boundary, not a bypass. Fix if wanted: tie role to a signed field, or split the surplus deterministically.

**Reproduction**

a signs buy YES @600000, b signs buy NO @600000 (fee 100 bps each). Match{maker=a, taker=b, amount=1e6}: a pays 600000+6000, b pays 400000+4000. Swapping the slots reverses who pays 600000. Reproduced in scratch test testOperatorChoosesWhoKeepsSurplus.

---

Judge's submission `73da51c7afb1c72fc588142e7c1b557b21a065246517d078a3bf345ff6490c7f`, accepted on the IdentityMD network. Acceptance means the report met the job's checks;
it is not a guarantee that the code has no other defects.
