# Audit report

> Audit the contracts in vadiszzz/briefs-contracts as they are.

| | |
|---|---|
| Repository | https://github.com/vadiszzz/briefs-contracts.git |
| Commit | `8d014e4ea49f25e36ea3c5ebd591af1b90ec992a` |
| Job | `a5960b3e-4ac1-4e65-8fc2-087178af730a` |
| Judged | 2026-10-13 09:45 UTC |
| Findings | 3 low · 2 info |

Four agents audited the code as it is at `8d014e4`, 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: refundRelay's entry-fee floor is zero for a case id that is not open yet, so a signed brief for the next case can be voided for 1 wei

`src/BriefsJury.sol:318`

```
        if (amount == 0 || (relayed[h] == 0 && amount < _feeOf(caseId))) revert NothingToRefund();
```

Merged from three specialists (audit_math, audit_economics finding 1, audit_flow finding 2), all the same mechanism. The tenth review's fix makes the first refund of a signed message pay at least the case's entry fee, which _feeOf reads from word 15 of Briefs.getCase(caseId). For a caseId that has no case yet (caseId > caseCount, or 0) getCase returns an all-zero Case struct: the call succeeds and returns 32 words, so the `r.length < 16 * 32` guard does not fire, and the fee read is 0. `amount < 0` is never true, so any amount >= 1 wei marks the message refunded (relayed[h] = 2) and it can never be filed. Case ids are sequential (`id = ++caseCount`), so the id the next openCase will take is known in advance; a brief signed for it (a relayer queuing a filing before the opening transaction lands, or a creator's launch flow) can be cancelled for 1 wei by anyone holding the signature, instead of the entry fee the rule intends. Impact is bounded to messages signed before their case exists; the author receives 1 wei and the on-chain `refunded` record then says 1 wei. Fix (minimal, keeps the design): in _feeOf revert NothingToRefund when the fee read is 0 (every real case has fee >= minFee > 0), or when word 5 (status) is 0, so a refund needs an existing case; alternatively compare caseId with the court's caseCount.

**Reproduction**

Verified with test/scratch/Judge.t.sol::test_RefundFloorIsZeroForACaseNotYetOpened on the current tree (FEE = 5 ether, minFee 1 ether). State: caseCount = 0, next = 1. Input: author dan signs jury.relayMessage(1, B1, dan, deadline) with personal_sign; carol (anyone) calls jury.refundRelay(1, B1, dan, deadline, sig, 1). Expected per the documented rule ('the first refund of a message must pay at least the case's entry fee'): revert NothingToRefund. Actual: succeeds, jury.relayed(h) == 2, jury.refunded(h) == 1. Then creator opens a case, which gets id 1 with fee 5 ether, and alice's b.fileBriefFor(1, B1, dan, deadline, sig) reverts BadSignature forever. Control in the same test: for the open case, refundRelay(..., 1) reverts NothingToRefund as intended.

### 2. Low: refundRelay lets anyone holding a relayed signature cancel the brief for the entry fee, while filing it is court-only

`src/BriefsJury.sol:312`

```
    function refundRelay(uint256 caseId, string calldata words, address author, uint256 deadline, bytes calldata signature, uint256 amount)
        external
    {
```

From audit_permissions; reproduced. relayed[h] is written by two functions with different guards: useRelay requires msg.sender == court (only Briefs.fileBriefFor sets it to 1), while refundRelay has no caller check: any address that can present the author's signature sets it to 2 by paying at least the case's fee to the author, after which _relayHash rejects the message for filing forever. The signature is not secret in practice: it is in the relayer's fileBriefFor calldata, so it is public the moment a relayer's attempt reverts (OracleTooExpensive, NotHolder, an allowance error) or is seen pending. The party with an incentive is the standing leader near the deadline: for the fee (5 IMD in the example) it removes a challenger from the docket to protect a pot that can be far larger; the author is made whole for the fee but loses the entry and the chance at the pot, and cannot re-sign in time once entries close. The NatSpec ('Anyone holding the signature may call it') and test_Relay_ARefundedSignatureCanNeverBeFiled show this openness is deliberate, so this is a trust-model gap to decide on, not a permission bypass: the fee floor prices the cancellation but does not bind it to a party the author chose. Minimal fix that keeps the design: require msg.sender == author or a relayer named in the signed message (add the relayer's address to relayMessage), so useRelay and refundRelay agree on who may spend relayed[h]; or let a relayer refund only with a second author signature.

**Reproduction**

Verified with test/scratch/Judge.t.sol::test_AStrangerHoldingTheSignatureCancelsTheBrief on the current tree. State: open case c (fee 5 IMD). Author dan signs relayMessage(c, B1, dan, deadline). Input: `leader` (neither dan nor the relayer), holding the signature and 5 IMD approved to the jury, calls jury.refundRelay(c, B1, dan, deadline, sig, 5 ether). Actual: succeeds, 5 IMD moves from leader to dan, relayed[h] == 2, and the relayer alice's b.fileBriefFor(c, B1, dan, deadline, sig) reverts BadSignature; waiting(c) stays 0, dan's brief is never heard. Expected under the filing guard's model: only the court (filing) or the author/their relayer (refund) can decide the message's fate.

### 3. Low: Overtime is not opened for a brief filed inside the window when an earlier queued leader already pushed the deadline

`src/Briefs.sol:770`

```
        if (t < end || filedAt + c.overtime > end) {
```

From audit_flow finding 1; reproduced. _newLeader decides whether a late win opens overtime by comparing filedAt + overtime with `end` = c.endsAt as it stands at verdict time, which an earlier leader in the FIFO docket may already have pushed forward. The README and the NatSpec promise that a brief 'filed in the last `overtime` seconds before the deadline, however long the jury took' pushes the deadline (the tenth review's finding 1 fix). When a brief ahead of it in the docket wins first and moves endsAt by `overtime`, the later brief's filing time is no longer within `overtime` of the new endsAt even though it was within `overtime` of the deadline it was filed against. If its verdict then lands after the pushed deadline (a late delivery, or two mistrial briefs between them, each costing answerTimeout + MISTRIAL_GRACE), it takes the lead without opening overtime, and with nothing queued behind it the case settles in the same call, so nobody can answer it: exactly the outcome the tenth review's fix was meant to prevent, now reachable whenever more than one lead changes in a window. No direct profit path was found (the jury answers junk briefs quickly, so an attacker cannot force the needed delay without mistrials), hence low. Fix: measure the window against the deadline the brief was filed under (record endsAt at filing, e.g. in the Brief's unused bytes of its second slot, or keep a per-case 'window opened at' marker), or state on the site that the window is measured against the current deadline.

**Reproduction**

Verified with test/scratch/Judge.t.sol::test_OvertimeLostWhenAnEarlierLeaderMovedTheDeadline and ::test_OvertimeLostBehindMistrials on the current tree. Params: overtime 10 min, answerTimeout 4 min. Case ends at E. Bob files at E-9min (heard at once), Alice files at E-9min+30s (queued). Bob wins at E-8min: endsAt becomes end1 = E+2min and Alice's hearing opens. Variant A: Alice's attestation (issuedAt = heardAt+30, inside her window) is delivered at end1+1 with nobody having called mistrial. Variant B: two junk briefs between Bob and Alice are mistrialed, so Alice's verdict lands at about E+5min. Expected per README/NatSpec: Alice was filed 8.5 min before the deadline she filed against, inside the 10-min window, so her win pushes endsAt to now + 10 min and the case stays Open. Actual in both variants: Alice becomes precedent, getCase(c).endsAt stays end1, no Overtime event, status == Settled and winner == alice in the same call; getBrief(aliceId).filedAt + 10 min <= end1.

### 4. Info: A ready oracle proposal lets anyone make a creator's pending openCase revert with WrongOracle

`src/Briefs.sol:624`

```
        if (jury.latest() != x.oracleId) revert WrongOracle();
```

From audit_flow finding 3; reproduced. openCase pins the setup the creator saw (x.oracleId must equal jury.latest()). BriefsJury.applyOracle is permissionless once readyAt has passed and stays callable for ORACLE_WINDOW (7 days). During that window any address can call applyOracle ahead of a creator's openCase, which then reverts WrongOracle (the caseFee transferFrom is rolled back with it). The grief is one-shot per proposal and the creator can resubmit with the new id, so this is a usability note, not a loss; the pinning itself is the intended protection. Suggested handling: the site refreshes oracleId on a WrongOracle revert, and the owner applies a ready proposal promptly to keep the window short.

**Reproduction**

Verified with test/scratch/Judge.t.sol::test_ApplyOracleMakesAPendingOpenCaseRevert. Owner calls jury.proposeOracle(o); warp 7 days. Creator prepares CaseInput with oracleId = jury.latest() = 0. carol (anyone) calls jury.applyOracle(): latest() becomes 1. Creator's openCase reverts WrongOracle. Expected: the case opens with the setup the creator agreed to, or the site tells them it changed. Actual: revert; a retry with oracleId = 1 opens the case.

### 5. Info: The time-in-the-lead fuzz never queues a brief behind a running hearing, so from-filing crediting with queued briefs is covered only by hand-written cases

`test/Briefs.t.sol:2099`

```
                if (b.getCase(c).hearing != id) break;
```

From audit_economics finding 2; confirmed by reading the fuzz. testFuzz_Reign_TheBooksAlwaysBalance files one brief, then judges it (or mistrials it) before the next iteration, so at every filing no hearing is running, the filed brief is heard at once, and the `hearing != id` break can only fire on a stall (which the mock requester never produces). No seed therefore exercises the paths that make v2's accounting subtle: a challenger queued behind a hearing whose filedAt precedes the previous leader's verdict (reignOf credited from filing in _newLeader), several queued briefs crossing the first deadline, a brief filed before reignEnd and heard after an overtime push, or an overtime-filed brief heard after an extended endsAt. The end-of-run assertions ('seconds add up', pool formula, 'solvent to the wei') are only ever evaluated on one-hearing-at-a-time histories. The hand-written tests cover some of these, and the specialist reports that the books balance in their own probes, so this is a coverage gap, not a defect found. Fix: let the fuzz file 1-4 briefs per step before judging them in docket order, keeping the existing assertions.

**Reproduction**

Read test/Briefs.t.sol lines 2090-2112: each loop iteration calls _file once and, in the same iteration, either b.mistrial(c) or _verdictAt(id, ...) on that brief, so c.hearing == 0 at the next _file and the new brief opens its own hearing (hearing == id). Expected: random seeds reach waiting(c) > 1. Actual: waiting(c) is always 1 immediately after a filing, for every seed; the break at line 2099 never triggers in a non-stalled run.

---

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