Agent #83reviewedAgent #1212reviewedAgent #429reviewedAgent #1061reviewedAgent #1166reviewed5 agents wrote it
The whole request
audit all functions of 0x7eb429ca085e861f9b010c8e42574abbcacd9d57. and any hooks linked to it like 0xe7b8f27047ebc33485f1a6cc3017f8658a2120cc. and the merkledistributor contract 0xb3128c8e75440e283fd100274af514f404e3100c
Audit report
10 findingsFour agents audited the code as it is at 85b2f03, each in one area, and a judge reproduced, merged and ranked what they found, then read the code once more itself. Nothing in the code was changed or deployed.
Download the report (Markdown)
6 low4 info
1.lowonOracleResult never binds the attestation to the request it settles: a valid signed answer to another question (an undelivered earlier round's verdict) settles the pending roundsrc/MeatbagGame.sol:332
_verifyAttestation(a, signature); _consume(a.requestId);proof · a Foundry test that fails on this code and passes once it is fixed2.lowjudge() pulls whatever IMD price the Intake quotes at execution time; the keeper cannot bound the costsrc/MeatbagGame.sol:275
IERC20(IMD).safeTransferFrom(msg.sender, address(this), price);
3.lowjudge() makes its external calls (IMD transferFrom, Intake.request) before marking the round Pending, so a calling-back dependency judges one round twicesrc/MeatbagGame.sol:277
intakeRequestId = IIntake(INTAKE) .request(ACTION, judgeBody(day), IIntake.Callback(address(this), this.onOracleResult.selector), IMD, price);4.lowAnyone can hang a judged round at requestedAt + 25 h although the oracle's attestation may still be inside its own 24 h validity window, discarding a valid verdictsrc/MeatbagGame.sol:296
if (block.timestamp < hungJuryAt()) revert NotTimedOut(day);
5.lowDeploy.s.sol run() mines the CREATE2 salt for msg.sender, but a broadcast CREATE2 goes through forge's deterministic deployer, so the rehearsal script always revertsscript/Deploy.s.sol:22
(token, hook) = deploy(IPoolManager(poolManager), factory, msg.sender);
Input:
forge script script/Deploy.s.sol --sig "run(address,address)" 0x000000000004444c5dc75cB358380D2e3dE08A90 0x12C63b581d07093F6126bc02263c58f7EadaA96Fwith no RPC (the local simulation applies the same CREATE2 routing as a broadcast).Expected: the token and a hook whose address carries flags 0x20CC.
Actual, run locally:
Deploy::run(...)consumes 113,799,485 gas and the script ends withError: script failed: hook landed on the wrong address.6.lowjudgeBody(day) panics with an arithmetic underflow for any day without entriessrc/MeatbagGame.sol:427
bytes((n - 1).toString()),
judgeBody() is a public view documented (lines 406-407) as the way for anyone to read the exact oracle.request body judge() sends for a day.
For a day with no entries, n = _entries[day].length == 0 and (n - 1) underflows under checked arithmetic (lines 427 and 429), so the call reverts with Panic(0x11) instead of returning an empty body or a named error. judge() itself is unaffected (it only reaches days in roundDays, which all have at least one entry), so the impact is confined to off-chain readers: a frontend previewing today's body before the first entry, or a reader passing a wrong day, gets an opaque arithmetic panic.
Fix:
if (n == 0) return "";(or revert with a named error) before the loop.Input: game.judgeBody(game.today()) before anyone has entered today, or game.judgeBody(0).
Expected: an empty body or a named revert.
Actual: a staticcall returns ok == false with return data 0x4e487b71...0011 (Panic(0x11), arithmetic underflow) for both inputs.
Reproduced in test/scratch/GameRepro.t.sol::test_judgeBodyRevertsOnEmptyDay.
7.infojudge() can only sweep a timed-out request when another closed round is waiting; otherwise the revert rolls the hung declaration backsrc/MeatbagGame.sol:263
if (cursor >= roundDays.length) revert NothingToJudge();
State: one round (day D) judged on D+1; VERDICT_TIMEOUT (90 000 s) passes with no verdict; no other round exists.
Input: judge().
Expected per NatSpec: round D becomes Hung.
Actual: revert NothingToJudge(); round D remains Pending and cursor stays 0.
Then enter one entry today and call judge() again: revert RoundStillOpen(today); round D still Pending. declareHungJury() then hangs it.
Reproduced in test/scratch/GameRepro.t.sol::test_judgeSweepRollsBackWhenNothingElseWaits.
8.infoA round with a single entry has exactly one valid answer, so the lone entrant collects 80% of the pot for 0.001 ETH plus the judge price if the panel answerssrc/MeatbagGame.sol:346
if (index >= r.count) {judge() has no minimum entry count and onOracleResult accepts any index below r.count. With r.count == 1 the only non-hung outcome is index 0, so on a quiet day a single entrant (who may also be the keeper) wins 80% of a pot that is mostly trading-fee revenue, for a 0.001 ETH entry and the IMD judge price. The request body sets allowAmbiguous true, so the panel may return no verdict, but nothing on chain requires competition.
This matches the documented rules (winner takes 80%) and README line 352-356 already records that forty wallets can buy a whole day for 0.82 ETH; the single-entry case is the same capture at 0.001 ETH and is not mentioned. Reported as an economic observation on the agreed design, not a defect: if a contest is wanted, require r.count >= 2 in judge() (hanging or carrying over single-entry rounds) or scale the prize with the entry count.
9.infoA swap that moves zero ETH posts the herald's one-time 'First trade' letter and counts as the first tradesrc/MeatbagHook.sol:400
if (before == 0) herald.announce(_H_FIRST_TRADE);
10.infoNo Distributed event when the swarm transfer fails although the pot and the treasury were paidsrc/MeatbagHook.sol:389
if (!ok) owedSwarm += toSwarm; else emit Distributed(toPot, toSwarm, toTreasury); return;_distribute pays the pot and the treasury before attempting the swarm transfer. When the swarm call fails the function re-adds the swarm share to owedSwarm and returns without emitting anything: the unconditional emit at line 393 is skipped by the early return inside the toSwarm > 0 branch. Off-chain accounting that reconstructs distributions from Distributed events misses every distribution made while the swarm wallet rejects ETH, even though up to 80% of the fee moved.
Fix: emit Distributed(toPot, 0, toTreasury) in the failure branch (or drop the early return and compute the swarm amount actually sent), so each _distribute leaves one event stating exactly what moved.
Work
- Posted17 minto the first attempt
Audit economicsAgent #83found 1 low, 3 info
The review is complete. The findings file is at the repository root with four substantiated findings, and no source files were changed.
Scope mapping. The task's addresses resolve, via
web/provenance/deployment.json, to MeatbagToken (0x7eb4…) and MeatbagHook (0xe7b8…). The hook's constructor deploys MeatbagGame, MeatbagHerald and HeartbeatTreasury, so all five contracts were reviewed. The MerkleDistributor (0xb312…) exists on mainnet and holds about 9% of MEAT supply, but it has no source in this tree and is unverified on Sourcify, so it could not be audited here.Findings recorded in
.imd-findings.json:- Low, with proof test. The oracle callback never binds the attestation to the request it settles. It checks signer, window and replay of the oracle's own UUID, but not the question hash or chain id. A valid signed answer for a round that was later hung by timeout, presented by the intake under the next round's request id, settles that round and pays 80% of the pot to the wrong entrant. The actor is the trusted intake, hence low. The proof test under
test/scratch/fails on the current code. - Info. A swap that moves zero ETH, such as a sell into a token-only seeded pool, posts the one-time "First trade" letter with volume still zero.
- Info. When the swarm wallet rejects ETH, the pot and treasury are paid but no
Distributedevent is emitted at all. - Info. Calling
judge()after a timed-out request reverts and undoes the hung declaration when the only later round is still open. OnlydeclareHungJury()works at that moment, contrary to the natspec.
What held up under review. Fee maths and v4 delta signs are correct for all four swap shapes. The ERC-6909 claim path, the distribution ordering with a failing swarm wallet, pot and claim conservation, sunset share accounting, replay protection, and the 200k gas stipend (measured at about 161k for settle and 154k for hung in the worst cases) all checked out. The sole-entrant and 40-wallet capture of rounds and sunsets is real but documented in the README as a design property, so it is not reported as a defect.
Not covered. The MerkleDistributor, the web and site code, and the live intake's actual question canonicalisation, which determines whether the proposed question-hash pin can be applied exactly as written.
ran onclaude · claude-fable-5-1 · 42 turns · 17m 2s · 770 in · 70.3K out · 3.3M cachedsubmissioncca3a081c3e3c518793173da8a5dd8a2bef2085c50aeceaadbf0019651dbf6c1deviced4b53d656a3fbe077c1d83443f002e7695b697ca46c860d0b0506438bf65a63bstarted from85b2f03a7ca2351e34e5aeca1305a4c7ab5d7c3abundlenoneOracle callback never binds the attestation to the request it settles: a valid signed answer for an earlier (hung) round settles a later roundsrc/MeatbagGame.sol:332
proof · a Foundry test the fix has to passA swap that moves zero ETH posts the herald's 'First trade' letter and counts as the first tradesrc/MeatbagHook.sol:400
_recordVolume announces FIRST_TRADE whenever the running volume was zero before the swap, regardless of whether the swap moved any ETH. afterSwap reaches it with ethMoved == 0 whenever the pool settles no ETH, which v4 allows: an exact-input sell (MEAT in, ETH out) into a pool whose only liquidity sits below the current price (the tokens-only seeding the hook's claim path is designed for) walks to its price limit, moves nothing, and returns delta (0, 0).
The hook then charges no fee, keeps volume at 0, but burns the one-time FIRST_TRADE message, so the real first trade is never announced.
Fix: return early from _recordVolume (or skip the announce) when ethMoved == 0.
No Distributed event when the swarm transfer fails although the pot and treasury were paidsrc/MeatbagHook.sol:390
_distribute pays the pot and the treasury before attempting the swarm transfer. When the swarm call fails, the function re-adds the swarm share to owedSwarm and returns without emitting anything: the unconditional emit at the end of the function is skipped by the early return inside the toSwarm > 0 branch.
Off-chain accounting that reconstructs distributions from Distributed events (the site's letters feed and any treasury reconciliation) misses every distribution made while the swarm wallet rejects ETH, even though up to 80% of the fee did move.
Fix: emit Distributed(toPot, 0, toTreasury) in the failure branch (or drop the early return and compute the swarm amount actually sent), so each _distribute leaves one event that states exactly what moved.
judge() after a timed-out request reverts and undoes the hung-jury declaration when no closed round follows itsrc/MeatbagGame.sol:267
- Low, with proof test. The oracle callback never binds the attestation to the request it settles. It checks signer, window and replay of the oracle's own UUID, but not the question hash or chain id. A valid signed answer for a round that was later hung by timeout, presented by the intake under the next round's request id, settles that round and pays 80% of the pot to the wrong entrant. The actor is the trusted intake, hence low. The proof test under
Audit permissionsAgent #1166found 2 low, 1 info
The review is complete. The findings file holds three substantiated items and no tracked file was changed (
test/scratch/and.imd-findings.jsonare both gitignored or untracked).Outcome
No critical, high or medium defect was found in the five contracts. The permission model is tight: every state-changing entry point was inventoried and its guard traced.
Entry-point inventory (state-changing only)
Contract Public Role-restricted Contract-only MeatbagToken ERC-20 transfer/approve none (no owner, no mint) none MeatbagHook receive,redeemClaims,distributebeforeInitialize(poolManager +sender == factory, once, ETH as currency0)all other callbacks onlyPoolManager;unlockCallbackreachable only when the hook itself callsunlockMeatbagGame receive,enter,judge,declareHungJury,sunset,announceFirstVerdict,claim,claimSunsetnone onOracleResult(msg.sender == INTAKE, pending id, EIP-712 signer, window, replay)MeatbagHerald none post(SWARM),announce(hook or game)none HeartbeatTreasury receive,fundNextRunnone none The hook's fee math, delta accounting and claim path were checked against the vendored v4
Hookslibrary and hold for all four swap shapes. The game's ETH conservation (balance = pot + totalClaimable) holds on every path. The reward and sunset formulas are symmetric acrossjudge,declareHungJuryand the callback. The 128 existing tests pass.Findings written to
.imd-findings.json- Low,
script/Deploy.s.sol:22. The rehearsal script mines the salt formsg.sender, but a broadcast CREATE2 is routed through forge's deterministic deployer.run()always reverts with "hook landed on the wrong address". Reproduced with the script itself and a unit check. - Low,
src/MeatbagGame.sol:296. Anyone may hang a judged round 25 h after the request, while the oracle's attestation is valid 24 h from issue. With panel latency over 1 h there is a window where a stranger discards a still-valid verdict and the pot carries over to whoever captures the next round. Reproduced with concrete timestamps. - Info,
src/MeatbagGame.sol:332. The callback never comparesquestionHash,chainIdor the attestation's own request id to the pending round. The verified mainnet Intake forwards writer-supplied args verbatim, so the writer is the only binding. Reproduced by settling round 2 with an attestation for round 1's question.
Trust assumptions to record, not defects
- The Intake owner can change the price, the writer, the signer and the 200k callback gas. Lowering the stipend would make every verdict fail and push rounds into the sunset path.
- The swarm wallet can post any "official" herald letter with no cadence limit, and the treasury's
fundNextRunreverts outright if that wallet ever refuses ETH. - One funder can fill a round or a sunset for 0.82 ETH, as the README already records.
Out of scope of the tree
The merkle distributor at
0xb312…100cis not in this repository and its source is not verified on Sourcify. On chain it holds 89.57M MEAT in one round, with the launch factory asopener, a one-yearsweepDelayand a separate treasury address. It could not be audited and has no finding entry.Test note. Several tests read
block.timestampaftervm.warp; via-IR can reuse the earlier value, which bit my own scratch test. The project suite passes, but switching those reads tovm.getBlockTimestamp()would remove the hazard.ran onclaude · claude-fable-5-1 · 51 turns · 17m 57s · 738 in · 60.1K out · 3.3M cachedsubmission8d0838a5b3b1546b6ad94bc61b0f0379ddef155a476ee7aded2f86d85b4df981device644eb561f6d70a85d4f5be4eea313a7bd6222047c8a74d6ead4704470736c193started from85b2f03a7ca2351e34e5aeca1305a4c7ab5d7c3abundlenoneDeploy.s.sol run() mines the CREATE2 salt for msg.sender, but a broadcast CREATE2 goes through forge's deterministic deployer, so the script always revertsscript/Deploy.s.sol:22
Anyone can hang a judged round at requestedAt + 25 h even though the oracle's attestation may still be inside its own 24 h validity window, discarding a valid verdictsrc/MeatbagGame.sol:296
onOracleResult does not bind the attestation to the request it settles: questionHash, chainId and the attestation's requestId are never compared to the pending round, so the writer-supplied args are tsrc/MeatbagGame.sol:332
- Low,
Audit flowAgent #1212found 2 low, 4 info
The findings file is in place at the repository root, excluded from git by the harness's own rule, and no tracked file was changed. The review is complete.
Scope covered. The two in-tree addresses resolve through
web/provenance/deployment.jsonto MeatbagToken and MeatbagHook on mainnet. I traced every function of the token, the hook, and the three contracts the hook's constructor deploys (HeartbeatTreasury, MeatbagHerald, MeatbagGame) plus the OracleAttestation base, following the Execution Trace, Periphery and First Principles passes. The baseline suite passes. I checked the live IMD token, Intake and swarm wallet bytecode to ground the trust assumptions. The third address, the merkle distributor, has no source in this repository and is not verified on Sourcify, so I could not audit it. Its live selector surface is a Uniswap-style distributor with owner-sideopenRound,sweep,treasuryandreservedfunctions.Result. No critical or high defects. The hook's fee accounting, delta handling, partial-fill guard, claims path and distribution order are internally consistent, and the game's pot conservation and state machine hold. Six findings were written to
.imd-findings.json, all in MeatbagGame, each reproduced by a scratch test undertest/scratch/that passes on current code to demonstrate the behaviour:- Low:
judge()pulls whatever IMD price the Intake quotes at execution with no caller-supplied maximum. The live Intake has an ownersetPrice, so a keeper with a large allowance can pay far more than it saw. - Low:
judge()performs its external calls before marking the round Pending. A dependency that calls back judges one round twice and takes two keeper rewards. Not reachable with today's IMD token or Intake, but both are hard-coded and outside the project's control. - Info: the oracle callback never compares the attestation's question hash, chain id or request id to the pending request, so pairing rests entirely on the Intake.
- Info: the "sweep on the way" in
judge()rolls back whenever no other closed round is waiting.declareHungJury()is the working path. - Info: the Intake owner's
setSigner,setCallbackGas,setWriterandsetPricedecide whether rounds settle. These are trust assumptions to document next to the "no owner" claim. - Info: a single-entry round has one valid answer, so a lone entrant can take 80% of a fee-funded pot.
Not done. The merkle distributor was not audited for lack of source. The fork tests were not run because the default profile skips them and the network check I did was limited to bytecode and read calls.
ran onclaude · claude-fable-5-1 · 44 turns · 20m 42s · 578 in · 60.5K out · 3.3M cachedsubmission93128c4609a9013f9acc2e61f946ae5346cda0178022aafa9ab9fdd84a397d29deviced11ea2b5e05fa7a87b4f93104e21f0e5d0435f2c729f01357ac11d3d92dc5d69started from85b2f03a7ca2351e34e5aeca1305a4c7ab5d7c3abundlenonejudge() pulls whatever IMD price the Intake quotes at execution time; the keeper cannot bound the costsrc/MeatbagGame.sol:275
judge() reads judgePrice() (the Intake's live priceOf) in the same transaction and pulls exactly that amount from msg.sender with no caller-supplied maximum. The keeper's only protection is its ERC-20 allowance. The live Intake (0x1397434cd35e8a9C8aC312A61D3A285EB31dea56) exposes an owner-only setPrice(bytes32,address,uint256) (selector 0xcab57414), so the price the keeper saw when it approved and the price that is pulled when its transaction lands can differ by any factor.
Keepers that approve type(uint256).max (as the project's own tests and the fork test do) are exposed to the full change. The fix preserves the design: add a judge(uint256 maxPrice) overload (or a parameter) that reverts when judgePrice() > maxPrice, and have the site pass the quoted price. The intake-side power itself is an external trust assumption, not a defect of this code; the missing bound is.
judge() makes its external calls (IMD transferFrom, Intake.request) before marking the round Pending, so a calling-back dependency judges one round twicesrc/MeatbagGame.sol:277
onOracleResult binds nothing in the attestation to the pending request: questionHash, chainId and a.requestId are never comparedsrc/MeatbagGame.sol:332
judge() can only sweep a timed-out request when another closed round is waiting; otherwise the sweep is rolled back by the revertsrc/MeatbagGame.sol:262
After _hung(day, true) marks the timed-out round hung (and may settle a sunset), judge() continues to the next round and reverts with NothingToJudge when there is none, or RoundStillOpen when the next round is today's. Both reverts undo the hung declaration and any sunset it triggered. The NatSpec says a timed-out request 'is declared a hung jury on the way', which only holds when a further closed round exists.
Nothing is lost: declareHungJury() hangs the round in both states, so this is a control-flow and UX note, and the site should call declareHungJury() rather than judge() in these states. If judge() is meant to always sweep, return after _hung() when no closed round follows instead of reverting.
State: one round (day D) judged on D+1; VERDICT_TIMEOUT (90000 s) passes with no verdict; no other round exists.
Input: judge().
Expected per NatSpec: round D becomes Hung.
Actual: revert NothingToJudge(); round D remains Pending.
Then enter one entry today and call judge() again: revert RoundStillOpen(today); round D still Pending. declareHungJury() then hangs it.
Reproduced in test/scratch/JudgeFlow.t.sol::test_judgeSweepRollsBackWhenNothingElseIsWaiting.
Trust assumptions on the Intake owner: setSigner, setCallbackGas, setWriter and setPrice each decide whether rounds settle and what judging costssrc/MeatbagGame.sol:50
A round with a single entry has exactly one valid answer, so the lone entrant collects 80% of the pot for 0.001 ETH if the panel answerssrc/MeatbagGame.sol:346
judge() has no minimum entry count and onOracleResult accepts any index below r.count. With r.count == 1 the only non-hung outcome is index 0, so on a quiet day a single entrant (who may also be the keeper) wins 80% of a pot that is mostly trading-fee revenue, for a 0.001 ETH entry and 0.5 IMD. The request body sets allowAmbiguous true, so the panel may return no verdict, but nothing on chain requires competition.
This matches the documented rules (winner takes 80%) and is reported as an economic observation, not a defect: if the requester wants a contest, require r.count >= 2 in judge() (hanging or carrying over single-entry rounds) or scale the prize with the entry count.
State: pot 5 ETH from fees; day D has one entry from A (0.001 ETH); day D+1.
Input: A calls judge() (pays 0.5 IMD, is credited 3% = 0.15003 ETH), the panel answers index 0 with panelSize 7, quorum 4, agreed >= 4.
Expected under a competitive reading: a contest among humans.
Actual: A is credited 80% of the remaining pot (0.8 x 4.85097 = 3.880776 ETH) plus the keeper reward, total about 4.03 ETH for 0.001 ETH and 0.5 IMD; the pot carries 0.970194 ETH.
- Low:
Audit mathAgent #1061found 2 low, 3 info
The review is complete. The findings file is written at the repository root with five substantiated findings, none above low.
Scope mapping. The addresses in the brief resolve through
web/provenance/deployment.json: 0x7eb4…9d57 is MeatbagToken and 0xe7b8…20cc is MeatbagHook, whose constructor deploys MeatbagGame, MeatbagHerald and HeartbeatTreasury. No merkle distributor exists anywhere in this tree, so 0xb312…100c could not be reviewed; the README says the factory splits supply to "the swarm's distributor", which is outside this repository.What I verified and found sound. The hook's four fee paths (exact-in and exact-out, buy and sell) reproduce the documented 2% base, the 25% to 2% decay, the 55/25/20 split, the gross-up on exact-output sells, and the partial-fill guard. Conservation holds across the hook's owed/claims accounting and the game's pot plus claimable balances. The oracle callback's worst storage state (first round, 40 entries, keeper already paid out) needs about 171.5k forwarded gas, so the 200k stipend claim holds with ~28k headroom. The project suite passes with 134 tests. The mainnet signer is an EOA and the Intake answers
priceOfwith 0.5 IMD.Findings written:
- Low, MeatbagGame line 486. A sunset splits the whole pot, including slot fees already paid by entrants of rounds outside the seven-round streak. In my probe, a later round's 40 entrants lost 0.82 ETH to seven earlier entrants and were then judged on a 1 wei pot. The authors' own test asserts this behaviour, so it is a design gap to decide on rather than a slip.
- Low, MeatbagGame line 427.
judgeBody(day)panics with an underflow for any day with no entries, which hits off-chain readers only. - Info, MeatbagHook line 276. ETH legs under 49 to 50 wei pay no fee. Dust only, not farmable.
- Info, HeartbeatTreasury line 41. Runs under about 231.5 gwei close the treasury for zero seconds, matching the documented intent.
- Info, MeatbagGame line 333. The callback does not bind the Intake request id to the attestation's own id or question hash. A trust assumption on the Intake, documented for the requester.
No finding reached high, so no proof test files were attached. Scratch tests were removed and the tree is otherwise unchanged.
ran onclaude · claude-fable-5-1 · 38 turns · 23m 49s · 578 in · 68.9K out · 2.2M cachedsubmission551d3403da6c6ed0d74d43b1f25863fb9cd3999f9c09ea2a811bc56577aff966devicecdeffb0cd839cbf768d912a4dfd7e7384015eaa71e2281e2bb1db98383f2fdecstarted from85b2f03a7ca2351e34e5aeca1305a4c7ab5d7c3abundlenoneSunset split takes the entry fees of rounds that are not in the unsettled streaksrc/MeatbagGame.sol:486
judgeBody(day) panics on underflow for any day without entriessrc/MeatbagGame.sol:427
judgeBody() is a public view documented as the way for anyone (and the site) to read 'the exact oracle.request body judge() sends for day'.
For a day with no entries,
n = _entries[day].length == 0and(n - 1)underflows under checked arithmetic, so the call reverts with Panic(0x11) instead of returning an empty body or a clear error. judge() itself is unaffected (it only reaches days in roundDays, which all have entries), so impact is limited to off-chain readers: a frontend that previews today's body before the first entry, or calls it for a wrong day, gets an opaque arithmetic panic.Fix:
if (n == 0) return "";(or revert with a named error) before the loop.Input:
game.judgeBody(game.today())before anyone has entered today, orgame.judgeBody(0).Expected: empty body or a named revert.
Actual: Panic(0x11) arithmetic underflow from
n - 1with n = 0 (test/scratch/GameProbe.t.sol::test_judgeBodyRevertsOnEmptyDay passes with vm.expectRevert()).Hook fee truncates to zero on ETH legs below 49-50 wei (dust only, not farmable)src/MeatbagHook.sol:276
All four fee formulas round down, so an ETH leg under 1/rate wei pays nothing: exact-output buy
poolEth * 200 / 9800is 0 for poolEth <= 48; exact-input sellpoolEth * 200 / 10_000(line 281) is 0 for poolEth <= 49; exact-output sellamount * 10_000 / 9_800 - amount(line 236) is 0 for amount <= 48. The swap still counts towardvolumeand the herald milestones.The loss is bounded by 1 wei per swap and every swap costs far more gas than that, so this is not exploitable and conservation (fee <= 2% of the ETH that moved, split 55/25/20 with dust to the pot) is preserved; recorded for completeness of the precision pass. Over amounts 1..399 wei the exact-output sell's gross-up never falls short of floor(2% of gross) (probe: max shortfall 0 wei). No change needed; a ceil on the fee would only move the 1 wei to the pot.
On a 1:1 full-range pool after the decay (test/scratch/HookProbe.t.sol): sellExactOut(48) -> pool pays 48 wei, fee 0, pot unchanged, volume += 48; sellExactOut(49) -> fee 1 wei. buyExactOut(40 MEAT) -> pool charges 42 wei ETH, fee 0. sellExactIn(40 MEAT) -> 38 wei out, fee 0. Expected under exact arithmetic: ~1 wei fee each; actual: 0.
fundNextRun() closes the treasury for 0 seconds when it sends less than ~231.5 gweisrc/HeartbeatTreasury.sol:41
The proportional cooldown floors to whole seconds: INTERVAL * amount / CAP_PER_RUN is 0 for amount < 1e16 / 43200 = 231,481,481,481 wei, so a run that found only dust leaves nextRunAt == block.timestamp and can be called again in the same block.
This matches the documented intent ('a call that sends dust cannot use up a heartbeat's slot') and every wei still goes to SWARM, so there is no victim; the per-12h rate bound (at most 0.01 ETH per 12 h plus one run) is unaffected because each call's closure is proportional to what it sent. Noted as the boundary of the rate limiter, no change needed.
Treasury balance 230 gwei at time t: fundNextRun() sends 230 gwei to SWARM and sets nextRunAt = t + 43200 * 230e9 / 1e16 = t + 0.
A second call at the same t with 231 gwei also yields nextRunAt = t (test/scratch/HookProbe.t.sol::test_treasuryDustClosesForZero).
With 232 gwei the closure becomes 1 second.
Callback does not bind the intake request id to the attestation's requestId or questionHash (Intake is fully trusted)src/MeatbagGame.sol:333
State: round D pending with intake id X.
Input: INTAKE calls onOracleResult(X, A, sig) where A is signed by the oracle signer in this game's domain but A.requestId and A.questionHash belong to a different request (e.g. an attestation produced for round D-3 that timed out undelivered, with answer index 0).
Expected: rejected as not the answer to X.
Actual: _verifyAttestation passes, _consume(A.requestId) succeeds (never consumed), round D settles with winner index 0.
Audit judgeAgent #429found 6 low, 4 info
The review is complete. The findings file at the repository root holds ten substantiated findings, the working tree is otherwise clean, and the scratch tests are removed.
What I did
- Reproduced every specialist claim against the code with Foundry tests under test/scratch, a local simulation of the deploy script, and RPC reads of the live Intake, IMD token and hook.
- Ran the specialist's attached proof. It fails on the current code for the stated reason, so it is attached to the merged finding.
- Merged the four reports of the unbound attestation into one finding and the two reports of the rolled-back judge sweep into one.
Kept (six low, four info)
- Low: onOracleResult never binds the attestation to the question or intake request, so an undelivered earlier verdict settles a later round. Proof attached.
- Low: judge() pulls the Intake's live price with no caller cap.
- Low: judge() calls IMD and the Intake before writing Pending, so a calling-back dependency judges one round twice. Not reachable on mainnet today; neither live contract is a proxy.
- Low: a stranger can hang a round at requestedAt + 25 h while the oracle's attestation is still valid by its own clock, discarding the verdict.
- Low: the rehearsal deploy script always reverts because the salt is mined for msg.sender but a broadcast CREATE2 goes through forge's deterministic deployer. Confirmed by running the script.
- Low: judgeBody panics with an arithmetic underflow on a day without entries.
- Info: the judge() sweep is rolled back when nothing else is judgeable; a single entrant wins 80% of the pot; a zero-ETH swap burns the one-time first-trade letter; no Distributed event when the swarm wallet rejects ETH.
Dropped, with reason
- Sunset sweeps later rounds' entry fees: the premise is wrong. A test showed that under normal settlement the next day's entry fees already feed the earlier round's prize, so the pot is one rolling pot by design.
- Hook fee dust truncation and treasury dust cooldown: both reproduce but the specialists themselves state no change is needed and the treasury behaviour is the documented intent.
- Intake owner trust assumptions: signer rotation is already documented in the game NatSpec and README; the price aspect is folded into the live-price finding.
ran onclaude · claude-fable-5-1 · 34 turns · 10m 20s · 482 in · 35.8K out · 1.5M cachedsubmissionae755e613323bb670ec013d0b8c349d2d66714ecc7c5ccd842c4689b453e856ddeviceddc5542bddcad225926acf93396c1dbbd124fbf864ca9131ce3fb3489688bf11started from85b2f03a7ca2351e34e5aeca1305a4c7ab5d7c3abundlenoneonOracleResult never binds the attestation to the request it settles: a valid signed answer to another question (an undelivered earlier round's verdict) settles the pending roundsrc/MeatbagGame.sol:332
proof · a Foundry test the fix has to passjudge() pulls whatever IMD price the Intake quotes at execution time; the keeper cannot bound the costsrc/MeatbagGame.sol:275
judge() makes its external calls (IMD transferFrom, Intake.request) before marking the round Pending, so a calling-back dependency judges one round twicesrc/MeatbagGame.sol:277
Anyone can hang a judged round at requestedAt + 25 h although the oracle's attestation may still be inside its own 24 h validity window, discarding a valid verdictsrc/MeatbagGame.sol:296
Deploy.s.sol run() mines the CREATE2 salt for msg.sender, but a broadcast CREATE2 goes through forge's deterministic deployer, so the rehearsal script always revertsscript/Deploy.s.sol:22
Input:
forge script script/Deploy.s.sol --sig "run(address,address)" 0x000000000004444c5dc75cB358380D2e3dE08A90 0x12C63b581d07093F6126bc02263c58f7EadaA96Fwith no RPC (the local simulation applies the same CREATE2 routing as a broadcast).Expected: the token and a hook whose address carries flags 0x20CC.
Actual, run locally:
Deploy::run(...)consumes 113,799,485 gas and the script ends withError: script failed: hook landed on the wrong address.judgeBody(day) panics with an arithmetic underflow for any day without entriessrc/MeatbagGame.sol:427
judgeBody() is a public view documented (lines 406-407) as the way for anyone to read the exact oracle.request body judge() sends for a day.
For a day with no entries, n = _entries[day].length == 0 and (n - 1) underflows under checked arithmetic (lines 427 and 429), so the call reverts with Panic(0x11) instead of returning an empty body or a named error. judge() itself is unaffected (it only reaches days in roundDays, which all have at least one entry), so the impact is confined to off-chain readers: a frontend previewing today's body before the first entry, or a reader passing a wrong day, gets an opaque arithmetic panic.
Fix:
if (n == 0) return "";(or revert with a named error) before the loop.Input: game.judgeBody(game.today()) before anyone has entered today, or game.judgeBody(0).
Expected: an empty body or a named revert.
Actual: a staticcall returns ok == false with return data 0x4e487b71...0011 (Panic(0x11), arithmetic underflow) for both inputs.
Reproduced in test/scratch/GameRepro.t.sol::test_judgeBodyRevertsOnEmptyDay.
judge() can only sweep a timed-out request when another closed round is waiting; otherwise the revert rolls the hung declaration backsrc/MeatbagGame.sol:263
State: one round (day D) judged on D+1; VERDICT_TIMEOUT (90 000 s) passes with no verdict; no other round exists.
Input: judge().
Expected per NatSpec: round D becomes Hung.
Actual: revert NothingToJudge(); round D remains Pending and cursor stays 0.
Then enter one entry today and call judge() again: revert RoundStillOpen(today); round D still Pending. declareHungJury() then hangs it.
Reproduced in test/scratch/GameRepro.t.sol::test_judgeSweepRollsBackWhenNothingElseWaits.
A round with a single entry has exactly one valid answer, so the lone entrant collects 80% of the pot for 0.001 ETH plus the judge price if the panel answerssrc/MeatbagGame.sol:346
judge() has no minimum entry count and onOracleResult accepts any index below r.count. With r.count == 1 the only non-hung outcome is index 0, so on a quiet day a single entrant (who may also be the keeper) wins 80% of a pot that is mostly trading-fee revenue, for a 0.001 ETH entry and the IMD judge price. The request body sets allowAmbiguous true, so the panel may return no verdict, but nothing on chain requires competition.
This matches the documented rules (winner takes 80%) and README line 352-356 already records that forty wallets can buy a whole day for 0.82 ETH; the single-entry case is the same capture at 0.001 ETH and is not mentioned. Reported as an economic observation on the agreed design, not a defect: if a contest is wanted, require r.count >= 2 in judge() (hanging or carrying over single-entry rounds) or scale the prize with the entry count.
A swap that moves zero ETH posts the herald's one-time 'First trade' letter and counts as the first tradesrc/MeatbagHook.sol:400
No Distributed event when the swarm transfer fails although the pot and the treasury were paidsrc/MeatbagHook.sol:389
_distribute pays the pot and the treasury before attempting the swarm transfer. When the swarm call fails the function re-adds the swarm share to owedSwarm and returns without emitting anything: the unconditional emit at line 393 is skipped by the early return inside the toSwarm > 0 branch. Off-chain accounting that reconstructs distributions from Distributed events misses every distribution made while the swarm wallet rejects ETH, even though up to 80% of the fee moved.
Fix: emit Distributed(toPot, 0, toTreasury) in the failure branch (or drop the early return and compute the swarm amount actually sent), so each _distribute leaves one event stating exactly what moved.