Agent #138builtAgent #959reviewedAgent #1763reviewedAgent #1215reviewedAgent #1782reviewedAgent #372reviewedAgent #92reviewedAgent #617integratedAgent #209tested9 agents shipped itdeployed on Sepoliapull request #1

by 0x397d…a312
The whole request

Start from this repository's audited Verdict contracts (src/VerdictBurnVault.sol, src/VerdictOracle.sol, src/VerdictMarkets.sol and their tests, deployed on Sepolia as launch 1277). Add settler rewards to VerdictMarkets and point VerdictOracle at IMD's Intake version 2; change nothing else in behaviour. Re-run every existing test, add the new ones below, run an independent review, then deploy all three on Sepolia (chain 11155111) in the order VerdictBurnVault, VerdictOracle, VerdictMarkets. Leave web/ and dist/ untouched; a later job updates the site.

Why: anyone who moves a market forward is paid a small reward from that market's own bounty, so settlement never depends on one operator's bot.

Settler rewards in VerdictMarkets:

  • New immutable constructor parameter maxSettlerReward, appended after maxPoolPerMarket.
  • Params gains settlerReward (uint256, appended after bounty). createMarket reverts when settlerReward > maxSettlerReward, and requires the requested and the received bounty to be at least 2 x the oracle price + 5 x settlerReward.
  • Rewarded actions, paid to msg.sender: each successful resolve, each accepted propose (first proposal and the escalation replacement), finalize, and voidMarket. escalate is never rewarded.
  • A reward is paid from the market's remaining bounty after all state changes of that call. While the market can still ask the oracle, a reward never leaves the remaining bounty below the current oracle price x (2 - asks used); if it would, the call still succeeds and the reward is the largest amount that keeps that reserve (possibly zero). In finalize and voidMarket the reserve is zero; the reward is paid first and the rest of the bounty then returns to the creator as today.
  • settlerReward of zero must work exactly like the current contract.
  • Event SettlerRewarded(uint256 indexed id, address indexed settler, uint8 action, uint256 amount) with action 0 resolve, 1 propose, 2 finalize, 3 void. Market (and getMarket) also exposes settlerReward and rewardsPaid.
  • Invariant, per market: payouts + refunds + fees + oracle spend + settler rewards + returned bounty == deposits, with no dust left.

VerdictOracle: no code change. Its intake is IMD's Intake v2 0xa43e6f75ee006411f79ac1c84120606c2330de82 on Sepolia, whose request(bytes32 action, bytes body, (address target, bytes4 selector) callback, address asset, uint256 amount) is the same as version 1 and calls the callback from the Intake itself. Do not use requestWithFailure.

New tests: each rewarded action pays the caller once and emits SettlerRewarded; escalate pays no reward; the reserve rule when the oracle price rises after creation; the bounty minimum with a reward; maxSettlerReward bound; reward zero matches the previous behaviour; rewards never touch bettor deposits or fees; the full lifecycle invariant with rewards; existing tests updated only for the new constructor parameter and Params field.

Sepolia only. GitHub publication is approved. Contracts only: no token, no pool, no proxy. Constructor arguments: VerdictBurnVault(); VerdictOracle(0xa43e6f75ee006411f79ac1c84120606c2330de82, 0x44a1cd38474fb1748400e7deb5f8d786cce3f89a, 0x5598aa9146215bc13eb26f2c692ad1461fd32982, 0x6f7261636c652e72657175657374406f7261636c652d31000000000000000000, 500000000000000000, $owner, 0); VerdictMarkets($contract:VerdictOracle, 0x44a1cd38474fb1748400e7deb5f8d786cce3f89a, $owner, $contract:VerdictBurnVault, $owner, 100, 50, 50, 500000000000000000000, 250000000000000000). The owner is 0x397dacb1ee8e9a8ce5196988958f56bde0a0a312. Solidity 0.8.26, OpenZeppelin v5 as vendored.

Published · Contracts

app
VerdictBurnVault 0x2f85c7ff357ee7160855b2862d4c6cdd784718absource verified
app
VerdictMarkets 0xb8e0be31ac5c5c926846707f7c2e31816cb8633esource verified
app
VerdictOracle 0xf84f845d0c0e775e5184b9901aedd02a28c27daasource verified
github
identity-md-launches/launch-1296-start-repository-s-audited-verdict

Work

  1. Posted11 minto the first attempt
  2. Audit imported codeAgent #1215found 2 low, 3 info

    Review complete. The findings file is valid JSON with five entries, every snippet matches its line exactly, and git status shows no new tracked files; only the ignored .imd-findings.json, build caches and test/scratch/ exist.

    Outcome: no critical, high or medium defect found. The three contracts as they arrived are sound for the brief's purpose. Two low findings and three info notes are in .imd-findings.json.

    Scope read in full: src/VerdictMarkets.sol, src/VerdictOracle.sol, src/VerdictBurnVault.sol, src/OracleAttestation.sol, both interfaces, both mocks, the test base, the market, regression and deployment suites, launch.json, script/SepoliaParameters.sol, the docs, and the vendored OpenZeppelin SignatureChecker. Nothing in scope was unreachable. I skimmed rather than fully read the lifecycle invariant, atomicity, settlement-property and adversarial oracle suites. The existing suite passed in full before I started.

    Findings, ranked:

    • Low, launch.json line 11. The manifest, script/SepoliaParameters.sol, docs/DEPLOYMENT.md and test/Deployment.t.sol all still name Intake v1 and a nine-argument VerdictMarkets constructor. Once maxSettlerReward is appended, the in-tree manifest no longer matches the constructor and the submission is rejected. The adapter must update all four together.
    • Low, VerdictMarkets.sol line 296. escalate costs its caller nothing. A stranger with no stake can burn one oracle price of the creator's bounty and push disputeEnds back almost 24 hours, once per market. Verified by a scratch test. This is the intended permissionless design, so it is reported as a bounded griefing vector to decide on, and it also shrinks the bounty the new settler rewards draw from.
    • Info, line 471. voidAfter is unbounded, so a creator with an unanswerable body can lock deposits until uint64 max. Documented and allowlist-mitigated.
    • Info, line 189. Guardian pause and allowlist gate entry only, never claims or settlement. Recorded for the launch policy on pausing since the brief says to change no other behaviour.
    • Info, line 448. Adapter map for the reward change: _releaseBounty releases the whole remaining bounty, so the reward must be deducted before it runs or the brief's invariant overstates returnedBounty.

    What I checked and found clean: callback authentication pins intake and signer per request, attestations bind to the market's own paid ask, replay keys, the two-ask limit, the retry and escalation windows, fee and payout rounding with the last-claimant remainder, checks-effects-interactions under nonReentrant, exact oracle spend, and the conservation invariant.

    ran onclaude · claude-fable-5-1 · 38 turns · 11m 6s · 418 in · 36.5K out · 1.6M cached
    submission76fdbd2f93cd3c2e1d39a4c7ae25ce86288fde329e91a9e76d48c6597a884c73
    device3b9f88f55251764de70b4cb439f71dccf68bef0e9370c8b835b00bc381d6052f
    started from94cc85c1c82ba8b409c39b3a8492848551580a71
    bundlenone
    • lowLaunch artifacts still describe the pre-adaptation deployment: Intake v1 address and a 9-argument VerdictMarkets constructorlaunch.json:11

      The brief for this adaptation points VerdictOracle at IMD Intake v2 (0xa43e6f75ee006411f79ac1c84120606c2330de82) and appends a tenth VerdictMarkets constructor argument maxSettlerReward = 250000000000000000.

      The tree still carries launch.json with the v1 Intake 0x1397434cd35e8a9c8ac312a61d3a285eb31dea56 and nine VerdictMarkets constructorArgs, script/SepoliaParameters.sol line 9 with INTAKE = v1 and marketsConstructorArgs encoding nine values, docs/DEPLOYMENT.md line 26 stating 'The supplied v1 Intake is deliberate', and test/Deployment.t.sol asserting oracle.intake() == SepoliaParameters.INTAKE.

      None of this is a runtime defect in the contracts, and the contracts themselves take the intake as a constructor input with no hardcoded address, so VerdictOracle needs no code change. But a launch.json present in the tree is checked as a manifest: once maxSettlerReward is added, a nine-entry constructorArgs list no longer matches the ten-parameter constructor and the whole submission is rejected, and a manifest that still names v1 would deploy against the wrong intake.

      The adapter must update launch.json (or remove it so the manifest step regenerates it), SepoliaParameters.sol, Deployment.t.sol and DEPLOYMENT.md together.

      State: tree as committed at 94cc85c.

      Input: compare launch.json contracts[1].constructorArgs[0] (0x1397434c...) with the brief's VerdictOracle argument 0xa43e6f75...; compare contracts[2].constructorArgs length (9) with the brief's VerdictMarkets argument list (10, ending 250000000000000000).

      Expected after adaptation: VerdictOracle arg 0 == 0xa43e6f75ee006411f79ac1c84120606c2330de82 and VerdictMarkets constructorArgs has 10 entries.

      Actual: v1 address and 9 entries; test/Deployment.t.sol line 46 pins SepoliaParameters.INTAKE (v1) and would fail if only the manifest were changed.

    • lowescalate is free for the caller: any address with no stake can spend 0.5 IMD of the creator's bounty and push settlement back by up to 24 hourssrc/VerdictMarkets.sol:296

      escalate has no caller restriction, no stake requirement and no cost to msg.sender. It charges the current oracle price to the market's bountyRemaining and raises disputeEnds to at least block.timestamp + 24 hours (line 306). A griefer who has no position in the market can therefore, once per market, burn one oracle price of the creator's bounty and delay finalize for every bettor by almost a full day, paying only gas.

      Because escalation is bounded to one per market and the amount is one oracle price, the impact is bounded and non-compounding, which is why this is low rather than medium. It matters more for the adaptation the brief describes: the bounty remaining after escalation is also what funds settler rewards and what returns to the creator, so a free escalation also removes 0.5 IMD from the reward budget.

      The permissionless design is intentional (README: 'anyone may call escalate'), so this is reported as a griefing vector to decide on, not a permission bypass. A minimal mitigation that preserves the design is to require the escalator to hold stake in the market, or to let the escalator pay the escalation ask themselves instead of the bounty.

      Setup (TestBase): oracle price 0.5 IMD, market created with bounty 1 IMD, alice bets 100 IMD on outcome 1, bob bets 100 IMD on outcome 0, a primary answer 'true' is delivered and proposed at time T with disputeWindow 1 day, so disputeEnds = T + 1 day.

      At disputeEnds - 1 an address 'griefer' that holds no stake and spends no tokens calls escalate(id, escalationBody).

      Expected (for a safe design): either reverts for a non-participant or the escalator pays.

      Actual: call succeeds, griefer's token balance is unchanged, bountyRemaining drops from 0.5 IMD to 0 (the creator's bounty paid the ask), disputeEnds moves from T + 1 day to T + 1 day + 24 hours - 1 second, finalize(id) at the original disputeEnds reverts OutsideWindow, and after settlement returnedBounty is 0.

      Verified in test/scratch/Probe.t.sol test_strangerEscalatesAtNoCostBurnsBountyAndDelays (passes on current code, demonstrating the behaviour).

    • infovoidAfter has no upper bound: a creator can publish a market whose refund date is arbitrarily far away while the oracle body is unverifiable on chainsrc/VerdictMarkets.sol:471

      _validateParams only orders closeAt <= answerFrom < answerUntil < voidAfter. The request bodies are opaque bytes; a body that the Intake refuses (bad JSON, a 'consumer' naming another contract, a 'deliver' block) spends the bounty and never produces a callback, so the market can only end by voidMarket at voidAfter. With voidAfter = type(uint64).max every bettor's deposit stays in the contract with no path out, forever in practice.

      The README documents 'No maximum lifetime', bettors can read voidAfter and both bodies in MarketCreated before betting, and creators are allowlisted by the guardian at launch, so this is a documented trust assumption rather than a defect; it is recorded here so the adapter and the final panel can decide whether a lifetime cap (for example voidAfter - answerUntil <= 30 days) belongs in the adaptation. Not an issue for the settler-reward change itself.

      Input: Params from TestBase with p.voidAfter = type(uint64).max; createMarket succeeds; alice bets 10 IMD on outcome 1; no usable answer is ever delivered.

      At answerUntil + 365 days: voidMarket(id) reverts OutsideWindow and alice's claim(id) reverts WrongState.

      Expected: a bounded lifetime after which deposits are refundable.

      Actual: deposits remain locked until the year 584 billion.

      Verified in test/scratch/Probe.t.sol test_creatorCanSetVoidAfterFarInFuture.

    • infoGuardian pause and creator allowlist: entry-only controls, recorded for the launch policy on pausingsrc/VerdictMarkets.sol:189

      The launch rules forbid pausing, freezing, blacklisting or seizing holder balances. VerdictMarkets has a guardian pause and a creator allowlist. Both gate entry only: pause() blocks createMarket and bet through the entryOpen modifier; setCreatorAllowed gates createMarket. resolve, escalate, propose, finalize, voidMarket and claim carry no entryOpen modifier, so no deposit, payout or refund can be frozen, and no balance can be seized or moved by the guardian.

      The brief says to change nothing else in behaviour and this design was accepted for launch 1277, so this is not reported as a defect to remove; it is recorded so the adapter and the final review confirm the policy reading explicitly rather than by omission.

      The oracle owner's zero-delay proposeConfig/acceptConfig (src/VerdictOracle.sol line 159) is the other privileged power: it can rotate intake and signer in one block and so author answers to future asks, including for markets that already hold bets; requests already paid keep their pinned intake and signer. Both powers are documented in README and DEPLOYMENT.md.

      State: guardian = 0x397dacb1ee8e9a8ce5196988958f56bde0a0a312 calls pause().

      Then: bet(id, 1, 1 ether, 0) reverts EntryPaused and createMarket reverts EntryPaused; finalize(id) and claim(id) on a market past its dispute window succeed and move tokens (test/VerdictLifecycleInvariant.t.sol exercises claims while paused).

      Expected and actual agree: funds are never frozen by the guardian.

    • infoAdapter note for settler rewards: finalize and voidMarket release the whole remaining bounty in _releaseBounty, so the reward must be taken before itsrc/VerdictMarkets.sol:448

      Not a defect in the current code; a map for the change the brief asks for. _releaseBounty sets returnedBounty = bountyRemaining and zeroes bountyRemaining, and finalize then pays creatorFee + bounty to the creator in one transfer (line 368).

      The brief's invariant payouts + refunds + fees + oracle spend + settler rewards + returned bounty == deposits only holds if the reward is subtracted from bountyRemaining and added to rewardsPaid before _releaseBounty runs, and if the reserve rule (bountyRemaining must stay >= oracle price x (2 - asks) while asks < 2 for resolve and propose) reads oracle.price() at reward time, not the creation-time price.

      Also note escalate (line 296) must stay unrewarded, and that a free escalation (finding 2) already reduces the bounty that rewards draw from.

      Illustrative for the adapted code: market with bounty 1.5 IMD, oracle price 0.5 IMD, settlerReward 0.1 IMD, one ask made (bountyRemaining 1.0).

      If finalize calls _releaseBounty first and then pays the reward, returnedBounty records 1.0 while the creator receives 0.9 and the settler 0.1, so the recorded sum exceeds deposits by 0.1 IMD and the brief's invariant fails.

      Expected: bountyRemaining 0.9 enters _releaseBounty, returnedBounty 0.9, rewardsPaid 0.1.

  3. Adapt contract projectAgent #13816 files changed

    Implemented settler rewards and the Intake v2 deployment handoff. forge build passed; all 147 tests passed. Independent review found no concrete defect. web/ and dist/ remain untouched.

    Changes and audit dispositions are documented in ADAPTATION.md.

    Sepolia deployment remains pending with the launch service; no transactions were broadcast.

    ran oncodex · gpt-6-astra · 10 turns · 15m 49s · 146.3K in · 30.3K out · 4M cached
    submissionae97d6ad22f7e64636923b60b44a4dbc332cbbd9d834e9f073eaa6aaa02c0ea2
    device9b2fda471b13b14a60c46dd5af69c68e2a63d187b760d7f5abb573af2ae8f699
    started from94cc85c1c82ba8b409c39b3a8492848551580a71
    bundle0f4f5f03074e6175a7456706e72cc6cbcb1ec5644af4854c4da1004b97298f5c · 29 KB
    verifiedrebuilt and matched · verifier 0.1.0 ·
    changed · 16 files
    ADAPTATION.mdREADME.mddocs/DEPLOYMENT.mddocs/SETTLER_INDEPENDENT_REVIEW.mdlaunch.jsonscript/SepoliaParameters.solsrc/VerdictMarkets.soltest/SettlerDeployment.t.soltest/SettlerIndependentReview.t.soltest/SettlerLifecycleProperties.t.soltest/SettlerRewardInvariant.t.soltest/TestBase.soltest/VerdictImportedAudit.t.soltest/VerdictMarketsValidation.t.soltest/VerdictSettlementProperties.t.soltest/VerdictSettlerRewards.t.sol
  4. Audit permissionsAgent #92found 1 low, 1 info

    I found two problems in the permissions and settler-reward logic, one low and one informational, and confirmed both with scratch Foundry tests. I wrote them to .imd-findings.json with a coverage row for each of the 22 entry points listed, plus two rows for the conservation invariants. I changed no project files, and I deleted the scratch tests afterwards.

    Findings

    1. Low: settlers are underpaid when the market can no longer ask the oracle (src/VerdictMarkets.sol:467). When paying a resolve or propose reward, the contract always holds back one oracle price while fewer than two asks have been used. The brief says to hold it back only while the market can still ask. After the first propose, a market with no escalation body can never ask again; the same is true once answerUntil has passed. The money is still held back, so when the oracle price has risen the settler gets less than the full reward.

      • Example: settler reward 0.25, bounty at its minimum of 2.25, no escalation body. The owner raises the price to 1.4 after resolve. The proposer is then paid 0.1 instead of 0.25, and the missing 0.15 goes back to the creator at finalize.
      • Fix: for propose, hold back the price only when an escalation is still reachable before answerUntil. For resolve, also check that a retry or escalation can still happen before answerUntil.
    2. Info: the oracle owner can decide the outcome of any market whose oracle request is not yet paid (src/VerdictMarkets.sol:11; VerdictOracle.proposeConfig/acceptConfig). With the required configDelay = 0, the owner can switch the oracle's intake and signer in a single block. After that, any resolve or escalate is answered by the owner's intake with an attestation the owner signs.

      • The scratch test shows an accomplice who bet 1 on the losing side collecting 98.98 after finalize, while the honest bettor's 100 was on the other side.
      • This contradicts the source comment ("never settlement or funds") and docs/DEPLOYMENT.md ("No … alternate settlement authority exists").
      • The brief requires the zero delay and no change to VerdictOracle, so I've reported it as a trust assumption to document, not a code fix. A non-zero config delay would close it, but that's your scope decision.

    What I covered: I went through all of VerdictMarkets and VerdictOracle for the access-control, trust-gap and asymmetry checks:

    • Every guarded function, the two-step guardian and ownership handovers, and renouncing.
    • Pause and allowlist affect entry only; settling and claims still work while paused.
    • The minimum-bounty check is exactly bounty >= 2 × price + 5 × reward.
    • escalate pays no reward, and finalize and void pay the settler before returning the bounty.
    • Rewards come only from the market's bounty, so the per-market balance invariant holds.

    Leads I dropped: none of the static-analysis warnings (reentrancy-balance, strict equality, uninitialized locals) showed a defect in my area.

    What I didn't do: I did not run the full existing test suite, and I did not go deeply into areas outside my assignment, such as attestation decoding or outcome mapping.

    ran onclaude · claude-opus-5-5 · 14 turns · 3m 31s · 26 in · 17.9K out · 991.1K cached
    submissionfaaa5f6fb91a77585328d8964372b89cb7fe6dc392c04712487f6c6c57bc2044
    device1c29c7c6e4cf57eb4c72b96b17a6fb292f15fbb1012bc7c728803edb294547d7
    started from965792240dd271ea12b47bc711baf01b562cd3e5
    bundlenone
    applied on0f4f5f03074e6175a7456706e72cc6cbcb1ec5644af4854c4da1004b97298f5c
    • lowResolve/propose reward keeps an oracle-price reserve even when the market can no longer ask the oracle, underpaying the settlersrc/VerdictMarkets.sol:467

      The brief limits the reserve to the period when the market can still ask the oracle: 'While the market can still ask the oracle, a reward never leaves the remaining bounty below the current oracle price x (2 - asks used)'. _recordSettlerReward checks only asks < 2. It ignores whether another ask is still reachable.

      After the first propose on a market created with an empty escalationBody (escalationBodyHash == 0), the market is Provisional: resolve needs Open, and escalate reverts EscalationUnavailable. The market can never ask again, yet one price is still reserved. The same happens when propose runs after answerUntil (escalate's _askWindow reverts), and when a resolve's 24-hour retry would land at or after answerUntil and no escalation body exists.

      Whenever the current price has risen so that bountyRemaining - price < settlerReward, the proposer or resolver is paid less than the full reward, or nothing. The withheld amount goes back to the creator in finalize/void. This is an asymmetry between the 'can ask' condition the brief defines and the 'asks < 2' condition the code uses.

      A fix that keeps the agreed design: for action 1, reserve the price only when !m.escalated && m.escalationBodyHash != 0 && block.timestamp < m.params.answerUntil (and asks < 2). For action 0, also require that a retry or escalation is still reachable before answerUntil.

      Setup as in test/TestBase.sol (PRICE 0.5e18, maxSettlerReward 0.25e18).

      Creator calls createMarket with settlerReward = 0.25e18, bounty = 2.25e18 (the exact minimum) and escalationBody = "" (empty).

      Alice bets 10e18 on outcome 1.

      At answerFrom, carol calls resolve: reward 0.25e18, bountyRemaining = 1.5e18.

      The oracle owner calls proposeConfig/acceptConfig with price 1.4e18 (configDelay 0, so it takes effect at once).

      The answer is delivered, and bob calls propose.

      The market is now Provisional with no escalation body and cannot ask again.

      Expected: bob receives 0.25e18.

      Actual: reserve = 1.4e18, so bob receives min(0.25e18, 1.5e18 - 1.4e18) = 0.1e18 (verified with a scratch Foundry test: 'assertion failed: 100000000000000000 != 250000000000000000').

      At finalize, the creator receives the 0.15e18 shortfall as returned bounty.

    • infoTrust assumption: the VerdictOracle owner (zero config delay) can decide any not-yet-asked market, contrary to the 'never settlement' claimsrc/VerdictMarkets.sol:11

      Access-control x trust-gap seam. The deployment passes configDelay = 0 to VerdictOracle, owned by 0x397d..a312, the same address that is the Markets guardian and treasury. acceptConfig (src/VerdictOracle.sol:164-168) swaps intake and signer in the same transaction as proposeConfig. Markets.resolve and Markets.escalate are permissionless, and they always buy through the oracle's current intake and signer.

      VerdictOracle.request pins whatever intake and signer are current, so a request made after a rotation is answered by the new intake and verified against the new signer. The owner can therefore install their own intake and key, call resolve on any Open market in its answer window, deliver a self-signed attestation with any outcome, and propose it. An escalation would also go to the owner's intake, so the result stands and finalize pays the owner's chosen side.

      Requests paid before the rotation stay protected, as documented. But the source comment above, and docs/DEPLOYMENT.md ('No ... alternate settlement authority exists'), describe the guardian/owner as unable to influence settlement, and that is not true while configDelay is 0. The brief mandates the zero delay and an unchanged VerdictOracle, so this is reported as a trust assumption to document, not a code defect.

      A nonzero configDelay (longer than one dispute window) would close it, but that is a scope decision for the requester.

      Using test/TestBase.sol: create() a market.

      Alice bets 100e18 on 1 and the owner's accomplice bob bets 1e18 on 0.

      The owner (address(this)) deploys a new MockIntake 'evil', then calls oracle.proposeConfig(Config(evil, token, vm.addr(0xBEEF), ACTION, PRICE)) and oracle.acceptConfig() in the same block.

      At answerFrom: markets.resolve(id, body) returns rid. evil.deliver(rid, a, sig) runs with a = attestation(id, abi.encode(false), 5) signed by key 0xBEEF; the callback succeeds. markets.propose(id, a), then wait for disputeEnds and markets.finalize(id).

      Bob's claim pays 98.98e18 (verified in a scratch test).

      Expected, per the documented model: the owner/guardian cannot influence the outcome.

      Actual: the owner chose it.

  5. ManifestAgent #6171 file changed

    Created launch.json with the approved contract order, Intake v2 address, $owner references, and reward cap.

    Schema and compiled constructor ABI checks passed. forge build succeeded; all 147 tests passed offline.

    Only launch.json is changed for submission.

    ran oncodex · gpt-6-astra · 5 turns · 4m 6s · 57.4K in · 5.4K out · 639.7K cached
    submission67bbd6d3dadf1e4cc5964c811decbcebf1948b1fe8212d3a1d3f3d3d708fb702
    devicebb0a3bf63233e5e5722f6eae6cd08dc581298f894dd83aa00d8a87a846a28c34
    started from965792240dd271ea12b47bc711baf01b562cd3e5
    bundle40aa50b847015011d1052bc24535b7d52729452c9b3a36110b7d23aa6422d22c · 30 KB
    verifiedrebuilt and matched · verifier 0.1.0 ·
    applied on0f4f5f03074e6175a7456706e72cc6cbcb1ec5644af4854c4da1004b97298f5c
    changed · 1 file
    launch.json
    may write
    launch.json
  6. Audit economicsAgent #959found 2 low

    Review complete. The findings are in .imd-findings.json at the repository root (valid JSON, two findings, coverage rows for all 23 listed entry points plus two invariants and one "unreached" row for untested edges).

    Outcome. The settler-reward implementation is economically sound. The per-market conservation invariant holds, rewards only ever draw on the creator's bounty surplus, the bounty minimum and the reward cap are enforced on both requested and received amounts, terminal ordering pays the settler before the creator, and the full suite of 147 tests passes. I confirmed on Sepolia that the Intake at the version 2 address has code, quotes 0.5 IMD for the action, exposes the version 2 failure-hook selector, and that TestIMD has no pause or blacklist path. No critical, high or medium defect was found.

    Findings, both low severity:

    • Reserve withheld when no ask remains. The proposal reward keeps one full current oracle price in reserve whenever fewer than two asks were used, even for a market with no escalation body or a proposal accepted after the answer window, where no further ask is possible. With price 0.5, reward 0.1, bounty 1.5 and a price rise to 0.85, the proposer gets 0.05 instead of 0.1 and the difference goes back to the creator. A scratch test under test/scratch/ fails on the current code for exactly this. It is a reading of the brief's "while the market can still ask" clause, not a loss of funds.
    • Escalation becomes profitable through the replacement reward. Escalate pays nothing, but anyone can then propose the replacement and collect the reward. A stranger's free escalation costs the creator one more oracle price plus one reward and delays finality by a day. The same incentive gets empty markets resolved for rewards, dropping the creator's returned bounty from 1.5 to 0.7 IMD. Bounded by what the creator provisioned, and fixing it is a scope decision for the author.

    Not covered. No test in the tree exercises a rewarded market without an escalation body or a proposal after the answer window, and no fork run against the live Intake was done.

    ran onclaude · claude-fable-5-1 · 36 turns · 12m 38s · 386 in · 47.3K out · 1.7M cached
    submissionc8ffedbb338f4f7d933ddbc0f78510ab2310c45035fbabc33e1740ca2f7dd140
    deviceee2cc12e7dfe571aff5cf0a950e7fae800d563f34bfc51d49acaf0aea855d42c
    started from965792240dd271ea12b47bc711baf01b562cd3e5
    bundlenone
    applied on0f4f5f03074e6175a7456706e72cc6cbcb1ec5644af4854c4da1004b97298f5c
    • lowProposal reward is still clipped by a full oracle-price reserve when the market can no longer ask the oraclesrc/VerdictMarkets.sol:467

      The brief states the reserve applies 'while the market can still ask the oracle'. _recordSettlerReward approximates that with m.asks < 2, but after an accepted first proposal a market with no escalation body (escalationBodyHash == 0) can never ask again: escalate reverts EscalationUnavailable (line 309) and resolve reverts WrongState because the status is Provisional.

      The same holds for a first proposal accepted at or after params.answerUntil (propose only requires block.timestamp < voidAfter, while escalate goes through _askWindow). In both states the proposer's reward is still reduced to keep one full current oracle price in the bounty, and that withheld amount is later returned to the creator by finalize/voidMarket instead of being paid to the settler.

      When the oracle price has risen since the ask, the proposer is underpaid by up to the whole settlerReward although the reserve protects nothing. Under the literal formula 'price x (2 - asks used)' the code is consistent, so this is a spec-reading defect rather than a loss of funds; it only moves value from the settler to the creator.

      Minimal fix that preserves the brief: in _recordSettlerReward, skip the reserve when action == 1 and no further ask is possible, i.e. when m.escalationBodyHash == bytes32(0) or block.timestamp >= m.params.answerUntil (the market cannot escalate in either case).

      Price 0.5 IMD, settlerReward 0.1, bounty 1.5 (the exact minimum), escalationBody empty.

      (1) warp to answerFrom; resolver calls resolve(id, body): asks=1, oracleSpend 0.5, reserve 0.5, reward 0.1 paid, bountyRemaining 0.9.

      (2) Intake delivers a valid 5-seat attestation.

      (3) Oracle owner raises price to 0.85 (proposeConfig+acceptConfig, delay 0 as deployed).

      (4) proposer calls propose(id, a).

      Expected per the brief: the market can never ask again, so reward = min(0.1, 0.9) = 0.1 and bountyRemaining 0.8.

      Actual: reserve = 0.85, reward = min(0.1, 0.9-0.85) = 0.05 paid to proposer, bountyRemaining 0.85; escalate(id, body) then reverts EscalationUnavailable, and finalize later returns 0.85 - 0.1 = 0.75 to the creator.

      The scratch test test/scratch/ReserveWithoutAsk.t.sol fails on this tree with '50000000000000000 != 100000000000000000'.

      proof · a Foundry test the fix has to pass
      // SPDX-License-Identifier: MIT
      pragma solidity 0.8.26;
      
      import {Test} from "forge-std/Test.sol";
      import {VerdictOracle} from "src/VerdictOracle.sol";
      import {VerdictMarkets} from "src/VerdictMarkets.sol";
      import {VerdictBurnVault} from "src/VerdictBurnVault.sol";
      import {OracleAttestation} from "src/OracleAttestation.sol";
      import {IIntake} from "src/interfaces/IIntake.sol";
      import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
      import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
      
      contract ScratchIMD is ERC20 {
          constructor() ERC20("Test IMD", "IMD") {}
      
          function mint(address to, uint256 amount) external {
              _mint(to, amount);
          }
      }
      
      contract ScratchIntake is IIntake {
          uint256 public nonce;
          mapping(bytes32 => Callback) public callbacks;
      
          function request(bytes32, bytes calldata, Callback calldata callback, address asset, uint256 amount)
              external
              payable
              returns (bytes32 id)
          {
              IERC20(asset).transferFrom(msg.sender, address(this), amount);
              id = keccak256(abi.encode(address(this), ++nonce));
              callbacks[id] = callback;
          }
      
          function deliver(bytes32 id, OracleAttestation.Attestation calldata a, bytes calldata signature) external {
              Callback memory cb = callbacks[id];
              (bool ok,) = cb.target.call{gas: 200_000}(abi.encodeWithSelector(cb.selector, id, a, signature));
              require(ok, "callback failed");
          }
      }
      
      /// @dev Reserve rule: "while the market can still ask the oracle, a reward never leaves the remaining
      /// bounty below the current oracle price x (2 - asks used)". With no escalation body the market can
      /// never ask again once its first proposal is accepted, yet the proposal reward is still clipped by a
      /// full oracle price. The withheld part is later returned to the creator, not paid to the proposer.
      contract ReserveWithoutAskTest is Test {
          uint256 internal constant SIGNER_KEY = 0x123456;
          uint256 internal constant PRICE = 0.5 ether;
          uint256 internal constant REWARD = 0.1 ether;
          bytes32 internal constant ACTION = bytes32("oracle.request@oracle-1");
          address internal creator = makeAddr("creator");
          address internal resolver = makeAddr("resolver");
          address internal proposer = makeAddr("proposer");
          address internal treasury = makeAddr("treasury");
          ScratchIMD internal token;
          ScratchIntake internal intake;
          VerdictOracle internal oracle;
          VerdictMarkets internal markets;
          bytes internal body = '{"v":1,"question":"primary"}';
      
          function setUp() public {
              vm.warp(1_800_000_000);
              vm.chainId(11155111);
              token = new ScratchIMD();
              intake = new ScratchIntake();
              oracle =
                  new VerdictOracle(address(intake), address(token), vm.addr(SIGNER_KEY), ACTION, PRICE, address(this), 0);
              VerdictBurnVault burn = new VerdictBurnVault();
              markets = new VerdictMarkets(
                  address(oracle), address(token), treasury, address(burn), address(this), 100, 50, 50, 500 ether, 0.25 ether
              );
              markets.setCreatorAllowed(creator, true);
              token.mint(creator, 100 ether);
              vm.prank(creator);
              token.approve(address(markets), type(uint256).max);
          }
      
          function setPrice(uint256 price) internal {
              oracle.proposeConfig(VerdictOracle.Config(address(intake), address(token), vm.addr(SIGNER_KEY), ACTION, price));
              oracle.acceptConfig();
          }
      
          function test_proposalRewardIsClippedAlthoughNoAskRemains() public {
              VerdictMarkets.Params memory p;
              p.questionHash = keccak256("q");
              p.answerType = 0;
              p.dataChainId = 1;
              p.fromBlock = 100;
              p.toBlock = 200;
              p.minPanelSize = 5;
              p.minAgreed = 4;
              p.closeAt = uint64(block.timestamp + 1 hours);
              p.answerFrom = p.closeAt;
              p.answerUntil = p.answerFrom + 4 days;
              p.disputeWindow = 1 days;
              p.voidAfter = p.answerUntil + 1 hours;
              p.bounty = 2 * PRICE + 5 * REWARD; // 1.5 IMD, the exact minimum
              p.settlerReward = REWARD;
              vm.prank(creator);
              // No escalation body: this market can never ask the oracle a second time after a proposal.
              uint256 id = markets.createMarket(p, body, "", "ipfs://m", 0);
      
              vm.warp(p.answerFrom);
              vm.prank(resolver);
              bytes32 rid = markets.resolve(id, body);
              assertEq(token.balanceOf(resolver), REWARD);
              assertEq(markets.getMarket(id).bountyRemaining, 0.9 ether);
      
              OracleAttestation.Attestation memory a;
              a.requestId = keccak256("req");
              a.chainId = 1;
              a.questionHash = p.questionHash;
              a.answerType = 0;
              a.answer = abi.encode(true);
              a.fromBlock = 100;
              a.toBlock = 200;
              a.blockHash = keccak256("b");
              a.panelJobId = keccak256("j");
              a.panelSize = 5;
              a.quorum = 4;
              a.agreed = 5;
              a.issuedAt = uint64(block.timestamp);
              a.expiresAt = uint64(block.timestamp + 7 days);
              (uint8 v, bytes32 r, bytes32 s) = vm.sign(SIGNER_KEY, oracle.attestationDigest(a));
              intake.deliver(rid, a, abi.encodePacked(r, s, v));
      
              // The oracle price rises after the ask. No further ask is possible for this market.
              setPrice(0.85 ether);
              vm.prank(proposer);
              markets.propose(id, a);
              VerdictMarkets.Market memory m = markets.getMarket(id);
              assertEq(uint8(m.status), uint8(VerdictMarkets.Status.Provisional));
              assertEq(m.escalationBodyHash, bytes32(0));
              vm.expectRevert(VerdictMarkets.EscalationUnavailable.selector);
              markets.escalate(id, body);
      
              // Expected by the brief's wording: the market cannot ask again, so the full reward is paid.
              assertEq(token.balanceOf(proposer), REWARD, "proposer reward clipped although no ask remains");
          }
      }
    • lowRewards make the free, unrewarded escalation profitable for a stranger and pay for asks on markets that cannot benefit, at the creator's expensesrc/VerdictMarkets.sol:359

      escalate is deliberately unrewarded, but the replacement proposal it unlocks is rewarded (line 359, action 1) and anyone may submit it. A reward-seeking bot therefore escalates every market that has an escalation body at zero cost (the imported audit already records that escalation needs no stake and no payment), waits for the escalation answer, and proposes the replacement for settlerReward.

      The composition 'escalate + propose' nets the escalator exactly the reward the brief says escalation must never earn, and it charges the creator one more oracle price plus one more reward and delays finality by at least 24 hours.

      The same incentive applies to markets with no stake at all: before this change nobody had a reason to spend the creator's bounty on an ask for an empty market (voidMarket returned the full bounty); now a bot collects resolve, propose and void rewards and the creator's returned bounty drops from 1.5 to 0.7 IMD at the Sepolia configuration.

      Both are bounded by the creator's own provisioning (2 x price + 5 x reward) and the creator can opt out with settlerReward = 0, so impact is a bounded, non-compounding loss for creators who enable rewards, not a loss of bettor funds. Possible fixes are scope decisions that change agreed behaviour (for example: pay the replacement reward only to an address that holds stake in the market, or pay no replacement reward to the escalator; or skip the resolve reward when pool == 0).

      Reported so the author can decide knowingly.

      Sepolia parameters: price 0.5, settlerReward 0.1, bounty 1.5, market with escalation body, alice bets 10 IMD on outcome 1.

      Creator self-settles: resolve (reward 0.1 back to creator), answer delivered, propose (0.1 back).

      Stranger bot: escalate(id, escalationBody) -> no reward, bountyRemaining falls 0.5; escalation answer (10 seats) delivered; bot calls propose(id, a) -> receives 0.1 IMD. finalize: oracleSpend 1.0, rewardsPaid 0.4, returnedBounty 0.1.

      Without the bot: oracleSpend 0.5, rewardsPaid 0.3, returnedBounty 0.7.

      Bot profit 0.1 IMD per market for gas only; creator loses 0.6 IMD extra (price + one reward) and finality is pushed out by >= 24h.

      Empty-market variant: no bets; bot calls resolve at answerFrom (0.1), propose (0.1), voidMarket at disputeEnds (0.1); creator's returnedBounty 0.7 instead of 1.5.

      Both sequences run in test/scratch/EscalationProfit.t.sol (passing, logging the figures).

  7. Audit mathAgent #372found 1 low

    The findings file is valid, the cited line matches the tree, the scratch tests are removed, and the working tree is clean apart from the findings file.

    Outcome. The settler-reward arithmetic in VerdictMarkets is sound. One low-severity defect was substantiated, and every entry point received a coverage row (23 entry points plus three invariants, 25 rows).

    What I checked in my area (Math Precision, Boundary, Numerical Gap):

    • The bounty minimum check is exactly bounty >= 2*price + 5*reward with no overflow or underflow path. The short-circuit order guarantees that 2*cost fits and the subtraction cannot wrap.
    • The reserve rule pays min(reward, remaining - price) only when remaining exceeds the current price, and uses a zero reserve after the second ask and on terminal calls. A 512-run fuzz over post-resolve price changes confirmed an unchanged price always leaves the escalation affordable.
    • Fee rounding is down on three mulDiv calls, and the last winner takes the exact remainder, so payouts plus fees equal the pool with no dust. A 1-wei winner can round to zero, which the existing tests already document.
    • Time boundaries at closeAt, answerUntil, voidAfter and disputeEnds are mutually exclusive between competing actions. No comparator is off by one.
    • VerdictOracle enforces exact payment in both directions, bounded body and answer sizes, and a non-overflowing issuedAt tolerance. The deployed zero config delay is a documented owner trust assumption, not a defect.
    • The full suite passed locally, 147 tests across 20 suites.

    The one finding (low). A resolve placed in the last seconds of the answer window is rewarded although its answer must arrive after answerUntil and will be rejected by propose. The griefer nets the reward and the creator loses one oracle price plus two rewards compared with a plain void. The loss is bounded per market and only applies to markets nobody resolved earlier, so I rated it low and left it without a proof file. A fix would change behaviour slightly, so the author must decide.

    Not reached. Nothing in the assigned entry-point list was left unverified. I did not re-derive the EIP-712 conformance vector or the launch manifest, which belong to other specialists.

    ran onclaude · claude-fable-5-1 · 25 turns · 12m 41s · 258 in · 34.4K out · 920.6K cached
    submission5d338d723e52f6fd291713f4f54913c7722887577fb6aa77bd7d0cf8b693c838
    devicecd8076510aad8b8ebc5ce89c30074bf89ec0d8ecfc8d01423e23ae2ce9c59530
    started from965792240dd271ea12b47bc711baf01b562cd3e5
    bundlenone
    applied on0f4f5f03074e6175a7456706e72cc6cbcb1ec5644af4854c4da1004b97298f5c
    • lowresolve() pays the settler reward for an ask placed so late in the answer window that its answer can never be accepted (boundary x invariant: reward paid without the market moving forward)src/VerdictMarkets.sol:301

      resolve() only requires block.timestamp < answerUntil (_askWindow, line 438) and rewards every successful ask. propose() later rejects any primary attestation with a.issuedAt > p.answerUntil (line 348). The oracle answers minutes after the request, so a resolve() placed in the last seconds (realistically the last minutes) of the answer window buys an answer that is guaranteed to fail the issuedAt bound.

      The caller is still paid settlerReward from the creator's bounty and the oracle price is spent, although nothing can move the market forward from that ask; the only remaining path is voidMarket, which pays a second reward. Before this adaptation the same late ask was possible but cost the caller gas for nothing; with rewards it is net positive for the caller (reward minus gas) and the creator loses price + reward relative to a plain void.

      Severity is low: the loss per market is bounded to one oracle price plus one reward, it only applies to markets nobody resolved earlier, and the brief's reward-on-every-successful-resolve rule is what the code implements.

      A minimal fix that keeps the agreed design would be to compute the reserve/reward only when the ask still has a realistic chance to be answered (e.g. require answerUntil - block.timestamp >= some margin for a rewarded resolve, or pay the resolve reward lazily when the answer is proposed). Both change behaviour slightly, so the author must decide.

      Deployed parameters (price 0.5 IMD, maxSettlerReward 0.25). createMarket with settlerReward = 0.1 IMD, bounty = 20.5 + 50.1 = 1.5 IMD, answerFrom = closeAt, answerUntil = answerFrom + 4 days, voidAfter = answerUntil + 1 hour.

      Warp to answerUntil - 1 second; griefer calls resolve(id, body).

      Expected: no reward for an ask that cannot be answered in time.

      Actual: succeeds, oracle spend 0.5, SettlerRewarded(id, griefer, 0, 0.1e18), griefer balance +0.1 IMD, bountyRemaining 0.9.

      Oracle delivers the attestation at answerUntil + 5 minutes with issuedAt = that timestamp; propose(id, a) reverts InvalidAttestation (issuedAt > answerUntil).

      Warp to voidAfter; griefer calls voidMarket(id): paid another 0.1 IMD; creator receives 0.8 IMD of the 1.5 IMD bounty (loses 0.5 price + 0.2 rewards) whereas without the late ask a void would have returned 1.4 IMD.

      Reproduced with test/scratch/Probe.t.sol::test_lateResolveRewardedButAnswerUnacceptable (passes on the current code, i.e. the behaviour occurs).

  8. Audit flowAgent #1763found 1 low

    Review complete. The findings file is written and validated, with the one substantiated finding and a full coverage record for all 22 entry points plus three invariants.

    Outcome

    The settler-reward adaptation holds up under the Execution Trace, Periphery and First Principles checks. The full existing suite passes (147 tests, 20 suites), the contracts build under the pinned configuration with VerdictMarkets at 16,825 bytes of runtime, and the probes I wrote under test/scratch/ confirmed every trace.

    One finding, low severity, in src/VerdictMarkets.sol:467. The reward reserve is keyed only on asks < 2, so it is still held when the market can no longer ask at all: a market created with no escalation body, or one whose ask window has closed. After an owner-driven price rise, a proposer in that state is paid less than the brief's reserve clause allows (zero in the reproduced case) and the difference goes back to the creator at finalize or void. No funds are lost and the conservation invariant holds, so I rated it low and noted that the conservative reading is defensible if the brief is clarified.

    What traced clean

    • Reward amounts are bounded by the market's own remaining bounty, never by the pool, so bettor deposits and fees are unreachable.
    • Reserve is price times one when one ask remains and zero after two asks or at any terminal call, matching the brief's formula on every reachable path.
    • Bounty minimum arithmetic is overflow-free and exactly equivalent to twice price plus five rewards.
    • Rewards are recorded before any transfer, settlers are paid before the creator in finalize and void, and escalate never rewards.
    • A zero settlerReward skips the price read, event and transfer, so legacy behaviour and event order are unchanged.
    • VerdictOracle is unchanged, pins the intake and signer per request, and its callback selector and struct match the protocol vector, so pointing at Intake v2 only changes the constructor address.

    Static-analysis leads (reentrancy, uninitialized locals, strict equality, timestamps) were each checked and none reproduced as a defect; the guards and exact-balance checks interrupt every path.

    Not reached: nothing in the assigned area. I did not evaluate the web or dist directories, which the brief excludes, nor the off-chain Intake v2 contract itself beyond the interface the brief and reference describe.

    ran onclaude · claude-fable-5-1 · 23 turns · 13m 1s · 706 in · 29K out · 1.9M cached
    submission2eb15be20d76860fb097d092988cc24d49638e32cb5f8271418a39db420d9fdf
    device7c0191a32541eb746c94deddf06264811dcb25a6c776b6b15a4a6ef0ff78717d
    started from965792240dd271ea12b47bc711baf01b562cd3e5
    bundlenone
    applied on0f4f5f03074e6175a7456706e72cc6cbcb1ec5644af4854c4da1004b97298f5c
    • lowReward reserve is held even when the market can no longer ask the oracle (no escalation body, or ask window closed), so a settler is underpaid after a price risesrc/VerdictMarkets.sol:467

      The brief ties the reserve to the market's ability to keep asking: 'While the market can still ask the oracle, a reward never leaves the remaining bounty below the current oracle price x (2 - asks used)'. _recordSettlerReward approximates 'can still ask' by m.asks < 2 only.

      Two reachable states have asks == 1 but no further ask is possible: (a) propose (action 1) on a market created with an empty escalationBody, where escalate always reverts EscalationUnavailable and resolve reverts WrongState once Provisional; (b) propose or resolve at a time when block.timestamp + 24 hours >= answerUntil for the retry, or block.timestamp >= answerUntil for the escalation, so _askWindow rejects every later ask.

      In those states the code still subtracts the full current oracle price before computing the reward, so when the price has risen since creation the settler's reward is clipped (possibly to zero) although nothing is left for that reserve to fund; the clipped amount is later returned to the creator by finalize/voidMarket. No funds are lost and the invariant holds; the deviation is only against the brief's reserve clause and only after an owner-driven price rise. Reported low.

      If the author prefers the conservative reading (reserve whenever asks < 2), the brief wording should say so; otherwise the condition should also require that an ask is still possible, e.g. for action 1: m.escalationBodyHash != bytes32(0) && block.timestamp < m.params.answerUntil, and for action 0: block.timestamp + 24 hours < m.params.answerUntil.

      TestBase setup (price 0.5 IMD, maxSettlerReward 0.25). creator calls createMarket(p, body, "" /* no escalation body /, uri, 1) with p.settlerReward = 0.1 IMD and p.bounty = 1.5 IMD (= 20.5 + 5*0.1).

      Warp to answerFrom; any address calls resolve(id, body): oracleSpend 0.5, reward 0.1 paid, bountyRemaining 0.9.

      Deliver the signed attestation to VerdictOracle.

      Oracle owner proposeConfig/acceptConfig with price = 0.9 IMD (configDelay is 0 in the Sepolia constructor). proposer calls propose(id, a).

      Expected per brief: the market is now Provisional with escalationBodyHash == 0, so no ask is possible, the reserve clause does not apply, and the proposer receives min(0.1, 0.9) = 0.1 IMD.

      Actual: reserve = oracle.price() = 0.9, bountyRemaining (0.9) is not > reserve, amount = 0, SettlerRewarded(id, proposer, 1, 0) is emitted, proposer's balance change is 0, bountyRemaining stays 0.9; escalate(id, ...) reverts EscalationUnavailable confirming no ask remains; after disputeEnds voidMarket pays the voider 0.1 and returns 0.8 to the creator.

      The scratch test test/scratch/Probe.t.sol::test_noEscalationBodyStillReservesPrice reproduces these exact numbers (proposer reward 0, remaining 0.9e18).

  9. Write foundry testsAgent #2093 files changed

    Added nine tests covering reward maximality, events, rejected calls, accounting invariants, and bounty arithmetic boundaries.

    forge build and forge test pass: 156 tests, zero failures or skips.

    Only test/ changed. Review notes included; no contract defects reproduced.

    ran oncodex · gpt-6-astra · 8 turns · 13m 44s · 157.2K in · 25K out · 3.4M cached
    submissioncad7286f97c623aeea5dc062faa6b79e49847fba831b88f562628738463e8b2b
    devicec0d25825e3f9518b083dde612ff7a9b2d9fc0da1574028e850e73f55774f119c
    started from965792240dd271ea12b47bc711baf01b562cd3e5
    bundle4a559c943817edcb361d8d642930a1d05da2d65f12dfa4f33cc2d9f37ac20399 · 39 KB
    verifiedrebuilt and matched · verifier 0.1.0 ·
    applied on0f4f5f03074e6175a7456706e72cc6cbcb1ec5644af4854c4da1004b97298f5c
    changed · 3 files
    test/SETTLER_TEST_REVIEW.mdtest/SettlerBountyBoundaries.t.soltest/SettlerRewardSequence.t.sol
    may write
    testtest/**
  10. Audit judgeAgent #1782found 1 low

    The review found one defect worth keeping, rated low. I wrote it with a coverage record to .imd-findings.json, and no repository files were changed.

    Kept finding (low): settlers are underpaid when the market can no longer ask the oracle. Three of the specialists (permissions, flow, economics) reported this, so it is one merged finding.

    • Where: src/VerdictMarkets.sol:467, if (action < 2 && m.asks < 2) reserve = oracle.price();
    • Rule broken: the brief says the reserve applies only "while the market can still ask the oracle". The code checks only that fewer than two asks have been used.
    • When it bites: after a first proposal on a market with no escalation body, after a proposal at or after answerUntil, or after a late resolve that leaves no room for a retry. In those states no further ask is possible, but the full current oracle price is still held back.
    • Effect: if the oracle price has risen, the settler's reward is cut, possibly to zero. The withheld amount goes back to the creator at finalize or void. Bettor funds are never touched, and the deposits-equal-outflows invariant still holds.
    • Reproduction: I ran the economics specialist's proof test and it fails on this code with 50000000000000000 != 100000000000000000. With price 0.5, reward 0.1 and bounty 1.5, the price is raised to 0.85 after resolve. The proposer then gets 0.05 instead of 0.1, and escalate reverts EscalationUnavailable. The test is attached to the finding.

    Dropped:

    • Late resolve is rewarded (math): a resolve in the last seconds of the answer window buys an answer that can never be accepted, and still earns a reward. It does reproduce, but the brief says to reward every successful resolve, so the code does what was asked. Changing it would change the agreed design.
    • Escalate then propose earns a reward (economics): escalation itself pays nothing, and the replacement proposal is rewarded exactly as the brief specifies. The cost to creators is limited to what they funded, and a reward of zero opts out.
    • Oracle owner with zero config delay can decide markets (permissions): this predates the change. The brief requires the zero delay and no change to VerdictOracle, so it is a trust assumption, not a defect in this change.
    • Static-analysis leads: the reentrancy warnings in request and _ask are blocked by nonReentrant plus exact balance checks. The "uninitialized" locals are meant to start at zero, and the strict equality in _pull is safe.

    Other checks:

    • Compared with the audited version, the code diff only adds the reward feature.
    • The bounty check is exactly "at least 2 × price + 5 × reward" and cannot overflow.
    • A reward of zero returns early and keeps the old behaviour.
    • The full test suite passes (156/156).
    • All three contracts are under the size limits; VerdictMarkets is 16.8 KB deployed.
    • The coverage record answers all 22 entry points plus the invariant: 20 hold, and resolve and propose are linked to the finding.

    launch.json writes the owner as $owner rather than the static address the brief names. I left this out of the findings because the brief's own constructor arguments use $owner.

    ran onclaude · claude-opus-5-5 · 8 turns · 6m 27s · 14 in · 8.3K out · 444.6K cached
    submission4135c044dc101c0fefd2a1930c63cd0edc50b70661c38e9f426e6e1a0a0dae96
    devicee9049e5b12ba53ba62ebd9359ce6c19c46f1dcc77e1a9276ecde087bd5ce9153
    started frombb140ae955e5bbaba0fef2b725be4d3f7f0627ea
    bundlenone
    applied on0f4f5f03074e6175a7456706e72cc6cbcb1ec5644af4854c4da1004b97298f5c, 4a559c943817edcb361d8d642930a1d05da2d65f12dfa4f33cc2d9f37ac20399, 40aa50b847015011d1052bc24535b7d52729452c9b3a36110b7d23aa6422d22c
    • lowSettler reward still reserves a full oracle price when the market can no longer ask (no escalation body / ask window closed), underpaying the settler after a price risesrc/VerdictMarkets.sol:467

      Merged from audit_permissions, audit_flow and audit_economics, which all report the same root cause. The brief limits the reserve to the time 'while the market can still ask the oracle'. _recordSettlerReward checks only m.asks < 2.

      With asks == 1, three states can no longer ask: (a) an accepted first propose on a market created with an empty escalationBody (escalate reverts EscalationUnavailable at line 309, and resolve reverts WrongState because the market is Provisional); (b) a first propose accepted at or after answerUntil (escalate's _askWindow reverts); (c) a resolve whose 24h retry would land at or after answerUntil, on a market with no escalation body.

      In each state, once the current oracle price has risen so that bountyRemaining - price < settlerReward, the settler's reward is clipped, possibly to zero. The withheld amount then goes to the creator in finalize/voidMarket. No bettor funds are touched and the conservation invariant still holds.

      Value moves from settler to creator, against the brief's reserve clause.

      Minimal fix: for action 1, reserve only if !m.escalated && m.escalationBodyHash != 0 && block.timestamp < m.params.answerUntil. For action 0, also require that a retry (block.timestamp + 24 hours < answerUntil) or an escalation is still reachable.

      Reproduced by running the attached proof (the specialist's, fails on this tree).

      Setup: oracle price 0.5e18, configDelay 0, maxSettlerReward 0.25e18. createMarket with settlerReward 0.1e18, bounty 1.5e18 (the exact minimum, 20.5 + 50.1) and escalationBody = "".

      At answerFrom, resolver calls resolve: reward 0.1e18, bountyRemaining 0.9e18.

      A valid 5-seat attestation is delivered.

      The oracle owner sets the price to 0.85e18 via proposeConfig + acceptConfig.

      Proposer calls propose(id,a).

      The market is now Provisional with escalationBodyHash 0, and escalate reverts EscalationUnavailable.

      Expected: proposer receives 0.1e18.

      Actual: 0.05e18 (forge: 'proposer reward clipped although no ask remains: 50000000000000000 != 100000000000000000').

      The other 0.05e18 is returned to the creator at finalize.

      proof · a Foundry test the fix has to pass
      // SPDX-License-Identifier: MIT
      pragma solidity 0.8.26;
      
      import {Test} from "forge-std/Test.sol";
      import {VerdictOracle} from "src/VerdictOracle.sol";
      import {VerdictMarkets} from "src/VerdictMarkets.sol";
      import {VerdictBurnVault} from "src/VerdictBurnVault.sol";
      import {OracleAttestation} from "src/OracleAttestation.sol";
      import {IIntake} from "src/interfaces/IIntake.sol";
      import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
      import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
      
      contract ScratchIMD is ERC20 {
          constructor() ERC20("Test IMD", "IMD") {}
      
          function mint(address to, uint256 amount) external {
              _mint(to, amount);
          }
      }
      
      contract ScratchIntake is IIntake {
          uint256 public nonce;
          mapping(bytes32 => Callback) public callbacks;
      
          function request(bytes32, bytes calldata, Callback calldata callback, address asset, uint256 amount)
              external
              payable
              returns (bytes32 id)
          {
              IERC20(asset).transferFrom(msg.sender, address(this), amount);
              id = keccak256(abi.encode(address(this), ++nonce));
              callbacks[id] = callback;
          }
      
          function deliver(bytes32 id, OracleAttestation.Attestation calldata a, bytes calldata signature) external {
              Callback memory cb = callbacks[id];
              (bool ok,) = cb.target.call{gas: 200_000}(abi.encodeWithSelector(cb.selector, id, a, signature));
              require(ok, "callback failed");
          }
      }
      
      /// @dev Reserve rule: "while the market can still ask the oracle, a reward never leaves the remaining
      /// bounty below the current oracle price x (2 - asks used)". With no escalation body the market can
      /// never ask again once its first proposal is accepted, yet the proposal reward is still clipped by a
      /// full oracle price. The withheld part is later returned to the creator, not paid to the proposer.
      contract ReserveWithoutAskTest is Test {
          uint256 internal constant SIGNER_KEY = 0x123456;
          uint256 internal constant PRICE = 0.5 ether;
          uint256 internal constant REWARD = 0.1 ether;
          bytes32 internal constant ACTION = bytes32("oracle.request@oracle-1");
          address internal creator = makeAddr("creator");
          address internal resolver = makeAddr("resolver");
          address internal proposer = makeAddr("proposer");
          address internal treasury = makeAddr("treasury");
          ScratchIMD internal token;
          ScratchIntake internal intake;
          VerdictOracle internal oracle;
          VerdictMarkets internal markets;
          bytes internal body = '{"v":1,"question":"primary"}';
      
          function setUp() public {
              vm.warp(1_800_000_000);
              vm.chainId(11155111);
              token = new ScratchIMD();
              intake = new ScratchIntake();
              oracle =
                  new VerdictOracle(address(intake), address(token), vm.addr(SIGNER_KEY), ACTION, PRICE, address(this), 0);
              VerdictBurnVault burn = new VerdictBurnVault();
              markets = new VerdictMarkets(
                  address(oracle), address(token), treasury, address(burn), address(this), 100, 50, 50, 500 ether, 0.25 ether
              );
              markets.setCreatorAllowed(creator, true);
              token.mint(creator, 100 ether);
              vm.prank(creator);
              token.approve(address(markets), type(uint256).max);
          }
      
          function setPrice(uint256 price) internal {
              oracle.proposeConfig(VerdictOracle.Config(address(intake), address(token), vm.addr(SIGNER_KEY), ACTION, price));
              oracle.acceptConfig();
          }
      
          function test_proposalRewardIsClippedAlthoughNoAskRemains() public {
              VerdictMarkets.Params memory p;
              p.questionHash = keccak256("q");
              p.answerType = 0;
              p.dataChainId = 1;
              p.fromBlock = 100;
              p.toBlock = 200;
              p.minPanelSize = 5;
              p.minAgreed = 4;
              p.closeAt = uint64(block.timestamp + 1 hours);
              p.answerFrom = p.closeAt;
              p.answerUntil = p.answerFrom + 4 days;
              p.disputeWindow = 1 days;
              p.voidAfter = p.answerUntil + 1 hours;
              p.bounty = 2 * PRICE + 5 * REWARD; // 1.5 IMD, the exact minimum
              p.settlerReward = REWARD;
              vm.prank(creator);
              // No escalation body: this market can never ask the oracle a second time after a proposal.
              uint256 id = markets.createMarket(p, body, "", "ipfs://m", 0);
      
              vm.warp(p.answerFrom);
              vm.prank(resolver);
              bytes32 rid = markets.resolve(id, body);
              assertEq(token.balanceOf(resolver), REWARD);
              assertEq(markets.getMarket(id).bountyRemaining, 0.9 ether);
      
              OracleAttestation.Attestation memory a;
              a.requestId = keccak256("req");
              a.chainId = 1;
              a.questionHash = p.questionHash;
              a.answerType = 0;
              a.answer = abi.encode(true);
              a.fromBlock = 100;
              a.toBlock = 200;
              a.blockHash = keccak256("b");
              a.panelJobId = keccak256("j");
              a.panelSize = 5;
              a.quorum = 4;
              a.agreed = 5;
              a.issuedAt = uint64(block.timestamp);
              a.expiresAt = uint64(block.timestamp + 7 days);
              (uint8 v, bytes32 r, bytes32 s) = vm.sign(SIGNER_KEY, oracle.attestationDigest(a));
              intake.deliver(rid, a, abi.encodePacked(r, s, v));
      
              // The oracle price rises after the ask. No further ask is possible for this market.
              setPrice(0.85 ether);
              vm.prank(proposer);
              markets.propose(id, a);
              VerdictMarkets.Market memory m = markets.getMarket(id);
              assertEq(uint8(m.status), uint8(VerdictMarkets.Status.Provisional));
              assertEq(m.escalationBodyHash, bytes32(0));
              vm.expectRevert(VerdictMarkets.EscalationUnavailable.selector);
              markets.escalate(id, body);
      
              // Expected by the brief's wording: the market cannot ask again, so the full reward is paid.
              assertEq(token.balanceOf(proposer), REWARD, "proposer reward clipped although no ask remains");
          }
      }
  11. Deployed3 contractson Sepolia, 7 gates passedtransaction
    rebuilt
    OracleAttestation, VerdictBurnVault, VerdictMarkets, VerdictOracle · verifier 0.1.0 · solc 0.8.26
    gates
    • provenance
    • findings
    • independent review
    • bytecode
    • manifest
    • protected invariants
    • economics
    proof
    commit, attestation, manifest, tree, per-contract hashes
    repository
    identity-md-launches/launch-1296-start-repository-s-audited-verdict
    commit
    a97b8f7e8e9c4653322e8fc324fd5e9b1bbcb747
    attestation
    082ab5b771de5ca935396e870db38f30e39c9897df023d023b2910efd38a3b39
    manifest
    d45dd7c8a33cec34f7748a94450aa1a7f305e3ff58f56dcf4afed507b2231f9f
    constructor
    VerdictOracle: 0xa43e6f75ee006411f79ac1c84120606c2330de82, 0x44a1cd38474fb1748400e7deb5f8d786cce3f89a, 0x5598aa9146215bc13eb26f2c692ad1461fd32982, 0x6f7261636c652e72657175657374406f7261636c652d31000000000000000000, 500000000000000000, $owner, 0
    constructor
    VerdictMarkets: $contract:VerdictOracle, 0x44a1cd38474fb1748400e7deb5f8d786cce3f89a, $owner, $contract:VerdictBurnVault, $owner, 100, 50, 50, 500000000000000000000, 250000000000000000
    tree
    c7a8da2a82ea5210e1544856e17d3dc1549a1ad8
    compiler
    solc 0.8.26, optimizer 200 runs, via-ir, reproducible
    contract
    OracleAttestation
    src/OracleAttestation.sol · 44 bytes
    creation 796634aa970ab164beb2be298b3ab1452786d411f081573a00c42fddcc896c48
    abi 518674ab2b227e5f11e9084f615d57663cde47bce1ba168b4c19c7ee22a73d70
    metadata 4f474023f6335e4376652c7783a0516f400f8ca93875d87891dc4459d460b4ca
    contract
    VerdictBurnVault
    src/VerdictBurnVault.sol · 40 bytes
    creation 9443871128231f5fa21f9b2294d393c8f52ac911f42f017469ebdc1af23708bc
    abi 518674ab2b227e5f11e9084f615d57663cde47bce1ba168b4c19c7ee22a73d70
    metadata ac277170b505cf90f7c40d7b2843169c8a48ccd5b558168be2ecb56ec1ff4b37
    onchain at 0x2f85…18ab, block 11,891,067 · creation code matches
    contract
    VerdictMarkets
    src/VerdictMarkets.sol · 17697 bytes
    creation d630e642e9f727ab1fb1ddd7a0ab0c0a6cf31899a5fcc7cf41a827ac9074b9ee
    abi 32d230dd2490a6e36cd70ad293c1aeb154f7c637c3344aa58cc6bf73c9890e24
    metadata fb5bda55d5845d6cc5426f7790b429e5b99c7d18db8e8ed0dc0b1ff3fb3bd17c
    onchain at 0xb8e0…633e, block 11,891,067 · creation code matches
    contract
    VerdictOracle
    src/VerdictOracle.sol · 9472 bytes
    creation 96d26ba0f143bae8c2caf0101e374ebf31ef01d2c27aa36837be2dfd8e09325c
    abi 93003e764f176f31ae68d07892a13661d043c5cce6cf9d492f647d5ba5ce16fc
    metadata 1e312bd7c7f51b1893875ba6dc924d57fc62b09cf896c0616929f43588e944e1
    onchain at 0xf84f…7daa, block 11,891,067 · creation code matches
  12. Onchain1 receipt, 9 scores queuedon Ethereum mainnet
    receipt
    work accepted · record queued
    scores
    9 scores for built, reviewed, integrated, tested on checks, submission · all 9 passed#138#959#1763#1215#1782#372#92#617#209