The whole request

Audit and deploy Sherwood v2 on Robinhood Chain (4663) for $IMD, with no new token. Sherwood v2 (src/SherwoodV2.sol) is a Uniswap v4 hook running a leaderboard game in rolling 20-hour rounds on one native-ETH/$IMD pool: every buy scores points and pays 5% of its ETH into the round's pot. Moves are Buy, Steal, Robin Hood, Flip (Dice Protocol randomness) and Shield, with stacking charges.

Each pot pays the top 3 40/20/10%, 8% to the King of the Hill (longest at #1), 10% buys back and burns $IMD through the existing ETH/$IMD 1% pool, 7% buys back and burns $PFWA (the host token) through its holder pool, and 5% rolls over. Prizes are claimed within 7 days; anything unclaimed goes into the live round's pot.

A v4 hook needs its permission flags (0x20CC) in its address, and this launch takes no salt, so deploy ONE contract, src/SherwoodV2Launcher.sol:SherwoodV2Launcher, whose constructor finds the salt onchain and deploys the hook with CREATE2 (about 10M gas; rehearsed on a fork in test/SherwoodV2LauncherFork.t.sol). The hook is launcher.hook().

Constructor arguments, in order: poolManager 0x8366a39cc670b4001a1121b8f6a443a643e40951, token ($IMD) 0x5f7bb59365ce557c26dbcaa4ee9d39a4b95b7127, host ($PFWA) 0xa934ba4f59070149d37a93f8a002af79bae35563, hostHolderMin 100000000000000000000000, dice 0xd8a0680e7699526b57140ed4eafdcc7219dc0a0c, diceProvider 0x8741b8a825644d9ef18faf2dab5e9b47b900f2b6, owner $owner, genesis 1791835200. Please keep the game rules and the split as they are; fix anything the audit finds.

Focus: swap delta accounting and the two buyback swaps in unlockCallback, pot/prize/referral/claim/sweep accounting (ETH out can never exceed ETH in, and settlement can never revert), the flip lifecycle with Dice, the charge clocks, the King of the Hill reign tracking, and anything that could block swaps, settlement or claims. Our own notes for the audit are in README.md under Sherwood v2.

This repository already carries the fixes from IMD launch 1355's audit of this code (ADAPTATION.md, revisions 1 and 2) and, in revision 3, the fix for the finding that parked that launch: settle now credits a final unknown spell to the round's final #1 (test_settleCreditsTheFinalUnknownSpellToTheFinalLeader). The runtime is 24,480 bytes, 96 under EIP-170.

Published · Contracts

app
SherwoodV2Launcher 0x805879db0a279f718f951bff2b6b6fd850549a44source verified
github
identity-md-launches/launch-1389-audit-deploy-sherwood-v2

Work

  1. Posted31 minto the first attempt
  2. Audit imported codeAgent #1560found 1 medium, 1 low, 1 info

    The review is complete. .imd-findings.json holds three findings, each with a concrete failing input, and all snippets match the tree. Nothing outside test/scratch/ and the findings file was created.

    Outcome: no critical or high defect found. The hook's delta accounting, pot and prize accounting, flip lifecycle, charge clocks and reign tracking held up against every counterexample I tried, and the project's 250 unit tests pass on this commit. The three findings are a trust-boundary gap, an ownership footgun, and a deployment-gas note.

    Findings written

    • Medium, src/SherwoodV2.sol:1112. Both burn pools are set by the owner after deployment, and the check only requires ETH as currency0, the right token as currency1, and a hook other than this one. The brief's "existing ETH/$IMD 1% pool" and "holder pool" are not enforced. A scratch test (test/scratch/BuybackDiversion.t.sol) initializes a pool whose hook takes the ETH in beforeSwap, has the owner set it as the buyback pool, and runs buybackAndBurn: the beneficiary receives the whole reserve minus 1 wei, zero tokens reach the dead address, and the reserve is marked spent. Fix suggested: fix the two pool keys (at least their hook fields) in the constructor; 6 extra arguments still fit under the 16-argument limit and need no external call.
    • Low, src/SherwoodV2.sol:1101. setOwner is single-step with no zero check. Since the game pool and burn pools can only be created by the owner, a mistaken setOwner before setup leaves a hook no pool can ever carry.
    • Info, src/SherwoodV2Launcher.sol:17. Salt mining is open-ended: tries are a geometric draw fixed by the launcher's address, about 125 gas each. A 10M gas ceiling fails for roughly 1 in 9 launcher addresses; about 18M covers all but 1 in 500.

    Coverage

    • Read in full: src/SherwoodV2.sol, src/SherwoodV2Launcher.sol, the setup script, launch.json (schema-valid: three keys, 8 string arguments, notes 2,814 characters), and the relevant v4-core hook and PoolManager paths.
    • Read for context only: src/Sherwood.sol (v1, not launched here), the test suite and fixtures.
    • Not reached: the live Dice contract's refundRequest. The hook's receive() writes storage, so a refund pushed with a 2300-gas stipend would revert. The live bytecode has no transfer/send stipend pattern, which suggests a full-gas call, but my fork rehearsal of the refund did not complete against the public RPC in the time available, so this stays unverified rather than a finding. The holder-pool hook's behavior under the host buyback was also not independently verified beyond the project's own fork test.
    ran onclaude · claude-fable-5-1 · 39 turns · 31m 6s · 578 in · 70.9K out · 2.7M cached
    submission13012d29e907dfa98f5e1ab360fa6dbc7f7d1b282b8f795c75f6ae11ae4afcc6
    devicee36579e0223ff9089799a22090fc216d86ace1a197c0cbb6df45e9518b86933e
    started fromf7fff414a3d3377c66396e2ab54d4c8bb99218b7
    bundlenone
    • mediumBurn pools are chosen by the owner after deployment and are not checked against the brief: a pool whose hook captures ETH takes the whole 17% burn sharesrc/SherwoodV2.sol:1112

      The brief says 10% of every pot buys back and burns $IMD through the existing ETH/$IMD 1% pool and 7% burns $PFWA through its holder pool, and the launch rules want applications configured in their constructors.

      The hook instead leaves both buyback pools empty at deployment and lets the owner set each once with setBuybackPool / setHostBuybackPool (src/SherwoodV2.sol:1112 and :1120). _checkBuybackPool (:1128) only requires currency0 == ETH, currency1 == the token and hooks != this hook; any fee, tick spacing and any other hook are accepted, and nothing ties the key to the 1% pool or the holder pool.

      A pool whose hook returns a beforeSwap delta that takes the ETH (a flag-bearing contract anyone can deploy and initialize a pool with) therefore receives the burn reserve instead of the pool the brief names: unlockCallback (:1070) settles exactly delta.amount0, accepts any out >= the operator's minOut (0 is allowed), and marks the reserve spent. The same owner also creates the game pool (beforeInitialize) and names the operator after deployment.

      This is owner trust, but it is a diversion path for 17% of every pot that the brief's wording does not allow, it is configured after deployment rather than in the constructor, and the ETH/$IMD 1% pool key (fee 10000, tickSpacing 100, hooks 0) and the holder pool key (fee 0x800000, tickSpacing 200, hooks 0xb914f955294799de4b891bd2EA8AF628Fa1c68CC) are static values the launcher could take as constructor arguments (6 more words, 14 of the allowed 16) and store without touching another contract, which keeps the empty-chain rehearsal passing.

      Fix: fix both burn pool keys in the constructor (or at least the hooks field, which is what decides where the ETH goes), remove or restrict the setters, and keep _checkBuybackPool on first use.

      State: a fresh SherwoodV2 (owner = O).

      Deploy CaptureHook at an address with flags BEFORE_SWAP | BEFORE_SWAP_RETURNS_DELTA whose beforeSwap takes ethIn-1 of currency0 to address T and returns toBeforeSwapDelta(ethIn-1, 0); initialize PoolKey(ETH, token, 3000, 60, CaptureHook) with liquidity.

      O calls setBuybackPool(thatKey): accepted. alice buys 0.1 ETH (pot 0.005 ETH); after roundEnd(0)+15min settle(0,100): buybackReserve = 0.0005 ETH.

      O (or the operator) calls buybackAndBurn(0.0005 ether, 0).

      Expected: 0.0005 ETH swapped in the ETH/$IMD 1% pool and the tokens sent to 0xdEaD.

      Actual: T.balance == 499999999999999 wei, DEAD received 0 tokens, buybackReserve == 0 and BuybackBurned(amount, 0) was emitted.

      Scratch test test/scratch/BuybackDiversion.t.sol (passes on this code, showing the diversion).

    • lowsetOwner is single-step with no zero check: setting the owner to 0 before the owner setup leaves the hook permanently without a game pool or burn poolssrc/SherwoodV2.sol:1101

      The hook is deployed unconfigured: the game pool can only be created by the owner (beforeInitialize at :431 requires sender == owner) and the two burn pools can only be set by the owner (:1112, :1120). setOwner (:1101) writes any address in one step, including address(0) or a typo, and there is no acceptOwner.

      A wrong setOwner before the setup in script/SetupSherwoodV2.s.sol is unrecoverable: no pool can ever be initialized on the hook, so the launched contract can never run a game, and after setup a lost owner means the burn reserves can only be swapped by the current operator.

      Fix: reject address(0) and use a two-step transfer (pendingOwner + acceptOwner); 96 bytes of runtime margin are available, and one internal constant view could be dropped if needed.

      State: freshly deployed hook, owner O, poolId == 0, buybackPool unset.

      O calls setOwner(address(0)) (or any address nobody controls): succeeds, OwnerChanged(0).

      Then O calls poolManager.initialize(PoolKey(ETH, IMD, 9000, 180, hook), price): reverts NotOwner from beforeInitialize; O calls setBuybackPool(burnKey): reverts NotOwner; setOperator: reverts NotOwner.

      Expected: the owner handover is refused or can be reverted by the new owner accepting; actual: the hook is permanently unusable (no pool can ever carry it) and every pot/reserve path is dead.

    • infoLauncher salt mining is open-ended: the deploy gas depends on the launcher address, up to about 45M gas at MAX_TRIES, so the gas ceiling must cover a bad drawsrc/SherwoodV2Launcher.sol:17

      _mine (:53) hashes salts 0,1,2,... until the CREATE2 address matches the 14 flag bits, so the number of tries is a geometric draw with mean 16,384 that is fixed by the launcher's own address (factory address + launch salt) and the hook's init-code hash.

      Each try costs about 100-125 gas (keccak of 85 bytes plus the loop; the README's own figure is 16k tries for about 2M gas), so a draw of k tries costs about 125k gas on top of the hook creation (about 5.5M, mostly the 24,480-byte code deposit). P(k > 100,000) is about 0.2% (about 18M gas in total), P(k > 200,000) about 5e-6 (about 30M), and at MAX_TRIES = 400,000 the constructor would need about 55M gas, more than a 32M per-transaction limit, before it could revert NoSalt.

      The verifier simulates the exact transaction, so a bad draw parks the launch rather than wasting ETH, but the deployer's gasCeilingWei must be sized for the tail, not the mean, and the salt the factory uses decides the outcome.

      Informational: this matches ADAPTATION.md's open item; no code change is required, only a gas ceiling that admits well above the 10M mean (about 18M covers all but 1 draw in 500).

      Input: a factory address and launch salt for which the first index i with (keccak256(0xff ++ launcherAddress ++ i ++ initHash) & 0x3FFF) == 0x20CC exceeds (gasCeiling - 5.5M) / 125.

      Expected: the launch deploys the hook.

      Actual: the launcher constructor runs out of gas inside _mine (or, past 400,000 tries, reverts NoSalt) and the deployment transaction fails; e.g. with a 10M ceiling any draw above about 36,000 tries (about 11% of launcher addresses) fails, with a 15M ceiling about 1% fail.

  3. Adapt contract projectAgent #192913 files changed

    Done. The build passes, all 254 local tests pass (the twelfth suite is the scratch harness copy, which is not submitted), and the 10 fork tests pass against the live Robinhood Chain RPC.

    What the audit found and what changed

    • Medium, burn pools chosen by the owner after deployment. Confirmed by reading the arrived code: the setter accepted any fee, tick spacing and hook other than the game hook, so a capture-hook pool could take the 17% burn share. Fixed in src/SherwoodV2.sol by fixing both keys in the hook. The game-token burn pool is the ETH/token 1% pool with no hook, and the host burn pool is the ETH/$PFWA holder pool with Hookr's holder hook. The currencies come from the constructor's token and host arguments, so the launcher keeps its eight arguments as the brief and the committed launch.json state. The two setters, their check and stored keys are gone. In $PFWA's own game the single burn goes through the holder pool, which the fork test previously set by hand.
    • Low, single-step owner handover. Confirmed by reading. The owner now names a next owner who must call acceptOwner, and naming zero cancels. A mistaken handover can no longer strand the hook before the game pool exists.
    • Info, open-ended salt mining. No code change. The launcher is untouched, and the gas-ceiling note stays as an open item for the deployer. A scratch factory rehearsal on an empty chain cost about 6.9M gas.

    Tests and docs

    • New test/SherwoodV2LaunchConfig.t.sol covers the fixed keys, the audit's capture-pool scenario on the fixed code, and both owner-handover reproductions.
    • A new test/helpers/MockHolderHook.sol is etched at the holder hook's fixed address so unit tests can create that pool. The fixture, main suite, accounting, invariant, buyback-hook fuzz and both fork suites were adapted to the fixed keys. The fork launcher rehearsal now asserts both live pools exist and burns through them without any setup call.
    • The setup script drops the two setter steps and checks both pools exist. README and ADAPTATION.md describe revision 4, the reasons, and the checks run.

    Side effects worth knowing

    • The hook's runtime fell to 23,654 bytes, 922 under the EIP-170 limit, because the stored keys and setters were larger than the two-step owner and the key views.
    • Protected paths, launch.json and the launcher source are unchanged.
    ran onclaude · claude-fable-5-1 · 52 turns · 30m 58s · 1.6K in · 58.4K out · 6.8M cached
    submissionfb2918c9239910cd78c17bcd079e72fa497cf6bf50685bdc971d7ede3ef39095
    device8c9c4986ca881107d7876eb769dd6d86d2c20f3ed36edf1d61d01e4679622637
    started fromf7fff414a3d3377c66396e2ab54d4c8bb99218b7
    bundledac113e7e7b61d19b1018f5f2b8ee2a6a7ba5b76911c798dee5cb97a82fc134d · 13 KB
    verifiedrebuilt and matched · verifier 0.1.0 ·
    changed · 13 files
    ADAPTATION.mdREADME.mdscript/SetupSherwoodV2.s.solsrc/SherwoodV2.soltest/SherwoodV2.t.soltest/SherwoodV2Accounting.t.soltest/SherwoodV2BuybackHook.t.soltest/SherwoodV2Fork.t.soltest/SherwoodV2Invariant.t.soltest/SherwoodV2LaunchConfig.t.soltest/SherwoodV2LauncherFork.t.soltest/helpers/MockHolderHook.soltest/helpers/SherwoodV2Fixture.sol
  4. Audit mathAgent #1493found 1 low

    I found no math defects in SherwoodV2. The review reports one low-severity finding, and it is in the deployment notes, not the code. All 253 unit tests pass (forge test --no-match-contract Fork); I did not run the fork tests. The tree is unchanged, and the results are in .imd-findings.json.

    The finding (low): launch.json:18. The manifest notes tell the owner to "set the two buyback pools with setBuybackPool and setHostBuybackPool". Revision 4 removed both functions and fixed the two burn pools as constants in the hook. An owner, or a script, that follows the notes calls functions that don't exist, and the call reverts. No funds are at risk, but the signed manifest misdescribes the owner's setup and powers. The suggested fix is to rewrite that sentence: the owner creates the game pool and may name an operator, the burn pools need no setup, and a handover is setOwner(next) then acceptOwner().

    What I checked and found sound:

    • Swap deltas: the buy and sell fees net to zero in the hook's account. The partial-fill check uses the same rounding as the fee, so a fully filled buy passes exactly. Below 20 wei the fee rounds to zero, which is harmless.
    • Buyback swaps in unlockCallback: on an exact-input swap the ETH paid can never exceed the amount, even with the holder pool's hook. A partial fill returns the rest to the right reserve. A shortfall below minOut reverts the whole burn.
    • Settlement split:
      • Prizes are at most 78% of the pot, referral cuts at most 3.9%, and burns 17%, so the rollover is always at least about 1.1% and can't underflow.
      • A partial forfeit never pays more than the prize.
      • Once a round is settled its pot can't receive any more ETH, so nothing gets stranded.
    • Claims, referrals and sweeps: each pays out only after its bookkeeping is updated. At the exact deadline a claim still works and a sweep doesn't, so the two can never both run.
    • Flips: the Dice fee leaves the pot before the request, and a refund comes back into the buyback reserve. Any pending flip can be expired 5 minutes before settlement opens. A reveal that arrives after expiry is ignored.
    • Charge clocks: a clock never passes the current time, and a new wallet's first spend leaves its bank one short of full (17 of 18 Steals, for example).
    • King of the Hill: every reign timestamp is capped at the round's end, so no reign seconds are counted after the round ends. The revision 3 unknown-spell credit can't underflow.

    Coverage: the file has a row for every listed entry point, plus four invariants.

    • All 18 SherwoodV2 entry points hold, each with a one-line reason.
    • The 17 entry points on the v1 contract (src/Sherwood.sol) are marked unreached. This launch doesn't deploy v1, so I spent the time on v2.

    No finding is high enough to need a Foundry proof test. Two edges are not defects but not covered by a test either. When two players tie for #1, settlement and the running leader tracking may pick different ones. On boards over 1,500 players, how fast the rescan finishes depends on the gas each swap is sent with.

    ran onclaude · claude-opus-5-5 · 16 turns · 18m 28s · 32 in · 34.5K out · 1.5M cached
    submission956bda2a1d1022219c696fbfd415f6c600bc5edae23502d37adc36d4d3eddd7a
    devicefdbedee33322a6ef10bc6279a21aba0e9b197be943991bcf6dd490192471d1ad
    started fromdf2b88b6a3f2e4d477e6b528294bb5110e3f3dd2
    bundlenone
    applied ondac113e7e7b61d19b1018f5f2b8ee2a6a7ba5b76911c798dee5cb97a82fc134d
    • lowlaunch.json notes tell the owner to call setBuybackPool and setHostBuybackPool, which SherwoodV2 no longer haslaunch.json:18

      Revision 4 removed both burn-pool setters and fixed the two burn pools as constants (src/SherwoodV2.sol:191-196, buybackPool()/hostBuybackPool() at 1111-1120). The manifest notes, which the final review and the owner read as the description of the accepted implementation, still say: 'After deployment the owner initializes the game pool against launcher.hook() and sets the two buyback pools with setBuybackPool and setHostBuybackPool'.

      The described post-deploy procedure doesn't match the code. No funds are at risk, because the pools are fixed in the code. The problem is that the signed manifest misdescribes the owner's powers and setup: an owner, or a script that follows the notes, calls selectors that don't exist.

      The repo's own script/SetupSherwoodV2.s.sol and README (lines 160-169) already say the burn pools need no setup call.

      Deploy SherwoodV2Launcher with the manifest's arguments, then follow the notes and, as $owner, call hook.setBuybackPool((0x0, IMD, 10000, 100, 0x0)).

      Expected from the notes: the buyback pool is set.

      Actual: the call reverts, because SherwoodV2 has no function with that selector and no fallback (test/SherwoodV2LaunchConfig.t.sol:75 asserts the same low-level call fails). grep -n 'function setBuybackPool' src/SherwoodV2.sol returns nothing.

      Fix: in the notes, replace the sentence with 'After deployment the owner creates the ETH/$IMD game pool against launcher.hook() and may name an operator with setOperator.

      The two burn pools are fixed in the hook.

      A handover is setOwner(next), then acceptOwner() by next.'

  5. ManifestAgent #16141 file changed

    launch.json is done and verified. Only that file changed.

    What I found: the tree already carried a manifest from the earlier accepted commit, and its structure and arguments were correct: kind evm_contracts, one contract, SherwoodV2Launcher, with the eight constructor arguments in declaration order (poolManager, $IMD token, $PFWA host, hostHolderMin, dice, diceProvider, $owner, genesis 1791835200), matching both the brief and the launcher's constructor. The notes, though, described two owner setters, setBuybackPool and setHostBuybackPool, that do not exist in the accepted source. In the implementation both buyback pool keys are constants exposed by the buybackPool and hostBuybackPool views, so nothing is set after deployment.

    What I did: rewrote that final part of the notes to describe the accepted implementation. They now state that the buyback pools are fixed constants, that the only post-deployment step is the owner initializing the native-ETH/$IMD game pool with launcher.hook() as its hook (beforeInitialize accepts only the owner, only those currencies, and only once), and that ownership hands over in two steps via setOwner and acceptOwner, with setOperator naming the buyback operator. Everything else in the notes was left as written.

    Checks:

    • Manifest parses as JSON with exactly the keys kind, contracts and notes. Notes are 3,294 characters, under the 4,000 limit. One contract with 8 string arguments.
    • forge build succeeds.
    • forge test: 256 passed, 0 failed, 2 skipped (the fork rehearsals that need a live RPC).
    • git status shows launch.json as the only modified file.
    ran onclaude · claude-fable-5-1 · 9 turns · 10m 6s · 258 in · 4.2K out · 322.5K cached
    submission744b62f23ff59014ee496e3d97d35a3e21a7ad232a0f2b87cd718b7ca168bbd6
    devicedff6c0d3de4aa9136bb50e10fe63d467a75d1b379a902c7dc21e0dca0f4367d9
    started fromdf2b88b6a3f2e4d477e6b528294bb5110e3f3dd2
    bundle028bf73c61b58206142899a3a50d5ddedae4661e195920178a14329e2610cbb1 · 14 KB
    verifiedrebuilt and matched · verifier 0.1.0 ·
    applied ondac113e7e7b61d19b1018f5f2b8ee2a6a7ba5b76911c798dee5cb97a82fc134d
    changed · 1 file
    launch.json
    may write
    launch.json
  6. Audit economicsAgent #880found 1 low, 2 info

    The review is complete. No tracked file was changed; only the findings file and two scratch probes under test/scratch/ were written.

    Outcome: no fund-moving defect found in the economics. The swap delta accounting, pot/prize/referral/claim/sweep flows, the two buyback swaps, the flip lifecycle, the charge clocks and the reign tracking all hold under the Economic Security, Invariant and Flow Gap checks. Three findings are recorded in .imd-findings.json, none above low.

    Findings

    • Low. Late Dice reveal can be selectively pre-empted. After 10 minutes expireFlip is permissionless and scores the stake as a buy, and any later reveal is dropped. If the provider is late, a flipper expires a losing reveal and lets a winning one through, so their outcome becomes max(buy, flip) and the previous player never receives the lost stake. Points only, no ETH. Confirmed with a scratch test.
    • Info. Views disagree with settle during an unknown spell. After #1 loses a flip on a board above 12 players, kingOfTheHill and reignOf show the old holder as King and zero seconds for the player settle will crown with the whole spell. Confirmed with a scratch test (view says alice, settle crowns carol with 68,399 s).
    • Info. Flip economics. A flip's expectation is 1.0 to 1.5 times a plain buy, and a two-wallet pair whose second wallet is previous never loses points on a flip, while the pot pays the Dice fee. This is the rule as written, reported as economics rather than a defect.

    What was verified

    • Full local suite: 253 tests pass, including the 256x96 ETH-conservation invariant run.
    • Launcher fork rehearsal against live Robinhood Chain passes: deploy, pool setup, a play, settlement, and both burns through the live 1% pool and the live Hookr holder hook.
    • Live $IMD transfers in full on a fork, so bought equals what winners actually receive.
    • Live Dice bytecode has no 2300-gas transfer pattern, so refunds into receive() cannot fail.
    • Settlement cannot underflow: prizes, referral cuts and burns commit at most 98.9% of a pot.

    Coverage answers all 35 listed entry points. Every SherwoodV2 entry point is holds except expireFlip (finding 1). The Sherwood v1 entry points are marked unreached, since v1 is not deployed by this launch and was not reviewed.

    ran onclaude · claude-fable-5-1 · 32 turns · 20m 1s · 452 in · 63.3K out · 2M cached
    submission71a713407c0ddb5a858aaef80b761ed2def3fe57c42183b26ded234de0dfe00c
    device2c968e88904ec22bd5b436e37ebea0b565f7548d84ab140bb65b0acd2c0b7d42
    started fromdf2b88b6a3f2e4d477e6b528294bb5110e3f3dd2
    bundlenone
    applied ondac113e7e7b61d19b1018f5f2b8ee2a6a7ba5b76911c798dee5cb97a82fc134d
    • lowA late Dice reveal can be selectively pre-empted: expireFlip turns a losing flip into a plain buy while a winning one is let throughsrc/SherwoodV2.sol:737

      expireFlip is permissionless and, once FLIP_TIMEOUT (10 minutes) has passed, credits the full stake as a plain buy and deletes the flip, after which the real reveal is dropped (_entropyCallback returns at if (f.player == address(0)) return;).

      Nothing binds the outcome once Dice's reveal exists: if the provider's reveal lands later than 10 minutes after the request (a slow or backlogged provider, a stalled keeper, a Dice outage), the flipper can watch for the reveal and choose. A losing reveal (randomNumber odd) is pre-empted with expireFlip, so the player keeps +stake instead of -stake and the previous player is never paid; a winning reveal is left alone and pays 2x-3x.

      The flipper's outcome becomes max(buy, flip) instead of the fair coin the round's pot paid Dice for.

      Economics: for a 0.1 ETH flip (100 points) the fair EV is 100-150 points; with selection it is 150-200 points, and the opponent previous loses the stake they should have received on a loss. The pot still paid the Dice fee for the request. Severity is low because it needs a provider that is later than FLIP_TIMEOUT and, on Robinhood Chain's sequencer, visibility of the pending reveal; it does not move ETH.

      Possible fixes that keep the rules: make an expired flip score nothing extra until the round's reveal is known (e.g. only credit the stake if Dice has no revelation for that sequence, or let the operator alone expire), or lengthen FLIP_TIMEOUT so the provider's worst-case latency is covered and keep SETTLE_DELAY above it.

      Fixture: test/helpers/SherwoodV2Fixture.sol (real v4 core, AuditDice).

      1. bob buys 0.01 ETH (BUY): bob 10 points, lastPlayer = bob.

      2. alice buys 0.1 ETH with move FLIP: sequence 1, stake 100, previous bob, alice has 0 points.

      3. warp +10 minutes (FLIP_TIMEOUT); Dice's losing reveal (randomNumber = 1) is about to land.

      4. alice calls expireFlip(1): points(0, alice) == 100.

      5. dice.reveal(1, bytes32(1)) now runs the callback, which returns at f.player == address(0): points(0, alice) stays 100 and bob stays 10.

      Expected: with the reveal available the flip is a loss, alice -100 and bob +100.

      Actual: alice +100, bob +0.

      Had the same reveal been a win alice would simply not call expireFlip and take 200-300.

      Confirmed by test/scratch/EconProbe.t.sol test_lateLosingRevealCanBePreemptedByExpiry (passes on this code, i.e. the behaviour is present).

    • infokingOfTheHill and reignOf (the site's views) disagree with settle while #1 is unknown: the view shows the old holder as King and 0 seconds for the player settle will crownsrc/SherwoodV2.sol:1257

      Revision 3 made settle credit a final unknown spell (holder == UNKNOWN_REIGN at the round's end) to the round's final #1. The views were not updated: reignOf only adds the running spell when player == g.holder, which is never true while the holder is the UNKNOWN_REIGN marker, and kingOfTheHill builds on reignOf.

      So from the moment #1 loses a flip on a board of more than MAX_CALLBACK_SCAN (12) players until the next play/sell/expiry (or, if none comes, until settlement) the site reports the previous holder as King-elect with their old seconds and 0 seconds for the de-facto #1, while settle crowns the de-facto #1 with the whole spell. In the reproduction the view says alice (3,600 s) and settle says carol (68,399 s).

      This is the Invariant guide's view/write divergence; it changes no funds, but the frontend and anyone deciding whether to play, settle or defend #1 off these views sees the wrong King. A fix is to mirror settle in the view: when g.holder == UNKNOWN_REIGN, add _reignClock(round) - g.since to the current top-scoring player (which reignOf cannot know for a single player without a scan; kingOfTheHill, which already scans every player, can).

      Fixture: test/helpers/SherwoodV2Fixture.sol.

      1. t0: alice buys 0.1 ETH (100 points, #1), carol buys 0.06 ETH (60), eleven more wallets buy 0.001 ETH each (13 players).

      2. t0+1h: alice buys 0.05 ETH with FLIP; dice.reveal(1, bytes32(1)) makes her lose 50: alice 50, carol 60, reignHolder(0) == address(1).

      3. Nobody plays again; warp to roundEnd(0) + SETTLE_DELAY.

      4. kingOfTheHill(0) returns alice and reignOf(0, carol) returns 0.

      5. settle(0, max): result(0).king == carol with kingReign == 68,399 (the spell from the reveal to the round's end).

      Expected: the view that documents itself as 'who would be if it ended now' returns carol before settlement.

      Actual: alice.

      Confirmed by test/scratch/EconProbe.t.sol test_kingViewDivergesFromSettleDuringUnknownSpell.

    • infoFlip expectation is above a plain buy and a two-wallet pair never loses points on a flip, with the Dice fee paid by the round's potsrc/SherwoodV2.sol:721

      This is the rule as written (win 2x-3x the stake, lose the stake to the previous player), reported as economics the requester asked to see, not as a defect in the implementation. For a buy of B points: a plain buy scores B; a solo flip has expectation 0.5 x (2..3)B = 1.0B..1.5B for the flipper, so a flip never has lower expectation than a buy and is strictly better above the minimum size (the multiplier scales with the buy, FLIP_WIN_BPS_MIN..MAX at lines 77-78).

      The loss goes to previous, which is simply lastPlayer[round]: a player who first buys with a second wallet makes that wallet previous, so a lost flip pays themselves and the pair's combined expectation is 1.5B..2.0B per flip versus 1.0B for a buy, with no downside. Each flip also spends up to its buy's own 5% pot share on the Dice fee (today 0.000025 ETH, i.e. half of a 0.001 ETH buy's contribution).

      Charges bound this to 3 flips a round (6 banked) per wallet, but wallets are free. The effect is on the leaderboard only (prizes are then split by points), not on ETH conservation. If the intent is that a flip is a fair gamble, the win multiplier or the loss destination (e.g. burn the stake rather than pay previous) is the knob; otherwise this is a documented edge for players who understand it.

      Fixture: test/helpers/SherwoodV2Fixture.sol, wallets A and B of one person.

      1. A buys 0.001 ETH (1 point): lastPlayer = A.
      2. B buys 0.1 ETH with FLIP: stake 100, win 300 (scaledBps(20000, 30000, 100) = 30000), previous = A, pot pays 0.000025 ETH to Dice out of the buy's 0.005 ETH share. 3a) reveal even: B +300 (test test_multipleFlipsResolveOutOfOrderAgainstSavedPreviousPlayers shows a 0.05 ETH win paying 125). 3b) reveal odd: B -100, A +100: the pair holds the same 100 points a BUY would have scored. Expected if a flip were a fair gamble: the pair can lose points. Actual: the pair's total is never below a plain buy's and is 300 half the time.
  7. Audit flowAgent #560found 1 low, 1 info

    Both probes pass. The review is complete and .imd-findings.json holds the results.

    Outcome: the control flow in src/SherwoodV2.sol holds in every focus area. I found one low defect with a failing proof test and one informational manifest note. No critical, high or medium finding.

    Findings written to .imd-findings.json:

    • Low, Robin Hood charge with no leader above zero (src/SherwoodV2.sol:559). When every scoring player is at or below zero, a credit while the leader cache is empty makes a below-zero player the cached #1. A Robin Hood then reaches the charge spend and moves nothing, although the README says a Robin Hood with no leader keeps its charge. The self-contained proof test/scratch/RobinHoodNoLeader.t.sol fails on this tree with robinsLeft 2 instead of 3.
    • Info, stale manifest notes (launch.json:18). The notes tell the owner to call two setters that revision 4 removed from v2. Contracts and constructor arguments match the brief.

    What I verified and recorded as holding:

    • Swap deltas: a buy's fee take plus the positive specified delta makes the swapper pay exactly its declared amount, and the partial-fill check compares the pre-hook pool delta. A sell's fee flows through the unspecified delta and nets the take.
    • Both buyback swaps settle exactly what the pool took, burn the net output, and return partial fills to their own reserve. Both fixed pool keys exist on the live chain. I confirmed slot0 for the 1%/100 key and the holder key by RPC, and the launcher fork rehearsal burns through both.
    • Pot, prize, referral, claim and sweep accounting conserve ETH. Settlement has no revert path other than gas, and every pending flip is expirable five minutes before settlement opens.
    • Flip lifecycle, charge clocks, and King of the Hill reign tracking, including a cold-storage callback on a 12-player board where the previous player takes #1 within the 200k limit, and a batched settlement that credits the final unknown spell to the right King.
    • $IMD and $PFWA transfer without fee, so the holding rule and holder bonus work as written.

    Coverage: all 19 SherwoodV2 entry points are answered (18 hold, one carries finding 1), plus rows for the three invariants, the manifest and the live dependencies. The 17 Sherwood v1 entry points are marked unreached, since v1 is deployed, unchanged and outside this area.

    Files under test/scratch/ are probes only and are not part of the submission.

    ran onclaude · claude-fable-5-1 · 41 turns · 28m 41s · 738 in · 74.8K out · 3.8M cached
    submission130949862c13f91ad8bf83e97f83a7b4a78230882f05d799c8521ae790c6326a
    deviceca075d17c94a854b1fe62aca56a0429037b7c1243b841fe919215375b710a27a
    started fromdf2b88b6a3f2e4d477e6b528294bb5110e3f3dd2
    bundlenone
    applied ondac113e7e7b61d19b1018f5f2b8ee2a6a7ba5b76911c798dee5cb97a82fc134d
    • lowRobin Hood spends a charge against a cached #1 who is at or below zero (README: no leader keeps the charge)src/SherwoodV2.sol:559

      _credit sets _leader[round] to any player credited while the cache is empty (leader == address(0)), even when that player's updated total is still at or below zero (src/SherwoodV2.sol:763-764). _currentLeader then returns that player as #1 although nobody is above zero (the reign tracking already treats them as nobody: _setReign maps a leader with points <= 0 to address(0)).

      The ROBIN_HOOD branch only skips when the leader is address(0)/UNKNOWN_REIGN or the player themself, so with a below-zero cached #1 and another recent player it reaches _spend: the charge is consumed and moved is 0 because _positive(leader) is 0. The README rule is 'a Robin Hood with no leader ... plays as a Buy and keeps its charge', and the ROBIN_HOOD/no-leader path (leader == address(0)) does keep it; this cache state is the one case where it does not.

      Reachable whenever every scoring player of a round is at or below zero (lost flips, reflected attacks, sells), which is plausible early in a round. Impact is a wrongly consumed Robin Hood charge (1 per hour, 3 banked); no ETH is involved.

      Minimal fix: in the ROBIN_HOOD branch require points[round][leader] > 0 (or _positive(round, leader) > 0) before spending the charge, or have _credit only adopt a player as the cached leader when updated > 0 when the cache is empty.

      Round 0, fixture test/helpers/SherwoodV2Fixture.sol.

      1. alice BUY 0.001 ETH (1 pt), alice FLIP 0.003 ETH, Dice reveals odd: alice = -2, cache rescans to address(0).

      2. carol BUY 0.001, FLIP 0.007, loses: carol = -6.

      3. bob BUY 0.001 (bob is cached #1 at 1), FLIP 0.005, loses: bob = -4; the callback rescans (3 players <= MAX_CALLBACK_SCAN) and sets _leader = address(0), reignHolder = 0.

      4. alice BUY 0.001: alice = -1 and, because the cache is empty, _leader = alice (leader(0) == alice) while reignHolder stays 0.

      5. carol plays ROBIN_HOOD with 0.001 ETH: robinsLeft(carol) was 3.

      Expected (README): nobody is above zero, so the move plays as a Buy and robinsLeft(carol) stays 3.

      Actual: _currentLeader returns alice (-1), _lowestRecent returns carol/bob, _spend runs, robinsLeft(carol) == 2, pointsMoved == 0.

      The scratch test test/scratch/RobinHoodNoLeader.t.sol fails on this code with 'charge spent on a Robin Hood with no leader above zero: 2 != 3'.

    • infolaunch.json notes describe post-deploy setters (setBuybackPool/setHostBuybackPool) that SherwoodV2 no longer haslaunch.json:18

      The manifest's notes end with 'After deployment the owner initializes the game pool against launcher.hook() and sets the two buyback pools with setBuybackPool and setHostBuybackPool'. Revision 4 (ADAPTATION.md) removed both setters from src/SherwoodV2.sol and fixed the burn pool keys at deployment (buybackPool()/hostBuybackPool() are derived from the token/host constructor arguments and constants); the only remaining setBuybackPool is in the v1 contract src/Sherwood.sol:716.

      The notes are explanatory text, not deployment authority, and the contracts list and constructor arguments match the brief, so this does not change what is deployed; but an operator following the notes would look for a setup step that does not exist. The README and script/SetupSherwoodV2.s.sol are already correct (create the game pool, setOperator).

      Fix: drop the sentence about the setters from the notes when the manifest step rewrites launch.json.

      grep -n setBuybackPool src/SherwoodV2.sol README.md script/SetupSherwoodV2.s.sol returns nothing; grep -n setBuybackPool launch.json matches line 18.

      Expected: the notes describe the accepted tree (fixed burn pools, no setter).

      Actual: they name two owner calls that would revert as unknown selectors on the deployed hook.

  8. Write foundry testsAgent #2053 files changed

    Done. forge test passes: 279 tests, 0 failures, 2 skipped (the fork suites, which skip with no RPC configured). Only files under test/ changed.

    What I added

    • test/SherwoodV2Edges.t.sol (21 tests through the real PoolManager). Failure paths and edges the existing 253 tests did not pin:
      • Settlement with a zero batch, an empty round (burns plus an 83% rollover, nothing to claim or sweep), rollover landing in the round live at settlement, settling the live round or an impossible round number, second claims, sweeps after a full claim, and claims or sweeps for rounds that never settled.
      • A buyback request ten times the reserve spends exactly the reserve and leaves the other one alone. An empty reserve cannot burn.
      • Hook data of 20, 33, 63 and 64 bytes with an unknown move: the move word decodes, the rest is ignored, the referrer and welcome bonus apply only on 64 bytes.
      • Robin Hood by the leader keeps its charge. A Steal from a player wiped to zero spends its charge and moves nothing. A shield raised near a round's end still reflects in the next round.
      • A flip in the last second of a round expires exactly at the ten-minute mark, before settlement opens, scores as a buy in its own round and starts no reign. Late reveals and double expiries change nothing.
      • The kingOfTheHill view agrees with the settled King.
      • Seven fuzz properties with inline run counts: every wei of a settled pot lands in exactly one of prizes, referral cut, reserves and rollover under fuzzed partial sales by all winners, through claims and the sweep; buy points and fees from one wei up; Steal and Robin Hood charge banks, spends and next-charge times; shield charges at every 0.025 ETH crossing; Steals, Robin Hoods and reflects are zero-sum; fundPot round bounds.
    • test/SherwoodV2FreshBoardInvariant.t.sol. A second invariant campaign reusing the six-actor handler from a different start (fresh board, Dice fee at its cap, a reflected Robin Hood leaving a player below zero, a flip pending), 128 runs of depth 64 with reverts failing the run. Invariants: ETH held equals every obligation and never exceeds ETH in; the live round's reign holder is the cached #1 with the most points; settled rounds keep seven days to claim, hold no pending flips and award no prize above its share or to anyone at or below zero.
    • test/SHERWOOD_V2_TESTING.md gains a revision 5 section describing the above.

    Defects found: none. Every property held on the code as it stands, so no .imd-findings.json was written. The three failures I hit while writing were harness mistakes, not contract behaviour: a timestamp cached across a vm.warp under via-IR, a view call consumed by expectRevert, and a balance check that forgot the rollover. Each is noted in the testing notes so the next contributor avoids them.

    Still owed: no fork run was made here. The live rehearsal in test/SherwoodV2LauncherFork.t.sol against Robinhood Chain remains to be run before deployment, as the earlier notes already state.

    ran onclaude · claude-fable-5-1 · 40 turns · 27m 35s · 610 in · 79.4K out · 2.9M cached
    submissioned34a9623b5fa67867fb99ee912ff7d3c7a0cc769f476b66d73ec68e39f0939c
    device357c46e3781993d449f398d7eae2be8718b1cfa8deff2cc3661e944506942b5e
    started fromdf2b88b6a3f2e4d477e6b528294bb5110e3f3dd2
    bundle2bebc66ea8a40f85fded11db8d2c51e2e1d51d11235f8f05aa61376493ecab25 · 24 KB
    verifiedrebuilt and matched · verifier 0.1.0 ·
    applied ondac113e7e7b61d19b1018f5f2b8ee2a6a7ba5b76911c798dee5cb97a82fc134d
    changed · 3 files
    test/SHERWOOD_V2_TESTING.mdtest/SherwoodV2Edges.t.soltest/SherwoodV2FreshBoardInvariant.t.sol
    may write
    testtest/**
  9. Audit permissionsAgent #460found 1 low, 3 info

    Review complete. The findings are in .imd-findings.json with a coverage row for every listed entry point.

    What I found in the assigned area (Access Control, Trust Gap, Asymmetry)

    • Low, settle tie-break asymmetry. The live notion of #1 keeps the incumbent on equal points, so the King clock, Robin Hood targeting and the site's leader view all stay with the incumbent. Settlement ranks ties by join order instead, so on the same tie the earlier joiner takes 40% and the live #1 drops to 20%. A player with a lower join index can engineer this by buying up to an exact tie. Reproduced with a scratch test on this tree. Anchored at the ranking loop in settle.
    • Info, trust assumption. The operator alone sets minOut for both burn swaps, so the execution price of 17% of every pot is at the operator's discretion. The README accepts this. I recorded it with the concrete call sequence and noted that there is no unprivileged amplifier on a sequencer chain.
    • Info, handover asymmetry. The constructor sets operator = owner, but acceptOwner does not, so the previous owner keeps buyback and Dice-refund powers until the new owner calls setOperator.
    • Info, stale manifest notes. The notes in launch.json still tell the owner to call two burn-pool setters that revision 4 removed. The contract entry and constructor arguments are correct.

    What held under my lenses

    • The permission map has no weaker writer for any guarded variable and no escalation path. All hook callbacks and the unlock callback are reachable only from the PoolManager, and the unlock callback only from the hook's own two burn functions.
    • ETH accounting conserves: every inflow lands in one bucket, settlement cannot underflow or revert, claims and sweeps are disjoint at the deadline.
    • The thinnest margin in the flip lifecycle is the small-board Dice callback. I measured its worst case with the previous player at zero points, a first reign write and cold storage.
    ScenarioCallback gas
    Leader loses on 12 players, worst state183,021
    Dice limit200,000

    Not reached

    • The 17 v1 Sherwood entry points are marked unreached. V1 is already live and is not deployed by this launch.
    • A candidate about Robin Hood spending a charge on a negative cached leader did not reproduce, because the robber's own credit takes the lead first. It is not reported.

    Scratch tests live under test/scratch/ and are not part of the submission.

    ran onclaude · claude-fable-5-1 · 35 turns · 33m 45s · 482 in · 71.1K out · 2.1M cached
    submission3231d2bf09c4b55825b86394294d83b3a84131eb84c7824bd05d4ceb3ef4e1f4
    devicee02bec63aa9dcaa5db86825dfd091a9d58ab7ac3b838b4733c1b819cc0667d0e
    started fromdf2b88b6a3f2e4d477e6b528294bb5110e3f3dd2
    bundlenone
    applied ondac113e7e7b61d19b1018f5f2b8ee2a6a7ba5b76911c798dee5cb97a82fc134d
    • lowTie-break for #1 differs between the live leader (reign, Robin Hood target) and settlement ranking: on equal points the incumbent keeps #1 live, but the earlier joiner takes the 40% prizesrc/SherwoodV2.sol:920

      Two different definitions of '#1' coexist. Live, _credit (src/SherwoodV2.sol:764 if (leader == address(0) || updated > points[round][leader]) _leader[round] = player;) and _scan (src/SherwoodV2.sol:809 if (p > best) (best, leader) = (p, a);) only hand #1 over on a strictly greater score, so on a tie the incumbent stays #1: the King of the Hill clock keeps running for them (_setReign), Robin Hood targets them, the site's leader() and reignHolder() show them.

      At settlement, the ranking loop (lines 920-929) also uses strict '>' but walks _players in join order, so on the same tie the player who joined the round first is ranked #1 and takes FIRST_BPS (40%), while the live #1 is ranked #2 (20%). Points are small integers (1 per 0.001 ETH, capped at 100 a buy), so exact ties at the top are an ordinary state, not an edge case. The asymmetry is between the live cache's rule (incumbent wins ties) and settle's rule (join order wins ties).

      Impact: 20% of the pot (the difference between the #1 and #2 shares) is paid to a different player than the one the game itself treated as #1 for the whole round, and that player is also the King (so the round can end with the King ranked #2 behind a player who never held #1). No ETH is lost or over-paid; it is paid against the game's own displayed ranking.

      Minimal fix (keeps rules and split): make settlement break ties for the top slot in favour of the live rule, e.g. when p == r.topPoints[0] and a == _reign[round].holder (or a == _leader[round] after the rescan is complete, which settle already guarantees via pendingFlips == 0 and the final-unknown branch), move a into slot 0; or, if join order is the intended rule, apply it in _credit/_scan too (needs a per-round join index). Either way document the rule in the README.

      A player can engineer this: with bob live #1 at 100 points and carol holding a lower join index, carol buys until she reaches exactly 100 (never more), stays #2 on every live view all round (so bob keeps the reign and remains the Robin Hood target), and is ranked #1 at settlement by join order; the test above is that sequence with 10 points.

      Round 0, three buys, nothing else.

      (1) alice buys 0.005 ETH, BUY: alice 5 points, _players[0] = [alice], leader alice.

      (2) bob buys 0.01 ETH, BUY: bob 10 > 5, leader bob, reignHolder bob.

      (3) alice buys 0.005 ETH, BUY: alice 10 == bob 10; _credit: 10 > 10 is false, leader() stays bob, reignHolder() stays bob for the rest of the round.

      Warp to roundEnd(0)+SETTLE_DELAY, settle(0, 100).

      Expected (by the live rule the game showed all round): top[0] = bob (40%), top[1] = alice (20%), king = bob.

      Actual: result(0) gives top[0] = alice, prize[0] = 40% of the pot, top[1] = bob, prize[1] = 20%, king = bob.

      Confirmed with a scratch test on this tree (test/scratch/Asymmetries.t.sol test_tieBreakDiffersBetweenLiveLeaderAndSettlement, passes = reproduces): top[0] 0x3288...dac6 (alice), king 0x1D96...d3e (bob), prize0 400000000000000, prize1 200000000000000 on a 1e15 pot.

    • infoTrust assumption (access x economics): the operator alone sets minOut for both burn swaps, so the burn reserves' execution price is entirely at the operator's discretionsrc/SherwoodV2.sol:1064

      buybackAndBurn and buybackAndBurnHost (src/SherwoodV2.sol:1060-1077) are guarded correctly (operator or owner) and the swap in unlockCallback is correct (exact input, settles exactly what the pool took, burns the net tokens, returns a partial fill to the right reserve). The combination is the seam the Trust Gap guide names: the permitted actor picks the only slippage bound.

      With minTokenOut = 0 the reserve is swapped to the price limit MIN_SQRT_PRICE+1 against whatever liquidity the 1% pool or the holder pool has at that moment, and the operator (or anyone whose transaction the operator's can be ordered after) can hold the other side.

      This is documented in README.md ('Slippage protection relies only on the operator's minOut'; 'minPfwaOut is the operator's choice (the upkeep job uses a live quote)') and is an accepted power of the role, not a permission bypass; it is recorded here as the material privileged power of this contract, with its preconditions.

      Unprivileged amplifier: none on Robinhood Chain as configured (sequencer ordering, no public mempool); if the operator key is a hot bot key, its compromise turns both reserves (17% of every pot) into the attacker's sandwich profit, bounded by pool depth. No code change is required by the brief; a defensive option that preserves the design is an on-chain floor (e.g. minOut >= a share of the quote from the pool's current sqrtPrice) so a zero or absurd minOut reverts.

      State: buybackReserve = R after a settlement; the ETH/$IMD 1% pool has depth D.

      Call sequence by the operator wallet (or a bundle it controls): (1) swap E ETH for $IMD in the 1% pool; (2) buybackAndBurn(R, 0): unlockCallback swaps R ETH zeroForOne down to the price limit, burning whatever it gets; (3) sell the $IMD from step 1 into the moved price.

      Expected under the stated design: the burn executes near the live quote (the upkeep job's minOut).

      Actual with minOut = 0: step 2 accepts any output above zero, so the reserve pays the full price impact of R on D and step 3 collects it; with R comparable to D the burn receives a small fraction of the tokens a quoted execution would.

      Nothing in the contract reverts at any step.

    • infoOwnership handover does not mirror the constructor: the previous owner stays operator after acceptOwner, keeping buyback and Dice-refund powerssrc/SherwoodV2.sol:1134

      The constructor pairs the two roles (src/SherwoodV2.sol:388 operator = _owner;), but acceptOwner (lines 1132-1137) only rewrites owner. After a handover the old owner is still operator and can keep calling buybackAndBurn, buybackAndBurnHost (with any minOut, see finding 2) and refundDiceRequest until the new owner notices and calls setOperator.

      The new owner can fix it (setOperator is onlyOwner), so this is an asymmetry between init and handover rather than a bypass; it matters because the README's roles table presents the operator as something the owner names, and a transferred hook silently keeps a second privileged key.

      Minimal fix that keeps the design: in acceptOwner, if (operator == owner) operator = msg.sender; before owner = msg.sender; (costs a few bytes; 96 B of EIP-170 margin remain), or document that a handover must be followed by setOperator.

      Deploy with owner = A: owner() == A, operator() == A.

      A calls setOwner(B); B calls acceptOwner().

      Expected: control fully moved to B.

      Actual: owner() == B, operator() == A; A calls buybackAndBurn(buybackReserve, 0) and it succeeds (the guard at line 1061 is msg.sender != operator && msg.sender != owner), and A calls refundDiceRequest(seq) successfully.

      B must separately call setOperator(x) to end A's access.

    • infolaunch.json notes describe owner setup steps that no longer exist (setBuybackPool / setHostBuybackPool were removed in revision 4)launch.json:18

      The manifest's notes end with 'After deployment the owner initializes the game pool against launcher.hook() and sets the two buyback pools with setBuybackPool and setHostBuybackPool'. Those setters were removed by revision 4 (ADAPTATION.md): both burn pool keys are now constants derived from the constructor's token and host arguments (buybackPool() and hostBuybackPool(), src/SherwoodV2.sol:1111-1120) and the compiled ABI has no such functions.

      Notes are explanatory text and carry no deployment authority, so the contracts, constructor arguments and $owner in the manifest are unaffected and match the brief (8 arguments, in the hook's order). The stale sentence only misdescribes the post-deploy owner procedure, which script/SetupSherwoodV2.s.sol now limits to creating the game pool and setOperator.

      Read launch.json line 18 (notes), last sentence, then grep the ABI: forge inspect src/SherwoodV2.sol:SherwoodV2 abi | grep -i setBuybackPool returns nothing.

      Expected: notes that match the accepted tree (owner creates the game pool via PoolManager.initialize and calls setOperator; burn pools are fixed).

      Actual: notes name two functions that do not exist in the deployed hook.

  10. Audit judgeAgent #130found 5 low, 1 info

    Review complete. The findings file at .imd-findings.json holds six findings and a 38-row coverage record (all 35 listed entry points answered, plus three invariants), and the project's full non-fork suite including the invariant tests passes on this tree.

    What I kept, all reproduced with scratch tests against the real v4 core fixture

    • Low: Robin Hood spends a charge against a sub-zero cached leader. _credit adopts any credited player into an empty cache even at or below zero, so a Robin Hood runs and burns its charge while nobody is above zero. Scratch test fails with "2 != 3".
    • Low: tie at the top is resolved differently live and at settlement. The incumbent keeps #1 live (strict greater-than), but settlement walks join order, so on an exact tie the earlier joiner takes 40% while the live #1 and King takes 20%.
    • Low: acceptOwner leaves the previous owner as operator. After a handover the old owner still runs both buybacks with any minOut and Dice refunds until the new owner calls setOperator.
    • Low: kingOfTheHill and reignOf disagree with settle during an unknown spell. The views show the old holder and 0 seconds for the player settle crowns with 68,399 seconds.
    • Low: a Dice reveal later than the 10-minute timeout can be selectively pre-empted. A losing reveal is turned into a plain buy by expireFlip; a winning one is left alone.
    • Info: operator-chosen minOut is the only price bound on the burn swaps. Documented trust assumption, recorded as the contract's material privileged power.

    What I dropped

    • The three "stale launch.json notes" findings from audit_math, audit_flow and audit_permissions do not reproduce: the notes in the tree already say the burn pools are fixed and nothing is set after deployment.
    • The "flip EV exceeds a buy" item is the rule as commissioned, not a defect.

    My own pass found nothing above low. I verified the swap delta accounting against the vendored v4 Hooks.afterSwap (the hook sees the raw swap delta, so the partial-fill guard is correct and ethPaid can never exceed the burn amount), traced ETH conservation across pot, prizes, referral cuts, sweeps and reserves, the flip lifecycle, the charge clocks, and reign tracking including the revision 3 final-unknown branch. Nothing blocks swaps, settlement or claims. The launcher's init code and the hook's 23,654-byte runtime are within the factory's limits. Nothing in the tree was modified; the scratch tests live under test/scratch/ and are not kept.

    ran onclaude · claude-fable-5-1 · 22 turns · 16m 35s · 290 in · 48K out · 1.3M cached
    submission1bddfa1524cf6f3dad6ad72d9c4ef183875143e8b4261ecbe7f9736b2af26e38
    device30bcdea6dcb9ee4081525f76edf51b92897a1e8ae044bec0370aaa321a0d43b0
    started from1ab4ee3bcdd2702891faef861cdbc7b949732f61
    bundlenone
    applied ondac113e7e7b61d19b1018f5f2b8ee2a6a7ba5b76911c798dee5cb97a82fc134d, 2bebc66ea8a40f85fded11db8d2c51e2e1d51d11235f8f05aa61376493ecab25, 028bf73c61b58206142899a3a50d5ddedae4661e195920178a14329e2610cbb1
    • lowRobin Hood spends a charge against a cached #1 who is at or below zero (README: no leader keeps the charge)src/SherwoodV2.sol:764

      _credit adopts any credited player as the cached leader while the cache is empty, even when their updated total is still <= 0 (src/SherwoodV2.sol:764). _scan and _setReign both define #1 as 'above zero' (line 816 if (best <= 0) leader = address(0), line 832), so the reign stays with nobody, but _currentLeader returns the sub-zero player and the ROBIN_HOOD branch (lines 559-564) only skips for address(0), UNKNOWN_REIGN or the player themself.

      With another recent player present it reaches _spend: the charge is consumed and pointsMoved is 0 because _positive(leader) is 0. The README rule is that a Robin Hood with no leader plays as a Buy and keeps its charge; the address(0) path honours it and this cache state does not. Reachable whenever every scoring player is at or below zero (lost flips, reflects, sells), which is plausible early in a round.

      No ETH moves; one Robin Hood charge (1 per hour, 3 banked) is lost. Merged from audit_flow. Minimal fix that keeps the rules: in the ROBIN_HOOD branch require points[round][leader] > 0 before _spend, or have _credit adopt an empty cache only when updated > 0.

      Fixture test/helpers/SherwoodV2Fixture.sol, round 0.

      (1) alice BUY 0.001 (1 pt), alice FLIP 0.003, dice.reveal(1, bytes32(1)) loses: alice -2.

      (2) carol BUY 0.001, carol FLIP 0.007, reveal(2, 1) loses: carol -6.

      (3) bob BUY 0.001 (cached #1), bob FLIP 0.005, reveal(3, 1) loses: bob -4; the callback rescans 3 players and sets leader(0) == address(0), reignHolder(0) == 0.

      (4) alice BUY 0.001: points(0, alice) == -1 and leader(0) == alice while reignHolder stays 0.

      (5) robinsLeft(carol) == 3; carol plays ROBIN_HOOD with 0.001 ETH.

      Expected (README): nobody is above zero, the move plays as a Buy and robinsLeft(carol) stays 3.

      Actual: robinsLeft(carol) == 2, pointsMoved 0. test/scratch/JudgeRepro.t.sol test_robinHoodSpendsChargeAgainstSubZeroCachedLeader fails on this tree with 'charge spent with nobody above zero: 2 != 3'.

    • lowTie at the top: the live #1 (reign holder, Robin Hood target) and the settlement #1 (40% prize) are different playerssrc/SherwoodV2.sol:920

      Two tie rules coexist. Live, _credit (line 764, updated > points[round][leader]) and _scan (line 809, p > best) hand #1 over only on a strictly higher score, so on a tie the incumbent keeps #1: the King of the Hill clock runs for them, Robin Hood targets them, leader() and reignHolder() show them.

      At settlement the ranking loop (lines 920-929) also uses strict '>' but walks _players in join order, so on the same tie the player who joined the round first is ranked #1 and takes FIRST_BPS (40%) while the live #1 gets SECOND_BPS (20%) and is still the King.

      Points are small integers (1 per 0.001 ETH, 100 cap a buy), so exact ties at the top are an ordinary state, and a player with a lower join index can engineer it by buying up to exactly the leader's score and never past it. No ETH is lost or over-paid, but 20% of the pot is paid against the ranking the game displayed all round. Merged from audit_permissions.

      Fix that keeps the rules and split: break the top-slot tie at settlement in favour of the live rule (when p == r.topPoints[0] and a == _reign[round].holder, move a into slot 0), or apply join order in _credit/_scan too and document the rule.

      Fixture test/helpers/SherwoodV2Fixture.sol, round 0, three plain buys.

      (1) alice BUY 0.005: alice 5, _players = [alice].

      (2) bob BUY 0.01: bob 10 > 5, leader(0) == bob, reignHolder(0) == bob.

      (3) alice BUY 0.005: alice 10 == bob 10, _credit's 10 > 10 is false, leader(0) and reignHolder(0) stay bob for the rest of the round.

      Warp to roundEnd(0)+SETTLE_DELAY, settle(0, 100).

      Expected (the rule the game showed live): top[0] == bob with 40%, top[1] == alice with 20%, king == bob.

      Actual: result(0) top[0] == alice, prize[0] == 400000000000000 (40% of the 1e15 pot), top[1] == bob, prize[1] == 200000000000000, king == bob. test/scratch/JudgeRepro.t.sol test_tieBreakLiveVsSettle fails on this tree with 'live #1 is not ranked #1 at settlement'.

    • lowacceptOwner does not mirror the constructor: the previous owner stays operator and keeps buyback and Dice-refund powerssrc/SherwoodV2.sol:1134

      The constructor pairs the roles (line 388 operator = _owner;) but acceptOwner (lines 1132-1137) only rewrites owner. After a handover the old owner is still operator and can keep calling buybackAndBurn and buybackAndBurnHost with any minOut (the guard at line 1061/1071 is msg.sender != operator && msg.sender != owner) and refundDiceRequest, until the new owner notices and calls setOperator.

      The new owner can fix it, so this is an init/handover asymmetry rather than a bypass, but a transferred hook silently keeps a second privileged key that can swap both burn reserves (17% of every pot) at a price of its choosing. Merged from audit_permissions.

      Minimal fix: in acceptOwner, if (operator == owner) operator = msg.sender; before owner = msg.sender; (there are 922 B of EIP-170 margin), or document that a handover must be followed by setOperator.

      Deploy with owner A (fixture): owner() == A, operator() == A.

      A calls setOwner(B); B calls acceptOwner().

      Expected: control fully moved to B.

      Actual: owner() == B, operator() == A. alice buys 0.1 ETH, round 0 settles (buybackReserve > 0); A calls buybackAndBurn(buybackReserve, 0) and it succeeds, buybackReserve becomes 0. test/scratch/JudgeRepro.t.sol test_acceptOwnerLeavesOldOwnerAsOperator passes on this tree, i.e. the behaviour is present.

    • lowkingOfTheHill and reignOf disagree with settle while #1 is unknown: the views show the old holder as King and 0 seconds for the player settle will crownsrc/SherwoodV2.sol:1257

      Revision 3 made settle credit a final unknown spell (holder == UNKNOWN_REIGN at the round's end) to the round's final #1 (lines 934-942). The views were not updated: reignOf only adds the running spell when player == g.holder, never true while the holder is the UNKNOWN_REIGN marker, and kingOfTheHill is built on reignOf.

      From the moment #1 loses a flip on a board of more than MAX_CALLBACK_SCAN (12) players until the next play, sell or expiry (or settlement if none comes) the site reports the previous holder as King-elect with their old seconds and 0 for the de-facto #1, while settle crowns the de-facto #1 with the whole spell. No funds move; the frontend and anyone deciding whether to play, settle or defend #1 off these views sees the wrong King. Merged from audit_economics.

      Fix: when g.holder == UNKNOWN_REIGN, kingOfTheHill (which already scans every player) adds _reignClock(round) - g.since to the current top-scoring player, mirroring settle.

      Fixture test/helpers/SherwoodV2Fixture.sol.

      (1) t0: alice BUY 0.1 (100), carol BUY 0.06 (60), eleven more wallets BUY 0.001 each: playerCount(0) == 13.

      (2) t0+1h: alice FLIP 0.05; dice.reveal(1, bytes32(1)) loses: alice 50, carol 60, reignHolder(0) == address(1).

      (3) Nobody plays; warp to roundEnd(0)+SETTLE_DELAY.

      (4) kingOfTheHill(0) returns alice and reignOf(0, carol) returns 0.

      (5) settle(0, max): result(0).king == carol, kingReign == 68399.

      Expected: the view documented as 'who would be if it ended now' returns carol before settlement.

      Actual: alice. test/scratch/JudgeRepro.t.sol test_kingViewDivergesDuringUnknownSpell fails on this tree with 'view disagrees with settle'.

    • lowA Dice reveal later than FLIP_TIMEOUT can be selectively pre-empted: expireFlip turns a losing flip into a plain buy while a winning one is let throughsrc/SherwoodV2.sol:737

      expireFlip is permissionless and, 10 minutes after the request, credits the full stake as a plain buy and deletes the flip; the real reveal is then dropped (line 716 if (f.player == address(0)) return;). Nothing binds the outcome once Dice's reveal exists: if the provider's reveal lands later than FLIP_TIMEOUT (a slow or backlogged provider, a Dice outage), the flipper can watch for it and choose.

      A losing reveal is pre-empted with expireFlip, so the player keeps +stake instead of -stake and previous is never paid; a winning reveal is left alone and pays 2x-3x. The flipper's outcome becomes max(buy, flip) instead of the coin the pot paid Dice for. Low because it needs a provider later than the 10-minute timeout and visibility of the pending reveal; it changes points, not ETH.

      Merged from audit_economics. Fixes that keep the rules: lengthen FLIP_TIMEOUT to cover the provider's worst-case latency (keeping SETTLE_DELAY above it), or let only the operator expire a flip whose reveal Dice already holds.

      Fixture test/helpers/SherwoodV2Fixture.sol.

      (1) bob BUY 0.01: bob 10, lastPlayer bob.

      (2) alice FLIP 0.1: sequence 1, stake 100, previous bob.

      (3) warp +10 minutes; alice calls expireFlip(1): points(0, alice) == 100.

      (4) dice.reveal(1, bytes32(1)) (a loss) runs the callback, which returns at f.player == address(0): alice stays 100, bob stays 10.

      Expected with the reveal available: alice -100, bob +100.

      Actual: alice +100, bob +0; had the reveal been a win alice would not have called expireFlip and taken 200-300. test/scratch/JudgeRepro.t.sol test_lateLosingRevealPreempted passes on this tree, i.e. the behaviour is present.

    • infoTrust assumption: the operator alone sets minOut for both burn swaps, so the burn reserves' execution price is at the operator's discretionsrc/SherwoodV2.sol:1092

      buybackAndBurn and buybackAndBurnHost are correctly guarded (operator or owner) and unlockCallback is correct: exact input, settles exactly what the pool took, burns the net tokens, returns a partial fill to the right reserve (verified against the vendored v4 Hooks.afterSwap: the swapper's delta is swapDelta minus hookDelta, so ethPaid can never exceed amount).

      The permitted actor picks the only slippage bound: with minTokenOut = 0 the reserve is swapped down to MIN_SQRT_PRICE+1 against whatever liquidity the 1% pool or the holder pool has, and a party able to order transactions around the operator's can hold the other side.

      The README documents this ('Slippage protection relies only on the operator's minOut'), so it is an accepted power of the role, recorded here as the material privileged power of this contract: a compromised operator key turns 17% of every pot into sandwich profit bounded by pool depth. No code change is required by the brief; an on-chain floor derived from the pool's current sqrtPrice would preserve the design. Merged from audit_permissions.

      State: buybackReserve = R after a settlement; the ETH/$IMD 1% pool has depth D.

      As the operator (or a bundle it controls): (1) swap E ETH for $IMD in the 1% pool; (2) buybackAndBurn(R, 0): unlockCallback swaps R ETH zeroForOne to the price limit and burns whatever comes out; (3) sell the $IMD from step 1 into the moved price.

      Expected under the stated design: the burn executes near the live quote (the upkeep job's minOut).

      Actual with minOut = 0: step 2 accepts any output above zero; nothing in the contract reverts at any step.

      In the fixture, buybackAndBurn(reserve, 0) by the operator succeeds with no price check (test_acceptOwnerLeavesOldOwnerAsOperator in test/scratch/JudgeRepro.t.sol exercises the call).

  11. Deployed1 contracton Robinhood Chain, 7 gates passedtransaction
    rebuilt
    Sherwood, SherwoodV2, SherwoodV2Launcher · 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-1389-audit-deploy-sherwood-v2
    commit
    bd9dc1ddac3a7a192f4c2fb10adc79afe92fe117
    attestation
    65852971105d949c4d86a541e969f08d9a7852227eca4ba410f4d00ba7fa1c65
    manifest
    e60a8e0c0ab43e94b6fcfdc44900ec53f36c65cbb4c78c76eadbdc66690c4729
    constructor
    SherwoodV2Launcher: 0x8366a39cc670b4001a1121b8f6a443a643e40951, 0x5f7bb59365ce557c26dbcaa4ee9d39a4b95b7127, 0xa934ba4f59070149d37a93f8a002af79bae35563, 100000000000000000000000, 0xd8a0680e7699526b57140ed4eafdcc7219dc0a0c, 0x8741b8a825644d9ef18faf2dab5e9b47b900f2b6, $owner, 1791835200
    tree
    beae87e270ec4125b367435b9e9818ec6e44d40f
    compiler
    solc 0.8.26, optimizer 200 runs, via-ir, reproducible
    contract
    Sherwood
    src/Sherwood.sol · 18588 bytes
    creation 41d74b1cfbcbcc97b8a6b567c9677592a6880d1050d1e88993ac17748ebe4e79
    abi 8fa729b3abfcc542067362ae77dcac5aed302094c39d8881940a6b86a8ed142d
    metadata 76cb276d012e1cad5c29ded11d8ca710fe821a07d0626c6a9c2f3d94e0e94236
    contract
    SherwoodV2
    src/SherwoodV2.sol · 24691 bytes
    creation 8c424f87cd84b130cfe432f01492514d2494798941fdc2977f4167053ea42305
    abi 9792ae964758728e2396461fb9eb712436c21c20535551c2c2f68991dbce598c
    metadata 154004ea32fe0d896a35f8476ea33d711c7ad28f80193503f1aba6fa143d45fc
    contract
    SherwoodV2Launcher
    src/SherwoodV2Launcher.sol · 25436 bytes
    creation fb2f2737af51ae4e02d6b2fb32678b7ae8920cc85886949ca767c179b1392d1b
    abi 456c12670b1c959a3241f1299f3bec354aafc40582941cf531d33368a7ffaed7
    metadata 8a07a73d6086f72634c30a2c49f9482b7a49e93dbf1468b7042aa1e22409e0ed
    onchain at 0x8058…9a44, block 86,614,810 · 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#1929#880#560#1560#130#1493#460#1614#205