# Audit report

> Audit v2 of Briefs, BriefsJury, BriefsText, ImdGatewayRequester and ImdOracle at commit c29c658 (not deployed yet; v1 is live on Robinhood Chain, tag v1-mainnet). Briefs holds every IMD (seed pots, escrowed entry fees, creator earnings, the platform share, unpaid shares for time in the lead, held payments): first look for any way to take, lock or misroute funds, or to break the exact solvency invariant (balance == open pots + creator owed + platform owed + queued fees + the fee of the brief being heard less the jury's price + (reignPool - reignPaid) of settled cases + unpaidTotal), or to stall a case so it can never settle.
> Then time in the lead: reignBps of the pot is shared by how long each challenger led, counted from when its brief was filed until the first deadline (reignEnd); overtime (a new leader in the last minutes pushes endsAt, up to finalCap) decides the final leader but earns no time, and what briefs filed in overtime add (overtimePot) goes to the final leader alone. Check that every leader's seconds add up to reignTotal, that claimReign never pays past reignPool, and that no one can buy time, dilute others or take the final share unfairly (queue spam, self-dealing with several wallets, verdicts landing after the deadline).
> Then relayed briefs: fileBriefFor files a brief its author signed (personal_sign of BriefsJury.relayMessage: case, author, words, deadline, court and chain), and refundRelay pays a refund through the jury and makes the message unfileable; look for replay, malleability, filing and refunding the same message, or free cancellation.
> Then the oracle path: an answer must land only for its own hearing (questionHash rebuilt on chain, delivery by IMD's Intake through BriefsJury.onImdAnswer, answer window, panel, EIP-712 signature for the jury's domain).
> Then griefing by the current leader (mistrial timing, skipStalled and the stall clock, queue spam), admin powers and their bounds (setParams; openCase must name the settings the creator saw, SettingsChanged otherwise), gas (hearingGas and the 64/63 rule), and the text rules in BriefsText (check, checkCase, checkBrief with an English-only ASCII mode and a per-case length limit; the question must stay valid JSON and under 2,000 characters).
> docs/audit-internal-2026-10.md lists what our nine internal reviews found and fixed; test/audit holds the PoCs.

| | |
|---|---|
| Repository | https://github.com/vadiszzz/briefs-contracts.git |
| Commit | `c29c658cf13ba777f61813737f7a2a04a89eea65` |
| Job | `0b0d0d84-c7e0-4dd4-b87e-d94e2e5150d9` |
| Judged | 2026-10-12 22:48 UTC |
| Findings | 1 medium · 2 low · 2 info |

Four agents audited the code as it is at `c29c658`, 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. Medium: Overtime keys on the verdict's time, so a lead taken by a verdict landing at or after endsAt never opens overtime: a last-second filing wins and the case settles in the same call

`src/Briefs.sol:758`

```
        if (t < end) {
```

Overtime exists so that a new leader late in a case gives everyone `overtime` seconds to answer (README: 'overtime keeps a hot case open'; the Overtime event: 'a new leader late in the case pushed the deadline'). _newLeader only pushes the deadline when the VERDICT's block.timestamp t is before c.endsAt. Every hearing takes time (IMD's panel answers in about a minute; answerTimeout is 3 min to 2 h; a mistrial ahead in the FIFO docket takes answerTimeout + 2 min), so a brief filed inside the last seconds, or one queued behind a junk brief its author filed first, is routinely judged after endsAt. For such a lead no Overtime fires, entries stay closed, and because the docket is then empty _maybeSettle pays the pot to that author in the same fulfill. The outcome is inverted from the intent: a challenger judged at end - 4 min hands everyone an extension and can be beaten; one filed at end - 1 s (or behind one junk brief) takes the final share with no possible reply. The same holds inside overtime, which degenerates to 'whoever is judged last'. The existing test test_Reign_ALeaderHeardAfterTheDeadlineTakesTheFinalShareAndTimeFromItsFiling asserts this behaviour and README line 34 states the rule as 'before the deadline', so this is a design gap rather than a coding slip, but it defeats the anti-sniping purpose v2 added overtime for. Merged from four specialists (permissions, economics, flow, math). Fix (verified: the attached proof passes with it and reign accounting is untouched, since reignEnd and overtimePot key on filedAt): decide overtime from the filing time: `if (t < end || filedAt + c.overtime > end)`, keeping `later = t + c.overtime` capped at finalEnd. Entries then reopen for `overtime` seconds after a late verdict; a brief filed long before the deadline but heard after it still does not extend. If the current rule is kept on purpose, the README, site and the eighth review should say that a filing inside the jury's latency defeats overtime.

**Reproduction**

Params: reignBps 6000, overtime 10 min, finalCap 0, answerTimeout 4 min; case with endsAt = now + 1 day (= end), seed 500, fee 5. alice files at end - 11 min and is COOKED at end - 11 min (no overtime, as expected). bob files at end - 1 s (inside the last `overtime` seconds); the hearing opens at once; the COOKED attestation (issuedAt = heardAt + 30) is delivered with fulfill at end + 1 min. Expected (stated purpose): endsAt pushed to about end + 11 min, status Open, carol can file an answer. Actual: getCase(c).endsAt == end, no Overtime event, status == Settled in that same fulfill, winner == bob; carol's fileBrief reverts EntriesClosed/NotOpen. Variant: bob files two junk briefs at end - 5 min and the real one behind them; the junk ones are Sustained at end - 3 min and end - 1 min, the real verdict lands at end + 30 s: same result. test/scratch/OvertimeSnipe.t.sol (the proof) fails on c29c658 with 'overtime must open for a lead taken in the last overtime seconds: 1790886400 <= 1790886400' and passes once _newLeader's trigger includes `filedAt + c.overtime > end`.

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

```solidity
// SPDX-License-Identifier: MIT
pragma solidity 0.8.30;

import {Test} from "forge-std/Test.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {Briefs} from "src/Briefs.sol";
import {BriefsText} from "src/BriefsText.sol";
import {BriefsJury} from "src/BriefsJury.sol";
import {ImdOracle} from "src/ImdOracle.sol";
import {IImdRequester} from "src/interfaces/IImdRequester.sol";
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";

contract MockERC20 is ERC20 {
    constructor(string memory n, string memory s, address to) ERC20(n, s) {
        _mint(to, 1_000_000_000 ether);
    }
}

/// a stand-in for IMD's requester: takes the fee, returns a fresh id, no on-chain answer source
contract MockRequester is IImdRequester {
    IERC20 public immutable imd;
    uint256 internal fee_;
    uint256 public count;

    constructor(IERC20 imd_, uint256 fee__) {
        imd = imd_;
        fee_ = fee__;
    }

    function answerSource() external pure returns (address) {
        return address(0);
    }

    function fee() external view returns (uint256) {
        return fee_;
    }

    function request(string calldata, address) external returns (bytes32 requestId) {
        imd.transferFrom(msg.sender, address(this), fee_);
        requestId = bytes32(uint256(keccak256(abi.encode(address(this), ++count))) << 128);
    }
}

contract Digester {
    function digest(bytes32 domain, ImdOracle.AttestationV2 calldata a) external pure returns (bytes32) {
        return ImdOracle.digestV2(domain, a);
    }
}

/// Overtime is meant to keep a hot case open: "a new leader in the last `overtime` seconds pushes the deadline".
/// But _newLeader only extends when the VERDICT lands before endsAt. A brief filed in the last seconds (or behind a
/// queue) is judged after the deadline, takes the lead, and the case settles at once: the latest sniper can't be
/// answered, while an honest challenger judged a few minutes earlier hands everyone else an extension.
contract OvertimeSnipeTest is Test {
    Digester digester = new Digester();
    MockERC20 imd;
    MockRequester requester;
    Briefs b;
    BriefsJury jury;
    uint256 key = 0xB21EF;
    bytes32 domain;

    address creator = makeAddr("creator");
    address alice = makeAddr("alice");
    address bob = makeAddr("bob");
    address carol = makeAddr("carol");
    address treasury = makeAddr("treasury");

    function setUp() public {
        vm.warp(1_790_800_000);
        imd = new MockERC20("IMD", "IMD", address(this));
        requester = new MockRequester(IERC20(address(imd)), 0.5 ether);
        domain = ImdOracle.domainSeparatorV("2", 1, address(0));
        jury = new BriefsJury(
            BriefsJury.Oracle({signer: vm.addr(key), requester: requester, domain: domain, chainId: 1, hearingGas: 3_000_000}),
            address(this),
            address(0)
        );
        b = new Briefs(IERC20(address(imd)), new BriefsText(), jury, treasury, _params());
        address[4] memory users = [creator, alice, bob, carol];
        for (uint256 i; i < users.length; i++) {
            imd.transfer(users[i], 1_000_000 ether);
            vm.prank(users[i]);
            imd.approve(address(b), type(uint256).max);
        }
    }

    function _params() internal pure returns (Briefs.Params memory) {
        return Briefs.Params({
            minSeed: 10 ether,
            minFee: 1 ether,
            maxOracleFee: 0.9 ether,
            creatorBps: 1_500,
            platformBps: 500,
            panelSize: 11,
            quorum: 6,
            answerTimeout: 4 minutes,
            caseFee: 2 ether,
            minDuration: 10 minutes,
            maxDuration: 90 days,
            maxBrief: 500,
            reignBps: 6_000,
            overtime: 10 minutes,
            finalCap: 0,
            creatorSetsReign: false,
            creatorSetsCap: false
        });
    }

    function _case() internal returns (uint256) {
        Briefs.CaseInput memory x = Briefs.CaseInput({
            title: "Dragon Jokes",
            task: "Write the funniest joke about dragons.",
            standard: "The funnier brief wins.",
            opening: "Dragons never use banks. Too many firewalls.",
            avatar: 3,
            seed: 500 ether,
            fee: 5 ether,
            endsAt: uint64(block.timestamp + 1 days),
            minHold: 0,
            oracleId: 0,
            reignBps: 6_000,
            finalCap: 0,
            overtime: 10 minutes,
            lang: 0,
            maxBrief: 0,
            caseFee: 2 ether,
            creatorBps: 1_500,
            platformBps: 500
        });
        vm.prank(creator);
        return b.openCase(x);
    }

    function _fileAt(uint256 c, address who, string memory words, uint256 t) internal returns (uint256) {
        vm.warp(t);
        vm.prank(who);
        return b.fileBrief(c, words);
    }

    function _verdictAt(uint256 briefId, bool better, uint256 t) internal {
        vm.warp(t);
        Briefs.Brief memory br = b.getBrief(briefId);
        ImdOracle.AttestationV2 memory a = ImdOracle.AttestationV2({
            requestId: br.requestId,
            chainId: 1,
            questionHash: jury.questionHashOf(briefId, 1, 2),
            answerType: 0,
            answer: abi.encode(better),
            figure: 0,
            fromBlock: 1,
            toBlock: 2,
            blockHash: keccak256("b"),
            panelJobId: keccak256("p"),
            panelSize: 11,
            quorum: 6,
            agreed: 6,
            issuedAt: br.heardAt + 30,
            expiresAt: uint64(block.timestamp + 1 days)
        });
        (uint8 v, bytes32 r, bytes32 s) = vm.sign(key, digester.digest(domain, a));
        b.fulfill(briefId, a, abi.encodePacked(r, s, v));
    }

    /// bob files inside the last `overtime` seconds, so by the stated rule his lead must push the deadline; the
    /// verdict lands a minute after the deadline (every hearing takes time), so no overtime: the case settles at once
    /// and carol, who was ready to answer, is shut out.
    function test_ALeadTakenByAVerdictAfterTheDeadlineStillOpensOvertime() public {
        uint256 c = _case();
        uint256 end = b.getCase(c).endsAt;
        uint256 a1 = _fileAt(c, alice, "Alice leads from eleven minutes out.", end - 11 minutes);
        _verdictAt(a1, true, end - 11 minutes); // alice leads; not in the last 10 minutes, no overtime
        assertEq(b.getCase(c).endsAt, end);

        // bob snipes: filed 1 second before the deadline, inside the last `overtime` seconds
        uint256 b1 = _fileAt(c, bob, "Bob snipes one second before the bell.", end - 1);
        _verdictAt(b1, true, end + 1 minutes); // the jury answers after the deadline

        // expected: a new leader in the last `overtime` seconds pushes the deadline, so others can answer
        Briefs.Case memory k = b.getCase(c);
        assertGt(k.endsAt, end, "overtime must open for a lead taken in the last overtime seconds");
        assertEq(uint8(k.status), uint8(Briefs.CaseStatus.Open), "the case must stay open for an answer");
        vm.warp(k.endsAt - 1);
        vm.prank(carol);
        b.fileBrief(c, "Carol answers the sniper in overtime.");
    }
}
```

### 2. Low: refundRelay: a 1-wei refund marks a signed brief refunded, so the author or anyone holding the signature cancels a paid filing for free

`src/BriefsJury.sol:315`

```
        if (amount == 0) revert NothingToRefund(); // a refund pays something: nobody cancels a signed brief for free
```

The eighth review's fix 5 only rejects amount == 0. Any amount >= 1 wei of IMD, paid by the caller to the author, sets relayed[h] = 2, after which _relayHash reverts BadSignature for useRelay and so Briefs.fileBriefFor can never file that message. One wei is free in practice, and the author holds their own signature, so after paying the relayer on Ethereum they can call refundRelay(..., 1) to themselves and the relayer's filing fails; the relayer then reads `refunded` (1 wei) and pays the rest back, as the relay design documents ('the relayer only pays what that record says is still owed'). Net effect: a free, unilateral cancel option on a brief sold as final, exercisable until the relayer's fileBriefFor lands (after seeing a stronger brief land, say). Third parties get the same power whenever a signature becomes visible (the calldata of a fileBriefFor that reverted with OracleTooExpensive, NotHolder or EntriesClosed, or a relayer leak): a 1-wei refund voids the author's brief and leaves the relayer to refund the balance. The comment's guarantee does not hold. Merged from four specialists. Fix: require the first refund (the one flipping state 0 -> 2) to pay a floor the author can rely on (the case's entry fee read from the court, or an owner-set minimum), or restrict that first refund to the author or a relayer role while anyone may add to an open refund.

**Reproduction**

Case c open (fee 5 IMD). dan signs relayMessage(c, words, dan, deadline) with personal_sign. carol (any holder of the signature, 1 wei of IMD approved to the jury) calls jury.refundRelay(c, words, dan, deadline, sig, 1). Expected: cancelling a signed brief costs a real refund. Actual: the call succeeds, relayed(h) == 2, dan received 1 wei, and the relayer's b.fileBriefFor(c, words, dan, deadline, sig) reverts BadSignature. dan can also call refundRelay on his own second signed brief with amount 1 (1 wei to himself, net cost zero) and that filing reverts BadSignature too. test/scratch/Misc.t.sol::test_OneWeiRefundCancelsASignedBrief passes on c29c658.

### 3. Low: openCase pins only part of the settings: panelSize, quorum, answerTimeout, the jury reserve (maxOracleFee) and the court's maxBrief can change under a pending openCase without SettingsChanged

`src/Briefs.sol:628`

```
        c.panelSize = p.panelSize;
        c.quorum = p.quorum;
        c.answerTimeout = p.answerTimeout;
```

The stated rule is that openCase names the settings the creator saw and reverts SettingsChanged otherwise ('a setting the creator can't pick must be the one they saw: the owner may have changed it since', line 635). CaseInput carries oracleId, reignBps, finalCap, overtime, caseFee, creatorBps and platformBps, but the case also fixes for life c.panelSize, c.quorum, c.answerTimeout (lines 628-630), c.reserve = p.maxOracleFee (line 634) and, when x.maxBrief is 0, the court's params.maxBrief passed to checkCase (line 334). None of these is compared with what the creator saw, so a setParams landing between the creator reading the settings and openCase executing (the owner is a multisig, whose transactions are visible in advance, but openCase cannot refuse them) opens the case with a different jury (3-of-2 instead of 11-of-6), a 2-hour mistrial window instead of 4 minutes (each unanswered hearing then holds the docket 2 h), a reserve that lets the jury take up to 4.9 of a 5 IMD fee (the creator's and the pot's cut per brief shrink when IMD's price rises), or a brief limit the creator never saw, with no revert and no way to move the case. Same class as the fixed F-5 (caseFee) and the overtime pin. The owner is trusted, so this is a consistency gap in the SettingsChanged guarantee, not a bypass. Merged from four specialists. Fix: add panelSize, quorum, answerTimeout and maxOracleFee (and the court's maxBrief when x.maxBrief == 0, or require maxBrief != 0) to CaseInput and compare them in _newCase like the others; Briefs is 54 bytes under the size limit, so the comparison may need to live in BriefsText.checkCase's packed rules word or a jury view.

**Reproduction**

Settings at page load: panelSize 11, quorum 6, answerTimeout 4 min, minFee 1, maxOracleFee 0.9, maxBrief 500. Owner calls setParams with panelSize 3, quorum 2, answerTimeout 2 hours, minFee 5 ether, maxOracleFee 4.9 ether, maxBrief 100 (all within _setParams bounds). The creator's openCase with the CaseInput built from the old settings (fee 5 ether, maxBrief 0, oracleId/reignBps/finalCap/overtime/caseFee/split unchanged) is then mined. Expected: revert SettingsChanged. Actual: the case opens; getCase(id) shows panelSize 3, quorum 2, answerTimeout 7200, reserve 4.9e18, maxBrief 100. test/scratch/Misc.t.sol::test_UnpinnedSettingsApplySilently passes on c29c658.

### 4. Info: BriefsJury._check accepts a hearingGas (200k) below what the code says opening a hearing on IMD's Intake costs (about 265k): a setup at the floor stalls every hearing and lets the leader win by skipSt

`src/BriefsJury.sol:400`

```
            o.signer == address(0) || address(o.requester) == address(0) || o.chainId == 0 || o.hearingGas < 200_000
```

_check admits hearingGas down to 200,000, while Briefs._hearNext's own comment (line 688-689) says openHearing as a whole costs 'about 265k on IMD's Intake' (two forceApproves, the Intake's request with a 1.9 KB body and the bookkeeping). With a setup proposed at the floor, this.openHearing{gas: hearingGas} runs out of gas on every brief: each filing is reported HearingStalled, the brief stays at the head of the docket, and STALL_WAIT (6 h) later anyone, the sitting leader first, calls skipStalled to hand each challenger back unheard. The precedent then wins the whole pot by default. This is an owner mistake that the 7-day proposal delay makes visible, so it is a trust assumption rather than a bypass, but the floor should not admit a value the code itself documents as insufficient. Merged from the economics specialist. Fix: raise the floor to the measured worst case with a margin (say 400k), or document that the floor is a placeholder and the deploy script measures the real requester.

**Reproduction**

jury.proposeOracle(Oracle{signer, requester, domain, chainId 1, hearingGas: 200_000}) succeeds; applyOracle after 7 days; a case opened on oracleId 1 with a requester whose request() costs about 230k gas (standing in for the Intake's 265k). alice's fileBrief: the fee is taken, openHearing reverts out of gas, HearingStalled is emitted, getCase(c).hearing == 0 and stalledSince > 0. 6 hours later skipStalled(c) marks the brief Unheard and refunds it. Expected: _check refuses a hearingGas that cannot open a hearing. test/scratch/Misc.t.sol::test_HearingGasFloorBelowTheIntakesCost passes on c29c658.

### 5. Info: 'The opening brief earns no time' is void for a creator with a second wallet: a self-challenger filed one second after opening is paid for the whole pre-competition period

`src/Briefs.sol:755`

```
            if (c.firstReign == 0) c.firstReign = uint64(from);
```

The design says the opening brief earns no time in the lead and that the time before the first COOKED belongs to nobody (the pool is scaled by total / (reignEnd - createdAt)). The creator writes a weak opening brief and, from any other wallet, a marginally better challenger filed one second after openCase; the jury must call it better, so it takes the lead at createdAt + 1, firstReign is set there, the pool is the full reignBps of the pot less overtimePot, and that wallet is paid for every second until the first honest leader's filing. The books still balance (the exact invariant holds) and nothing can be enforced on chain (any address could file first), so this is an economic note for the docs and the site: a case creator who plays is an ordinary player, and the 'opening brief earns none' rule binds only the single opening entry, not its author. The same trick also lets the creator earn creatorBps back on its own fee. Merged from the economics specialist.

**Reproduction**

Params reignBps 6000; case opened at T with endsAt T + 1 day, seed 500e18, fee 5e18. Wallet `sock` files at T + 1 and its COOKED verdict lands at T + 60; alice files at T + 20 h and is COOKED at T + 20 h; settle at T + 1 d. Actual: reignOf(c, sock) == 20h - 1 s, reignTotal == 1 d - 1 s, claimReign(c, sock) pays 253_596_477_777_777_777_777 wei of a 304_316_477_777_777_777_777 wei pool (console output of test/scratch/Misc.t.sol::test_CreatorSockPuppetEarnsThePreCompetitionTime, which passes on c29c658). Expected per the stated rule: the creator's side earns nothing for the time before the first outside challenger.

---

Judge's submission `ff120e8b6955fb0525ccc3c73184d65399084953949c081978b93a8466bfb354`, 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.
