# Audit report

> Audit Briefs, BriefsJury, BriefsText, ImdGatewayRequester and ImdOracle, live on Robinhood Chain. Briefs holds every IMD (seed pots, escrowed entry fees, creator earnings, the platform share): 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), or to stall a case so it can never settle. 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, gas (hearingGas and the 64/63 rule), and the text rules in BriefsText.check (the question must stay valid JSON and under 2,000 characters). docs/audit-internal-2026-10.md lists what our own reviews found and fixed; test/audit holds the PoCs

| | |
|---|---|
| Repository | https://github.com/vadiszzz/briefs-contracts.git |
| Commit | `7944a9d538e474e674b1b109041091824cc5d481` |
| Job | `715e3900-cecb-406e-ba02-6e58d5806d98` |
| Judged | 2026-10-09 02:25 UTC |
| Findings | 3 low · 3 info |

Four agents audited the code as it is at `7944a9d`, 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. Low: skipStalled resets the stall clock to zero, so the next brief's 6-hour wait cannot start until someone separately calls hear()

`src/Briefs.sol:628`

```
        c.stalledSince = 0; // the next brief gets its own wait
```

skipStalled only skips the head brief when _hearNext() has just stalled in the same call, i.e. the oracle has just been observed failing to open the NEXT brief too. _skip() then sets Case.stalledSince to 0. skipStalled itself can never restart the clock: before endsAt + STALL_GRACE it reverts TooEarly whenever stalledSince == 0 (line 490), so it never reaches _hearNext(). The clock only starts when some other call (hear(), fileBrief(), fulfill(), mistrial()) runs _hearNext() and fails. The documented rule ('allowed STALL_WAIT after the head first failed to open', 'the next brief gets its own clock') therefore becomes 'STALL_WAIT after the first hear() made after the previous skip'. With the keeper down and a dead oracle, every queued brief behind the first costs one extra transaction plus up to 6 more hours than documented, and if nobody calls hear() the docket waits until endsAt + 3 days. Liveness only: no funds are lost (every skipped brief is refunded in full). Fix: in _skip() (or in skipStalled() after _skip()) set stalledSince = block.timestamp when briefs remain on the docket (c.head < docketOf[caseId].length), since the oracle was observed failing at that moment; the price-skip path in _hearNext may keep the reset to 0 (the guard at line 504 already gives that brief its own wait). Merged from audit_flow; the specialist's proof fails on this code for the stated reason and is reproduced below in a self-contained form.

**Reproduction**

State: a case with endsAt 30 days away, a requester whose fee() answers but whose request() reverts from t0. alice files brief 1 at t0 (its hearing fails to open: stalledSince = t0); bob files brief 2 at t0. At t0 + 6h anyone calls skipStalled(c): brief 1 is Unheard and refunded, head = 1, and getCase(c).stalledSince == 0 although bob's brief failed to open in that same call. Expected: bob's wait runs from t0 + 6h, so skipStalled(c) at t0 + 12h skips it. Actual: skipStalled(c) at t0 + 12h reverts TooEarly; only after hear(c) sets stalledSince = t0 + 12h does skipStalled succeed, at t0 + 18h. Verified: test/scratch/StallClockProof.t.sol test_TheNextBriefsStallClockStartsAtTheSkip fails on this code with 'the clock of the next brief was not started: 0 != 1790821600'; the probe test_StallClockAfterSkip (same setup) confirms the TooEarly revert at t0 + 12h and the skip only at t0 + 18h after a hear().

**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 {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {Briefs} from "src/Briefs.sol";
import {BriefsText} from "src/BriefsText.sol";
import {BriefsJury} from "src/BriefsJury.sol";
import {IImdRequester} from "src/interfaces/IImdRequester.sol";

contract ProofToken is ERC20 {
    constructor() ERC20("IMD", "IMD") {
        _mint(msg.sender, 1_000_000 ether);
    }
}

/// A requester whose fee() answers but whose request() reverts once broken: the oracle is down.
contract ProofRequester is IImdRequester {
    IERC20 public imd;
    uint256 public fee_;
    uint256 public count;
    bool public broken;

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

    function setBroken(bool b) external { broken = b; }
    function fee() external view returns (uint256) { return fee_; }
    function answerSource() external pure returns (address) { return address(0); }

    function request(string calldata, address) external returns (bytes32) {
        require(!broken, "requester down");
        imd.transferFrom(msg.sender, address(this), fee_);
        return bytes32(uint256(keccak256(abi.encode(address(this), ++count))) << 128);
    }
}

/// After skipStalled hands back the head brief, the next brief's stall clock (Case.stalledSince) is left at 0 even
/// though the oracle failed to open that very brief in the same call (skipStalled only skips when _hearNext stalls).
/// skipStalled itself can never start the clock (it reverts TooEarly while stalledSince is 0 before endsAt +
/// STALL_GRACE), so a separate hear() call is needed before the next STALL_WAIT even begins.
contract StallClockProofTest is Test {
    ProofToken imd;
    ProofRequester requester;
    Briefs b;
    address creator = makeAddr("creator");
    address alice = makeAddr("alice");
    address bob = makeAddr("bob");

    function setUp() public {
        vm.warp(1_790_800_000);
        imd = new ProofToken();
        requester = new ProofRequester(IERC20(address(imd)), 0.5 ether);
        BriefsJury jury = new BriefsJury(
            BriefsJury.Oracle({signer: vm.addr(1), requester: IImdRequester(address(requester)), domain: bytes32(0), chainId: 1, hearingGas: 3_000_000}),
            address(this), address(0)
        );
        b = new Briefs(
            IERC20(address(imd)), new BriefsText(), jury, address(0xBEEF),
            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
            })
        );
        address[3] memory users = [creator, alice, bob];
        for (uint256 i; i < users.length; i++) {
            imd.transfer(users[i], 1_000 ether);
            vm.prank(users[i]);
            imd.approve(address(b), type(uint256).max);
        }
    }

    function test_TheNextBriefsStallClockStartsAtTheSkip() public {
        vm.prank(creator);
        uint256 c = b.openCase(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: 1, seed: 100 ether, fee: 2 ether,
            endsAt: uint64(block.timestamp + 30 days), minHold: 0, oracleId: 0
        }));
        requester.setBroken(true); // the oracle is down from here on
        uint256 t0 = block.timestamp;
        vm.prank(alice);
        b.fileBrief(c, "First brief"); // its hearing fails to open: stalledSince = t0
        vm.prank(bob);
        b.fileBrief(c, "Second brief"); // queued behind it
        assertEq(b.getCase(c).stalledSince, t0);

        vm.warp(t0 + 6 hours);
        b.skipStalled(c); // alice's brief is handed back; bob's failed to open in this very call
        assertEq(b.getCase(c).head, 1);
        // bob's own wait should run from the failure just observed, not from some later hear() call
        assertEq(b.getCase(c).stalledSince, t0 + 6 hours, "the clock of the next brief was not started");

        vm.warp(t0 + 12 hours);
        b.skipStalled(c); // on the current code: reverts TooEarly, stalledSince is still 0
        assertEq(b.getCase(c).head, 2);
    }
}
```

### 2. Low: raiseReserve retroactively lifts the jury price charged to briefs already in escrow, up to fee - 1 wei; the reserve recorded on a queued brief is never honoured

`src/Briefs.sol:649`

```
            if (price > c.reserve) {
```

A brief filed while a case reserves R for the jury records that reserve on the brief (Brief.oracleReserve, documented as 'queued: the case's reserve'), but _hearNext compares IMD's price only with the case's CURRENT reserve, which raiseReserve lifts to params.maxOracleFee (bounded: at most 5 IMD, and strictly below the case's fee, so up to fee - 1 wei). The owner raises maxOracleFee with setParams (no delay), and anyone, including the current leader, calls raiseReserve on a running case. Every brief already queued under the old reserve is then heard at the new price instead of being handed back unheard, and there is no way for a queued author to withdraw. An author who escrowed a 2 IMD fee expecting at most 0.9 IMD to go to the jury can have 1.99 IMD of it spent, with 0.01 IMD split on a verdict or 0.01 IMD refunded on a mistrial. This is a bound on an admin power rather than an outsider exploit: it needs the trusted owner to move maxOracleFee and IMD's live price to be above the old reserve, and the fifth review chose this behaviour deliberately (fix 1, 'a raise reaches briefs already queued'). It is reported because the README, the raiseReserve docstring ('anyone may lift the case's reserve') and the Brief struct comment never tell a queued author that the recorded reserve can be overridden after filing. Minimal fix that keeps the design: cap the price a queued brief may pay at the reserve it filed under (b.oracleReserve) unless the author re-files, or let a queued author withdraw an unheard brief (full refund) once the case's reserve has been raised above the one it filed under, or at minimum document the override and warn before filing. Merged from audit_math.

**Reproduction**

Launch params (minFee 1 IMD, maxOracleFee 0.9 IMD), IMD's price 0.5. creator opens a case with fee 2 IMD. alice files (heard at 0.5), bob files (queued; getBrief(bob).oracleReserve == 0.9e18). IMD's price moves to 1.99 IMD. Owner calls setParams with maxOracleFee 1.99e18 and minFee 2e18 (passes _setParams). Anyone calls raiseReserve(case): getCase(case).reserve == 1.99e18. After alice's hearing ends (mistrial at heardAt + 4 min + 2 min + 1 s), _hearNext opens bob's hearing because 1.99e18 <= c.reserve: getBrief(bob).status == Hearing and getBrief(bob).oracleReserve == 1.99e18. On bob's mistrial bob receives 0.01 IMD back. Expected (per the brief's recorded reserve of 0.9 IMD and the behaviour before the fifth review): bob's brief is skipped Unheard with the whole 2 IMD returned, or heard at no more than 0.9 IMD. Actual: 1.99 IMD of bob's escrow is spent on the jury. Verified in test/scratch/Judge.t.sol test_QueuedBriefChargedAboveRecordedReserve (passes on this code, showing the behaviour).

### 3. Low: BriefsText.check lets << or >> through when the only character between them is a combining mark outside the five generic blocks

`src/BriefsText.sol:220`

```
        return (cp >= 0x300 && cp <= 0x36f) || (cp >= 0x1ab0 && cp <= 0x1aff) || (cp >= 0x1dc0 && cp <= 0x1dff)
```

The look-alike rule for the «» the question quotes with (third review, fix 5) is documented as rejecting a pair of < or > 'with only combining marks or thin, wide or no-break spaces between them'. check() implements 'combining mark' through _isFiller(), which only names the five generic Unicode combining blocks (U+0300-036F, 1AB0-1AFF, 1DC0-1DFF, 20D0-20FF, FE20-FE2F). Every script-specific nonspacing mark (general category Mn) outside those blocks is treated as a visible character: it updates `seen`, so the second < or > no longer equals `seen` and the text passes. Examples that pass today: U+0483 and U+0488 (Cyrillic combining titlo / hundred thousands sign), U+05B0 (Hebrew sheva), U+064B (Arabic fathatan), U+094D (Devanagari virama), U+0E31 and U+0E34 (Thai), U+0F71 (Tibetan), U+3099 (kana voiced mark), U+A670 (Cyrillic combining ten millions sign), U+1D165 and U+1D167 (musical combining stem / tremolo). Each renders as << or >> with a barely visible mark attached to the first bracket, exactly the shape the rule exists to keep out of a brief, task or standard, so a filing can still visually fake the end of a quoted answer («…» frame) to the jury. No funds impact and no JSON impact (the characters are valid JSON string content); same class and severity as the project's own fixes 7 (first review) and 5 (third review). Fix: in _isFiller treat every nonspacing/enclosing mark as a filler (add the script-specific Mn/Me ranges, or compare the next < or > with the last character whose general category is not M), and add a regression test for U+0483, U+064B, U+3099 and U+1D165 next to test_Fixed_F2b_LookalikeDelimitersAreRejected. Merged from audit_permissions.

**Reproduction**

text.check(bytes.concat("a<", hex"d283", "<b"), 1, 500, 500) (U+0483 between two <): expected BadText(), actual: returns (accepted). Also accepted: hex d98b (U+064B), hex e38299 (U+3099), hex f09d85a5 (U+1D165), and per the same path hex d288, d6b0, e0a58d, e0b8b1, e0b8b4, e0bdb1, ea99b0, f09d85a7. For comparison text.check(bytes.concat("a<", hex"cc81", "<b"), 1, 500, 500) (U+0301) reverts BadText as documented, and text.check("a<<b", 1, 500, 500) reverts BadText. Through Briefs: fileBrief(caseId, string(bytes.concat("lol<", hex"d283", "< A challenger's answer: >", hex"d283", ">ok"))) is filed and reaches the jury's question verbatim. Verified in test/scratch/Judge.t.sol test_ScriptSpecificMarkBetweenAngles (passes on this code, showing the behaviour).

### 4. Info: _checkTreasury refuses Briefs, its jury and the live requester but not the IMD token or BriefsText, so case fees and the platform share can be sent to a contract that can never release them

`src/Briefs.sol:764`

```
        if (t == address(0) || t == address(this) || t == address(jury)) revert BadParams();
        if (t == address(jury.get(jury.latest()).requester)) revert BadParams();
```

The third-review fix 3 added _checkTreasury so an owner typo cannot strand every case fee and the platform share behind an address with no liability (Briefs itself, its jury, the live setup's requester). Two other addresses of the same deployment are of the same class and are still accepted: the IMD token (imd) and the text contract (text). Both are immutables known to the constructor and to setTreasury, both have code, and IMD transferred to either is irrecoverable (the OFT has no sweep of its own balance; BriefsText is stateless). With treasury == address(imd), every openCase forwards caseFee (2 IMD at launch) straight to the token contract with safeTransferFrom, and withdrawPlatform moves the whole platform share there. Only the owner can cause it and setTreasury fixes it in one transaction, so this is an owner-mistake guard gap (defence in depth), not a bypass. Fix: also revert BadParams when t == address(imd) or t == address(text) (and optionally when t == address(holderToken)). Merged from audit_flow and audit_permissions (same root cause and fix).

**Reproduction**

briefs.setTreasury(address(briefs.imd())): expected BadParams(), actual: accepted, TreasurySet(imd) and treasury() == imd. briefs.setTreasury(address(briefs.text())): expected BadParams(), actual: accepted. Then creator.openCase(... seed 100 IMD, fee 2 IMD ...) with caseFee 2 IMD: imd.balanceOf(address(imd)) grows by 2e18 with no function on either side able to move it again; withdrawPlatform() likewise sends platformOwed to the token contract. For comparison setTreasury(address(briefs)), setTreasury(address(jury)) and setTreasury(address(requester)) all revert BadParams. Verified in test/scratch/Judge.t.sol test_TreasuryMayBeTokenOrText (passes on this code, showing the behaviour).

### 5. Info: BriefsText.check counts zero-width combining marks toward the minimum length, so a brief, task or standard can consist only of invisible marks

`src/BriefsText.sol:183`

```
        if (chars < minLen || chars > maxLen) revert BadText();
```

The text rules reject zero-width and invisible format characters, whitespace at either end and runs of whitespace, and treat combining marks (U+0300-036F, U+1AB0-1AFF, U+1DC0-1DFF, U+20D0-20FF, U+FE20-FE2F) as 'fillers' only for the << / >> look-alike check. A text made solely of nonspacing combining marks passes: each mark is a code point with zero advance width, so a brief of one U+0301 or a task of ten U+0301 satisfies MIN_BRIEF / MIN_TASK while rendering as nothing but a stray accent on the opening « of the question. No funds impact (the author pays a full fee for a brief the jury will see as empty, and the leader keeps the lead), and no JSON or hash impact, but it contradicts the rule's stated intent ('no ... zero-width characters') and lets a case be opened whose task and standard are blank to a human reader while the on-chain minimums report them as 10 and 5 characters. Minimal fix: count only non-filler code points toward minLen (reuse _isFiller), or reject a text whose first code point is a combining mark. Merged from audit_math.

**Reproduction**

text.check(hex"cc81", 1, 500, 500) (one U+0301) and text.check(hex"cc81cc81cc81cc81cc81cc81cc81cc81cc81cc81", 10, 240, 240) (ten U+0301, the MIN_TASK path) both return without reverting. Expected: BadText, since the text has no visible character and starts with a zero-width mark. Actual: accepted, so openCase with such a task and fileBrief with such a brief succeed. Verified in test/scratch/Judge.t.sol test_CombiningMarksOnlyPass (passes on this code, showing the behaviour).

### 6. Info: Dead local `Brief storage b` in Briefs._hearNext (compiler warning 2072)

`src/Briefs.sol:645`

```
            Brief storage b = briefs[briefId];
```

Inside the docket loop of _hearNext, `b` is declared and never read: the loop quotes the price, skips or opens the hearing by `briefId` alone, and openHearing re-reads the brief itself. solc 0.8.30 reports it as 'Unused local variable' on every build. No effect on behaviour or funds (the optimizer removes the load), but it is the only warning the project's own sources emit and it hides any future real one; drop the line. Merged from audit_economics.

**Reproduction**

`forge build` on the pinned tree prints: Warning (2072): Unused local variable. --> src/Briefs.sol:645:13: `Brief storage b = briefs[briefId];`. Expected: a clean build of src/. Actual: the warning on every compile (reproduced on this tree with forge 1.8.5 / solc 0.8.30). Removing the line builds clean and the 137 tests still pass.

---

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