{"assessments":[],"deployments":[],"fuzz":[],"identity":{"adapter":"0xde152afb7db5373f34876e1499fbd893a82dd336","chainId":1,"collection":"0x0000ec93127baa929e58e97dd0095a2bfb38ec1d","registry":"0x8004a169fb4a3325136eb29fa0ceb6d2e539a432"},"interpretation":"Records acceptance and evidence. Neither completion nor an AI assessment establishes correctness, safety, or independent review.","jobId":"0b474c5e-e1bc-4451-b0e3-9e6459e2007d","kind":"audit","nodes":[{"acceptedSubmissionHash":"b5d140ed9028aedab598d4a54ce67cb1d2c6400546d5d988ed3699bed8afa0d6","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_economics","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"c990bc182bfc1d9445731f9fefd73ec76f8e8378aea9c9b2b55f215e4d482c28","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_flow","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"318791f2d028569162124cdc4dcda2a318660073ca4f68c40f5eb09fe7f45e99","dependsOn":["audit_math","audit_permissions","audit_economics","audit_flow"],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"af3aa01159bbf354f621cafa5c0006f6169e0938a8b015be508b7377dbf165bc","skillId":"audit-judge","tools":[]},"key":"audit_judge","kind":"code","role":"review","skillHash":"af3aa01159bbf354f621cafa5c0006f6169e0938a8b015be508b7377dbf165bc","skillId":"audit-judge","state":"accepted"},{"acceptedSubmissionHash":"a46cbc4ad8d9c6344879aed21c42f76662783d949da98d8c17693c77dcd0a6d0","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_math","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"589d1fd04b181be7c8667c7e013a3e6e232ca61308602d424e4d012ff0702807","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_permissions","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"}],"objective":"Audit the contracts in this repository.","parentJobId":null,"planHash":"5e813a476c3ba890fb2b7243cb3c19537850383ccad06614224a7bb7ef831b10","previousHash":"0000000000000000000000000000000000000000000000000000000000000000","projectId":"0b474c5e-e1bc-4451-b0e3-9e6459e2007d","publication":{"commit":null,"deliveredAt":null,"repoUrl":null},"receiptIdentity":{"adapter":"0xde152afb7db5373f34876e1499fbd893a82dd336","chainId":1,"collection":"0x0000ec93127baa929e58e97dd0095a2bfb38ec1d","registry":"0x8004a169fb4a3325136eb29fa0ceb6d2e539a432"},"registry":"0xb6d0a187b050fa5bb0b87033a203f37becf4a775","research":[],"schema":"identitymd-work-v1","signals":[{"agentId":"52263","feedbackHash":"ab3a57ac68ab68f7710134efa940a1fabff4030cb7e17039e14de6bdafc244e8","nodeKey":"audit_economics","submissionHash":"b5d140ed9028aedab598d4a54ce67cb1d2c6400546d5d988ed3699bed8afa0d6","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51484","feedbackHash":"903de1963bb5cb9507c414768ad4158e39bff789183936ac5888b8f6439d67c9","nodeKey":"audit_flow","submissionHash":"c990bc182bfc1d9445731f9fefd73ec76f8e8378aea9c9b2b55f215e4d482c28","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52666","feedbackHash":"9d1c5378239a541a17b86f2797b0748746fb5433ed9ac403bd2c90b8f4a3e7d0","nodeKey":"audit_judge","submissionHash":"318791f2d028569162124cdc4dcda2a318660073ca4f68c40f5eb09fe7f45e99","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"50903","feedbackHash":"f46a58e28d582ecc492ad5fb4ba0a0dd1113f89ea4f076b579d4578ab81a45f8","nodeKey":"audit_math","submissionHash":"a46cbc4ad8d9c6344879aed21c42f76662783d949da98d8c17693c77dcd0a6d0","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52290","feedbackHash":"04e6857e3ea5c0449b304f78d030395d7506bff7872973942faa2e60e6815ab0","nodeKey":"audit_permissions","submissionHash":"589d1fd04b181be7c8667c7e013a3e6e232ca61308602d424e4d012ff0702807","tag1":"review:submission","tag2":"acceptance-v2","value":1}],"site":null,"snapshotHash":"2fbd11287a59bf5961e4af1d86919b3d75eb7e8417eeaa9736065f2f1f3905b8","state":"completed","submissions":[{"artifacts":[],"attempt":1,"bundleHash":null,"device":"30bcdea6dcb9ee40","findings":[{"citation":"resolved","description":"sponsor() is documented as 'IMD paid straight to every part of the running machine, by weight', but unlike build(r, ...) it carries no round id and is guarded only by _live(). It reads the global `round` at execution time. Two consequences, same root cause (merged from the economics and math specialists): (1) When the sponsor's transaction is sequenced after any mint or endRound() that closed the round they saw, the new round has totalWeight == 0, so _distribute() (line 622-624) falls through to `R.pot += amount`. The sponsor's IMD becomes prize money for the next round (70% to its last ten placers, 30% carried) instead of income for the builders the sponsor intended to pay, and Sponsored(who, newRound, amount) is emitted. build() reverts RoundOver on the same stale intent; sponsor() accepts it silently. (2) Even with no race, sponsoring a round in which nothing has been placed yet (the whole pre-start window and the start of every round) funds the pot, which fundPot() already exists for. Nothing leaves the protocol, so severity is low. Fix: add a `uint256 r` parameter and `if (r != round) revert RoundOver();`, and revert (or document the fallback) when rounds[round].totalWeight == 0.","line":553,"path":"src/IdentityMachine.sol","reproduction":"Reproduced in test/scratch/Judge.t.sol (test_A_sponsorAfterRolloverGoesToNextPot, test_A_sponsorNothingPlaced), both pass on the current code. State: fresh deployment, basePrice 0.1 IMD. alice mints one part, it reveals as design 0 (STRAIGHT) and she builds it at (2,0) rot 0 plugging the core's E socket; round 1 has totalWeight 1. Warp to rounds[1].deadline. bob calls mint(1, max): round becomes 2 (totalWeight 0). Record earned(alice's id) and rounds[2].pot. bob calls sponsor(100 ether). Expected (per NatSpec): alice's part earns the 100 IMD, or the call reverts like build(1, ...) does. Actual: rounds[2].pot increases by exactly 100 ether and earned(alice's id) is unchanged. Second case: fresh deployment, sponsor(10 ether) with nothing placed: rounds[1].pot == 10 ether.","severity":"low","snippet":"        _distribute(rounds[round], amount);","title":"sponsor() has no round guard and routes to the prize pot whenever nothing is placed, so a sponsorship meant for the running machine's builders can land in the next round's empty pot"},{"citation":"resolved","description":"check(design, rot, x, y) is the documented fit oracle 'for agents and UIs'. It wires against rounds[round] unconditionally, whereas price(), quote() and _mint gate on _live() and treat an expired clock as the next round (k = 0, bare core), and build(r, ...) reverts RoundOver once block.timestamp >= deadline. Between the deadline passing and the next mint or endRound(), check() therefore says a placement fits and names payees on a machine that can never receive another part. An agent that trusts check() submits builds that always revert, and after the rollover the same spot is empty space next to nothing, so check() then reverts NotPlugged. Fix (one line, preserves all other behaviour): at the top of check(), `if (!_live()) revert RoundOver();`, or wire against a bare core when the clock has run out, matching price().","line":501,"path":"src/IdentityMachine.sol","reproduction":"Reproduced in test/scratch/Judge.t.sol (test_G_checkIgnoresExpiredClock, test_G_buildRevertsWhereCheckSaidFits). State: round 1 running; alice places a STRAIGHT (design 0) at (2,0) rot 0 (plugs the core's E socket), id = that part. check(0, 0, 3, 0) returns [id]. Warp to T0 + 25 hours without calling endRound(). timeLeft() == 0 and price() == basePrice (it already prices round 2). Actual: check(0, 0, 3, 0) still returns [id]. alice's build(1, [], [], [Spot(id2, 0, 3, 0)], GrowthExit(3, 0, E)) reverts RoundOver. After any mint rolls the round to 2, check(0, 0, 3, 0) reverts NotPlugged. Expected: check() to be consistent with price()/build() (RoundOver, or the next round's bare core).","severity":"low","snippet":"        (uint256 links, uint256[8] memory into,) = _wires(round, n, cx, cy, m);","title":"check() reports fits and payees against the expired machine while price()/quote()/build() already treat the clock as run out"},{"citation":"resolved","description":"script/Deploy.s.sol's DeployLib.check requires basePrice > 0, dev != address(0) and that imd/beacon have code, but the constructor validates only delay_. launch.json instantiates IdentityMachine directly from constructor args (dev = the placeholder \"$owner\") and bypasses DeployLib, so in production the constructor is the only guard that runs. There is no owner or setter to repair a bad value. With dev == address(0), every mint still accrues 2% to devBalance but payDev() calls imd.safeTransfer(address(0), a), which an OpenZeppelin v5 ERC20 (the IMD token is one) reverts with ERC20InvalidReceiver, so that 2% of every mint is locked in the contract forever. With basePrice == 0 every mint is free while still issuing inventory items. Fix: mirror DeployLib.check in the constructor: revert when dev_ == address(0), basePrice_ == 0, or imd_/beacon_ have no code.","line":188,"path":"src/IdentityMachine.sol","reproduction":"Reproduced in test/scratch/Judge.t.sol (test_D_zeroDev, test_D_zeroBase) with an OpenZeppelin v5 ERC20 as IMD. new IdentityMachine(imd, beacon, address(0), bytes32(0), 0.1 ether, 0) succeeds (expected: revert as DeployLib.check would). approve; mint(1, max): devBalance == 0.002 IMD. payDev() reverts (ERC20InvalidReceiver(0x0)) and devBalance is still 0.002 IMD afterwards, so it can never leave the contract. Separately, new IdentityMachine(imd, beacon, dev, bytes32(0), 0, 0) succeeds and mint(200, 0) returns total == 0 with nextId == 201 (200 free parts).","severity":"low","snippet":"        if (delay_ > 30 days) revert BadDelay();","title":"Constructor accepts dev == address(0) and basePrice == 0; DeployLib.check rejects both but the swarm path (launch.json) never runs it, and all four values are immutable with no admin"},{"citation":"resolved","description":"The header (line 34-35) and _endRound NatSpec say 'the last ten parts placed share 70% of the pot' and 'the rest of the pot starts the next machine's pot'. The loop pays prize * placeBps(i) / BPS only for i < filled, so when placed < 10 the basis points of the empty places are never paid and roll into the next pot with the 30%. One builder receives 30% of 70% = 21%; five builders receive 51%. Accounting is internally consistent (RoundEnded's `carried` shows the real amount) and no value is lost, so this is a specification mismatch for the requester to confirm. If the documented behaviour is intended, normalise the table over the filled places or pay the remainder to the last place; otherwise say so in the header.","line":596,"path":"src/IdentityMachine.sol","reproduction":"Reproduced in test/scratch/Judge.t.sol (test_H_prizeWithOnePartIs21Percent). State: fundPot(100 ether); alice places one STRAIGHT at (2,0); pot = rounds[1].pot (100 IMD plus that mint's contributions). Warp to the deadline; endRound(). Actual: earned(alice's id) grows by exactly pot * 7000/10000 * 3000/10000 (21% of the pot) and rounds[2].pot == pot - that amount (79% carried). Expected per NatSpec: 70% to the placed parts, 30% carried.","severity":"info","snippet":"        uint256 filled = placed < 10 ? placed : 10;","title":"With fewer than ten parts placed, the unfilled places' share of the 70% prize is carried over rather than shared, so a one-builder round pays 21% of the pot, not 70%"},{"citation":"resolved","description":"In _mint the reserve is debited by min(toPot, reserve) (line 311-312) before this mint's own 5% is credited (line 323), and it receives only 5% per mint while paying out up to 15%. Without external fundReserve() deposits the reserve never holds more than the previous mint's 5%, so the effective match is about 5% of the prior mint's price (one mint lag): the pot receives roughly 20% of mint volume, not 30%. The split comment on line 37 ('15% round pot (matched by the reserve)') has the same gap. The app's llms.txt says 'while it lasts', so the behaviour is intended; the on-chain header is the misleading surface. No value is lost or mis-accounted. Fix: reword lines 35 and 37 to 'matched by the reserve while it lasts'.","line":35,"path":"src/IdentityMachine.sol","reproduction":"Reproduced in test/scratch/Judge.t.sol (test_B_reserveMatch). Fresh deployment, basePrice 0.1 IMD, reserve 0. mint(1) at price t1 = 0.1: toPot 0.015, matched = min(0.015, 0) = 0, reserve == t1 * 5% = 0.005. mint(1) again at t2 = 0.1 * (1501/1500)^2: toPot = 0.01502.. > 0.005, so matched = 0.005 (the whole reserve) and reserve afterwards == t2 * 5% only. Expected from the header: the pot money (0.01502..) matched 1:1. Actual: 0.005 matched. test/security/Lifecycle.t.sol:249-251 asserts the same one-mint behaviour.","severity":"info","snippet":"///          starts the next machine's pot. The reserve matches every round's pot money mint for mint.","title":"Header overstates the reserve match: with mint income alone the reserve holds only the previous mint's 5%, so it matches about 5% of the prior mint, not the 15% pot money"},{"citation":"resolved","description":"Merged from three specialists (economics, permissions, math), who all reported the same mechanism. Each mint commits to drand round roundAt(block.timestamp) + 3, which drand publishes 9 to 12 s after the block's own timestamp. The invariant 'the drop is unknown when the price is paid' therefore holds only while the chain clock trails real time by less than 9 s. Robinhood Chain is an Arbitrum Orbit rollup whose sequencer sets block.timestamp with only a wide protocol bound (24 h past / 1 h future against L1); if it lags 9 s or more, the signature for dr is public when the mint executes. A minting contract can then call beacon.prove(dr, sig) and, since nextId is public, compute rollTier(uint256(keccak256(abi.encode(randomness, nextId)))) and mint only when the result is legendary (1% odds, weight 20), or revert and pay only gas. The contract has no owner and cannot widen the margin after deployment. This is a trust assumption on the sequencer clock rather than a logic defect, recorded so it is documented; raising DRAND_MARGIN (e.g. 10 rounds, 30 s) or deriving the reveal round from beacon.latest() + margin would widen the safety margin at the cost of a few seconds of reveal latency. test/audit/Beacon.t.sol (IM05) already records 9 s as the exact limit.","line":285,"path":"src/IdentityMachine.sol","reproduction":"Deterministic part reproduced in test/scratch/Judge.t.sol (test_I_revealDistance): mint(1, max) at block time T gives pending[id].drandRound = dr with beacon.timeOf(dr) - T in [9, 12]. Hence whenever wall time >= T + 9 s when the block is built, timeOf(dr) <= wall time and drand's signature for dr is already published. Failing state (not reproducible in Foundry without faking the clock relation): block.timestamp = T sequenced at wall time T + 9. Input: attacker contract calls beacon.prove(dr, sig) then mint(1, max) only if rollTier(uint256(keccak256(abi.encode(keccak256(sig), nextId)))) == 3. Expected: the mint outcome is unknown at commit time. Actual under that state: only legendary mints are submitted.","severity":"info","snippet":"        uint48 dr = uint48(beacon.roundAt(block.timestamp) + DRAND_MARGIN);","title":"Reveal unpredictability rests on the sequencer's block.timestamp being within 9 s of drand's wall clock (DRAND_MARGIN = 3): a lagging L2 clock lets a minter pre-select drops"},{"citation":"resolved","description":"roundAt(ts) is the first round published at or after ts (0 to 3 s later); + DRAND_MARGIN (3 rounds of 3 s) puts the reveal round 9 to 12 s after block time, as the constant's own comment on line 112 states. The public NatSpec on mint says 15 s. Integrators read the ABI comment, and the number also sets expectations about the clock-lag tolerance in the previous finding. Align the NatSpec with the constant (or raise the margin to match the NatSpec).","line":251,"path":"src/IdentityMachine.sol","reproduction":"Reproduced in test/scratch/Judge.t.sol (test_I_revealDistance). Input: mint(1, max) at block time T = 1791300000. Actual: beacon.timeOf(pending[id].drandRound) - T is in [9, 12] (asserted with assertGe/assertLe). Expected per NatSpec: about 15.","severity":"info","snippet":"    /// @notice Mint `n` parts (at most 200). Each is revealed by a drand round about 15 seconds later.","title":"mint() NatSpec says the reveal round is 'about 15 seconds later'; the code schedules it 9 to 12 seconds later"},{"citation":"resolved","description":"The docstring on mintWithReferral states self-referrals are allowed, so this is intended, but the consequence is worth stating as a design note: any minter who passes their own address gets 3% of the price back through claimReferral(), while a caller of mint(), mintFor(), or EthMinter.mint without a referrer pays the same price and that 3% stays in the 26% shared pool. The effective price differs by 3% purely on which entry point the caller used, and the only way to be on the losing side is not to know the trick. There is no owner to adjust this after deployment. If the intent is a universal 3% rebate, mint()/mintFor() could default the referrer to the recipient; if the intent is a true referral programme, disallow referrer == recipient/msg.sender.","line":316,"path":"src/IdentityMachine.sol","reproduction":"Reproduced in test/scratch/Judge.t.sol (test_E_selfReferral). alice calls mintWithReferral(1, max, alice) at price t. Actual: referralBalance[alice] == t * 300 / 10000 immediately, claimable via claimReferral(), so her net cost is 0.97 t; a mint(1, max) at the same k costs t with the 0.03 t added to _distribute. Expected under a uniform price: equal net cost.","severity":"info","snippet":"        if (referrer != address(0) && referrer != address(this) && referrer != DEAD) {","title":"Self-referral rebates 3% of the price, so mint() callers pay 3% more net than mintWithReferral(n, max, self) callers for identical parts"},{"citation":"resolved","description":"sqrtPriceLimitX96 = 4295128740 is MIN_SQRT_PRICE + 1, i.e. no price limit for a zeroForOne swap. The exact-output swap for `total` IMD consumes as much ETH as the pool demands, capped only by `spent > budget` (line 112) where budget = msg.value (line 90). The `expires` deadline bounds time, not price. A client that follows the contract's own guidance (quote(n), then send a buffer above ethTotal so small price moves do not revert the transaction) exposes the whole buffer: a searcher who sees the pending transaction moves the ETH/IMD pool so the exact-output cost equals msg.value, then unwinds. The user still receives their parts, so this is value leakage from the ETH payer to the sandwicher, bounded by the buffer the payer chose; the payer controls that bound, so it is a hardening note, not a permission defect. Fix: accept a maxEth parameter distinct from msg.value (so the refund path and the slippage bound are decoupled), or pass a caller-supplied sqrtPriceLimitX96.","line":107,"path":"src/EthMinter.sol","reproduction":"Reproduced in test/scratch/JudgeEth.t.sol (test_F_bufferIsFullyExtractable) with a pool mock whose ETH-per-IMD rate can be moved between quote and execution (the repository's DevEthPool has a fixed rate): quote(1) gives ethQuote; the rate is raised 20%; alice calls mint{value: 1.2 * ethQuote}(1, max, now). Actual: ethPaid == msg.value, refund 0, alice's balance drops by the full 1.2 * ethQuote. Generalised: user calls EthMinter.mint(1, maxImd, expires) with msg.value = 1.2 * ethQuote. Attacker front-runs with a large ETH->IMD swap in the same v4 pool so that the exact-output cost of `total` IMD rises to exactly 1.2 * ethQuote, and back-runs after. unlockCallback: ethDelta = -(1.2 * ethQuote), spent = 1.2 * ethQuote, `spent > budget` is false (equality), settle{value: spent}, refund = msg.value - ethPaid = 0. Expected: the user pays about ethQuote. Actual: the user pays 20% more than quoted and the attacker captures the difference.","severity":"info","snippet":"        int256 delta = poolManager.swap(poolKey(), EthSwapParams(true, int256(total), 4295128740), \"\");","title":"EthMinter swaps with no sqrtPriceLimit; the only slippage bound is msg.value, so any ETH sent as a buffer above the quote is extractable by a sandwich"}],"hash":"318791f2d028569162124cdc4dcda2a318660073ca4f68c40f5eb09fe7f45e99","nodeId":"51799c84-a15f-4835-a80d-5d57342b694b","outcome":"completed","summary":"The review is complete. The findings file parses, and git shows no tracked changes (the findings file and test/scratch/ are ignored, and nothing else was touched).\n\n**Outcome.** All twelve specialist findings were reproduced or rejected against the code with Foundry tests under test/scratch/, and merged into nine findings in `.imd-findings.json`. No critical or high defects exist, so no proof files were attached. The contracts have no owner, so every kept item is either a small logic inconsistency, a missing constructor guard, or a documented trust assumption.\n\n**Kept, after merging duplicates:**\n\n- **Low: sponsor() has no round guard** and routes to the prize pot whenever nothing is placed. The economics specialist's rollover race and the math specialist's empty-machine case are the same root cause, merged into one finding at the `_distribute` call. Reproduced: after a rollover mint, sponsoring 100 IMD raised the new round's pot by exactly 100 IMD and alice's part earned nothing.\n- **Low: check() wires against the expired machine** while price, quote and build already treat the clock as run out. Reproduced: check returns a payee, build on that round reverts RoundOver, and after rollover check reverts NotPlugged.\n- **Low: constructor accepts a zero dev address and zero basePrice** that only the scripted deploy path validates. Reproduced with an OpenZeppelin v5 token: payDev reverts forever with devBalance stuck, and a zero base price mints 200 free parts.\n- **Info: fewer than ten parts placed** pays 21% of the pot for one builder, not the documented 70%.\n- **Info: the header overstates the reserve match** (the reserve only ever holds the previous mint's 5%).\n- **Info: drand clock-lag trust assumption**, merged from three specialists who reported the same mechanism. The deterministic part (reveal round lands 9 to 12 s after block time) was reproduced; the lagging-sequencer state cannot be staged in Foundry and is reported as a documented assumption.\n- **Info: mint NatSpec says 15 s**, code schedules 9 to 12 s.\n- **Info: self-referral rebates 3%**, a documented design choice recorded as a pricing asymmetry.\n- **Info: EthMinter has no price limit**, so an ETH buffer above the quote is fully extractable. I staged this with a movable-rate pool mock since the repository's dev pool has a fixed rate. Reproduced: a 20% rate move consumed the entire 20% buffer with zero refund.\n\n**Dropped:** nothing. Every specialist claim reproduced. The flow specialist reported none.\n\n**Caveat.** One mistyped path during the session briefly created a sibling directory outside the repository. I moved the file into test/scratch/ and removed that directory, so nothing remains outside the tree.","treeHash":null,"usage":{"cachedInputTokens":1300010,"inputTokens":452,"model":"claude-fable-5-1","outputTokens":23669,"runtime":"claude","turns":28,"wallClockMs":619570}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"60c045240bb09c33","findings":[{"citation":"resolved","description":"Asymmetric validation between the two deployment paths. script/Deploy.s.sol's DeployLib.check requires basePrice > 0, dev != address(0) and that imd/beacon have code, but the constructor only validates delay_. launch.json (the swarm/production path) instantiates IdentityMachine directly with constructor args and bypasses DeployLib entirely, so the only guard that actually runs in production is the one in the constructor. All four parameters are immutable and there is no owner or admin to repair them. With dev = address(0) every mint still accrues 2% to devBalance, but payDev() calls imd.safeTransfer(address(0), a), which the live IMD token (OpenZeppelin v5 ERC20 at 0x5F7Bb59365ce557C26dbcAa4EE9d39A4b95B7127 on chain 4663, verified via eth_call: ERC20InvalidReceiver) reverts, so the developer share is locked in the contract forever and that 2% of every mint is dead capital. With basePrice = 0 every mint is free (total = 0) while still issuing inventory items. launch.json uses the placeholder \"$owner\" for dev; if the launcher's substitution yields the zero address (e.g. an unset owner) the deployment silently ships in this state. Fix: mirror DeployLib.check in the constructor: revert when dev_ == address(0), basePrice_ == 0, or address(imd_).code.length == 0 / address(beacon_).code.length == 0.","line":188,"path":"src/IdentityMachine.sol","reproduction":"new IdentityMachine(imd, beacon, address(0), bytes32(0), 0.1 ether, 0) succeeds (expected: revert like DeployLib.check). Then imd.approve(machine, max); machine.mint(1, max) -> devBalance == 0.002 IMD; machine.payDev() reverts with ERC20InvalidReceiver(0x0) and will revert on every future call, so devBalance can never leave the contract. Separately, new IdentityMachine(imd, beacon, address(1), bytes32(0), 0, 0) succeeds and machine.mint(200, 0) returns total == 0 (200 free parts). Reproduced in test/scratch/DevZero.t.sol against the current code.","severity":"low","snippet":"        if (delay_ > 30 days) revert BadDelay();\n        imd = imd_;\n        beacon = beacon_;\n        inventory = IParts(address(new Parts(address(this)))); // created here, so only this machine can issue items\n        dev = dev_;\n        seatRoot = seatRoot_;\n        basePrice = basePrice_;","title":"IdentityMachine constructor accepts a zero dev address and a zero basePrice that DeployLib.check rejects; the swarm deploy path (launch.json) never runs that check"},{"citation":"resolved","description":"The drand round that reveals a mint is derived from block.timestamp, not from a future block or a commit/reveal step. roundAt(t) is the first round published at or after t, so dr is published between t+9 s and t+12 s of the block's own timestamp. On Robinhood Chain (an Arbitrum Orbit rollup) the sequencer sets block.timestamp and the protocol permits it to lag L1/wall-clock time (e.g. during sequencer delays or delayed-inbox inclusion). Whenever the chain timestamp lags real time by 9 s or more, the drand signature for dr is already public when the mint transaction is built: a minter can compute keccak256(keccak256(sig), id) for the ids they would receive (nextId is public), evaluate rollTier/rollDrop off-chain, and only submit mints that land legendary tiers, or bundle a mint with build() carrying the proof in the same transaction. The contract has no owner and no way to widen the margin after deployment. This is a documented trust assumption (the comment states 9-12 s) rather than a code defect in the guarded paths, and it is recorded here as the one external dependency on which the permissionless reveal() relies. A larger margin (or anchoring dr to a later block) would reduce exposure at the cost of a slower reveal.","line":285,"path":"src/IdentityMachine.sol","reproduction":"State: beacon.randomness(roundAt(block.timestamp) + 3) != 0 at the time a mint transaction is being constructed (true whenever block.timestamp is >= 9 s behind drand's wall-clock schedule). Input: an attacker reads nextId = N, computes r = beacon.randomness(dr), x = keccak256(abi.encode(r, N)), tier = rollTier(x); if tier == 3 they call mint(1, max) in the same block and receive a guaranteed legendary; otherwise they wait one block and repeat. Expected: the drop is unknown when the price is paid. Actual: the drop is knowable before paying. No in-repo reproduction, because it depends on sequencer timestamp behaviour outside the contracts.","severity":"info","snippet":"        uint48 dr = uint48(beacon.roundAt(block.timestamp) + DRAND_MARGIN);","title":"Reveal unpredictability rests on the chain's block.timestamp being within ~9 s of wall-clock time (DRAND_MARGIN = 3 rounds); a lagging L2 timestamp lets a minter pre-select drops"},{"citation":"resolved","description":"Economics-by-input-shape asymmetry (Trust Gap seam 2). Self-referral is explicitly allowed by the docstring on mintWithReferral, so any minter who passes their own address as referrer gets 3% of the price back via claimReferral(), while a minter who calls mint() (or EthMinter.mint without a referrer) pays the same price and that 3% instead stays in the 26% shared pool. The effective price therefore differs by 3% purely on which entry point the caller used, and the only way to be on the losing side is to not know about the trick. Because the contract has no owner this cannot be adjusted post-deployment; the intended design appears to be a universal 3% rebate, in which case mint()/mintFor() could apply it automatically (referrer defaulting to the recipient) so no user class is silently disadvantaged, or self-referral could be disallowed if the intent is a true referral program. Recorded as a trust/design note, not a permission bypass.","line":316,"path":"src/IdentityMachine.sol","reproduction":"Same block, same round, two users with equal balances. Alice: mint(1, max) at price p. Bob: mintWithReferral(1, max, bob) at the same price p (next k, so p' slightly larger but set k equal by using two fresh machines). Expected under a uniform price: equal net cost. Actual: referralBalance[bob] == p * 300 / 10000 and bob can call claimReferral() immediately, so bob's net cost is 0.97 p while alice's is p; the 0.03 p difference in alice's case was added to _distribute (shared income of placed parts) rather than returned.","severity":"info","snippet":"        if (referrer != address(0) && referrer != address(this) && referrer != DEAD) {\n            reward = (total * REFERRAL) / BPS;\n            referralBalance[referrer] += reward;","title":"mint() and mintWithReferral(self) are priced identically but the latter refunds 3% of the price to the caller: an unreferred minter subsidises shared income relative to a self-referring one"},{"citation":"resolved","description":"sqrtPriceLimitX96 = 4295128740 is MIN_SQRT_PRICE + 1, i.e. no price limit. The exact-output swap for `total` IMD will consume as much ETH as the pool demands, capped only by `spent > budget` where budget = msg.value (line 112). The `expires` deadline (line 85) does not bound price. A client that follows the contract's own guidance (quote(n) then send a buffer above ethTotal so the transaction is not reverted by small price moves) exposes the whole buffer: an MEV searcher who sees the pending transaction can move the ETH/IMD pool so the exact-output swap costs exactly msg.value, then unwind. The user still receives their parts, so this is value leakage from the ETH payer to the sandwicher, bounded by the buffer the payer chose. Fix options: accept a maxEth parameter distinct from msg.value (so the refund path and the slippage bound are decoupled), or pass a caller-supplied sqrtPriceLimitX96. This is a hardening note; the user does control the bound, so it is not a permission defect.","line":107,"path":"src/EthMinter.sol","reproduction":"User calls EthMinter.mint(1, maxImd, expires) with msg.value = 1.2 * ethQuote (a 20% buffer). Attacker front-runs with a large ETH->IMD swap in the same v4 pool so that the exact-output cost of `total` IMD rises to 1.2 * ethQuote, and back-runs after. Expected: the user pays about ethQuote. Actual: unlockCallback computes spent == msg.value (passes `spent > budget` check with equality), refund == 0, and the user paid 20% more than quoted while the attacker captured the difference.","severity":"info","snippet":"        int256 delta = poolManager.swap(poolKey(), EthSwapParams(true, int256(total), 4295128740), \"\");","title":"EthMinter swaps with no sqrtPriceLimit; the only slippage bound is msg.value, so any ETH over-provisioned as a buffer is extractable by a sandwich"}],"hash":"589d1fd04b181be7c8667c7e013a3e6e232ca61308602d424e4d012ff0702807","nodeId":"b30defbb-665f-448d-a811-c841c806d5e2","outcome":"completed","summary":"The findings file is written and validated. Here is the recap.\n\n**Outcome:** The permission model holds. I inventoried every state-changing entry point in `IdentityMachine`, `Parts`, `DrandBeacon` and `EthMinter`, traced each guard to the state it protects, and found no permission bypass, escalation path, or asymmetric write that lets one caller class take value from another. One low and three informational findings are in `.imd-findings.json`, each with a concrete failing input or state. Nothing rated high or critical, so no proof tests were attached.\n\n**What was checked in depth**\n- Every external write path and its caller assumption: `build` and `claim` bind to `inventory.ownerOf` and `placerOf`, `Parts.mint/burn` to the immutable machine, `claimAgentCore` to a leaf containing `msg.sender`, `EthMinter.unlockCallback` to the pool manager plus a pending flag.\n- Paired surfaces for asymmetry: `quote` against `_mint`, `earned` against `claim`, `sponsor` and `fundPot` and `mint` at the round boundary, `endRound` against `build`, `_place` plug-pay against `_endRound` prizes, and the growth-exit witness. Accounting sums to the amount paid in on every mint path.\n- Cross-contract trust: the live IMD token on Robinhood Chain is an OpenZeppelin v5 ERC-20 with an owner but no pause or blocklist entry points, and it reverts on transfers to the zero address. The seat snapshot holds seats 0 to 1999, so the `seat + 1` arithmetic cannot overflow.\n\n**Findings**\n- **Low.** The constructor accepts `dev = address(0)` and `basePrice = 0`, which `DeployLib.check` rejects, and the production path in `launch.json` never runs that check. With a zero dev address `payDev` reverts forever and the 2% share is locked. Reproduced in `test/scratch/DevZero.t.sol`.\n- **Info.** Reveal unpredictability depends on the chain timestamp staying within about 9 seconds of drand's wall clock. A lagging sequencer timestamp lets a minter pre-select drops.\n- **Info.** Self-referral is allowed, so `mint()` costs 3% more net than `mintWithReferral(self)` for identical output.\n- **Info.** `EthMinter` swaps with no price limit. The only slippage bound is `msg.value`, so any buffer the payer sends is sandwichable.\n\n**Limits.** Baseline suite: 212 tests pass, one fails only because the generated `art/out/templates.json` fixture is absent. I did not run the fork, invariant or large-batch suites, and BLS signature correctness in `DrandBeacon` was outside the assigned area and relies on the repository's own vector tests.","treeHash":null,"usage":{"cachedInputTokens":854536,"inputTokens":290,"model":"claude-fable-5-1","outputTokens":31299,"runtime":"claude","turns":30,"wallClockMs":672699}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"523ef565dd740e25","findings":[{"citation":"resolved","description":"Boundary x invariant (time boundary). `check(design, rot, x, y)` is the documented fit oracle 'for agents and UIs'. It wires against `rounds[round]` unconditionally, whereas `price()`, `quote()` and `_mint` all gate on `_live()` and treat an expired clock as the next round (k = 0, bare core). Between the deadline passing and `endRound()` being called, `check` therefore reports a placement as fitting, and names payees, on a machine that can never receive another part: `build(round, ...)` reverts `RoundOver`, and after the round rolls over the same spot is empty space next to nothing and `check` reverts `NotPlugged`. An agent that trusts `check` (as the NatSpec invites) will submit builds that always revert, or price a placement by payees that will not exist. The same inconsistency exists with `cellAt`-style reads, but `check` is the one documented as a decision oracle. Expected: `check` to behave like `price()` and evaluate the machine the next build would actually run on (the fresh core when `!_live()`), or revert `RoundOver`. Fix: at the top of `check`, `if (!_live()) revert RoundOver();` (one line, preserves all other behaviour), or wire against a bare core when the clock has run out.","line":501,"path":"src/IdentityMachine.sol","reproduction":"State: round 1 running, alice places a common STRAIGHT (design 0) at (2,0) rot 0; it plugs the core's E socket. `check(0, 0, 3, 0)` returns [id] (correct). Warp 25 hours (clock expired, nobody called endRound). `timeLeft()` == 0 and `price()` == basePrice (next round). Actual: `check(0, 0, 3, 0)` still returns [id] (fits, pays that part). `build(1, [], [], [Spot(id2, 0, 3, 0)], exit)` reverts RoundOver. After `endRound()`, `check(0, 0, 3, 0)` reverts NotPlugged. Expected: check to report the machine the next build runs on (RoundOver or NotPlugged), consistently with price(). Verified in test/scratch/Leads.t.sol::test_checkIgnoresExpiredClock.","severity":"low","snippet":"        (uint256 links, uint256[8] memory into,) = _wires(round, n, cx, cy, m);","title":"check() answers for the expired machine while price()/quote() already price the next round"},{"citation":"resolved","description":"Boundary x invariant. The invariant 'a mint's randomness is unknown when the mint transaction executes' rests on `roundAt(block.timestamp) + 3` being a future drand round. drand evmnet publishes every 3 s, so the reveal round is published 9 to 12 s after `block.timestamp`. On an L2 whose sequencer stamps blocks with a clock that trails wall-clock time by 9 s or more (the Arbitrum-style inbox only bounds the timestamp against L1 posting time, with a 24 h past / 1 h future window, so nothing on chain enforces a tighter bound), the signature for `roundAt(ts)+3` is already public when the mint executes. A minter can then compute `rollTier`/`rollDrop`/`partFor` for the ids it will receive (ids are sequential and `nextId` is public) and only send the mint, or only include it, when the result is a legendary or rare part, turning the 1 % legendary odds into a choice. The repository's own test (test/audit/Beacon.t.sol, IM05) records 9 s as the exact limit, so this is a documented trust assumption on the sequencer's clock rather than a logic error; it is reported because the margin is the one tunable that bounds it and widening it costs only reveal latency. Fix (no design change): raise DRAND_MARGIN to e.g. 10 rounds (30 s), or compute the reveal round from `beacon.latest() + margin` so that it is always after the newest proven round regardless of the block clock.","line":112,"path":"src/IdentityMachine.sol","reproduction":"State: block.timestamp = T, drand wall clock = T + 9 (sequencer lag 9 s), so drand has published `roundAt(T) + 3`. Input: `mint(1, max)`. Actual: `pending[id].drandRound` == roundAt(T)+3, whose signature already exists off chain; `keccak256(abi.encode(keccak256(sig), id))` and therefore the tier, template and design are computable before the mint is sent. Expected: the reveal round to be unpublished at mint time. Verified in test/scratch/Leads.t.sol::test_nineSecondLagMakesRevealKnownAtMint (the mock beacon stands in for the published signature; with lag < 9 s the assertion `dr <= published` fails).","severity":"low","snippet":"    uint256 public constant DRAND_MARGIN = 3; // drand rounds (9-12 s) between a mint's block time and its reveal","title":"A block timestamp 9 s behind drand's clock makes a mint's reveal round already public at mint time"},{"citation":"resolved","description":"Precision x invariant (payout table). The contract header and `_endRound` NatSpec say 'the last ten parts placed share 70% of the pot' and 'the rest of the pot starts the next machine's pot'. The loop pays `prize * placeBps(i) / BPS` only for i < filled, so when `placed < 10` the bps of the empty places (for one part: 7000 of the 10000) are never paid and roll into the next pot together with the 30%. A round with one builder therefore pays 21% of the pot, not 70%, and a round with five builders pays 51%. The accounting is internally consistent (`carried` in RoundEnded shows the real amount) and no value is lost, so this is reported as a specification mismatch for the requester to confirm. If the intent is the documented one, normalise the table over the filled places (sum of placeBps(0..filled-1)) or pay the remainder to the last place; if the current behaviour is intended, say so in the header.","line":596,"path":"src/IdentityMachine.sol","reproduction":"State: round 1 pot funded with 100 IMD via fundPot; alice places one part; clock expires; `endRound()`. Actual: `earned(id)` == 21 IMD and `rounds[2].pot` == 79 IMD. Expected per NatSpec: 70 IMD to the placed parts, 30 IMD carried. Verified in test/scratch/Leads.t.sol::test_prizeWithOnePartIs21Percent.","severity":"info","snippet":"        uint256 filled = placed < 10 ? placed : 10;","title":"With fewer than ten parts placed, the unfilled places' share of the 70% prize carries over rather than being shared"},{"citation":"resolved","description":"Boundary (totalWeight == 0 fast path). `sponsor` is documented as 'IMD paid straight to every part of the running machine, by weight' and emits Sponsored. When no part has been placed yet (the whole pre-start window and the start of every round, since each round opens as a bare core) `_distribute` routes the amount into `R.pot`, where 70% becomes the last-ten prize and 30% carries to the next round. A sponsor who intended to reward builders ends up funding the prize, which `fundPot` already exists for. Nothing is lost, so informational: either document the fallback in sponsor's NatSpec or revert `sponsor` with `NotPlugged`-style error when `totalWeight == 0`.","line":623,"path":"src/IdentityMachine.sol","reproduction":"State: fresh deployment, no part placed. Input: `sponsor(10 IMD)`. Actual: `rounds[1].pot` == 10 IMD, Sponsored emitted, no part can ever claim it as sponsorship. Expected per NatSpec: paid to parts by weight (none exist), i.e. revert or documented fallback. Verified in test/scratch/Leads.t.sol::test_sponsorWithNothingPlacedGoesToPot.","severity":"info","snippet":"            R.pot += amount; // nothing placed yet: the pot takes it","title":"sponsor() with nothing placed funds the prize pot, not 'every part of the running machine'"},{"citation":"resolved","description":"Boundary (clock arithmetic). `roundAt(ts)` is the first round published at or after `ts` (0 to 3 s later); `+ DRAND_MARGIN` (3 rounds of 3 s) puts the reveal round 9 to 12 s after block time, as the constant's own comment states. The public NatSpec on `mint` says 15 s. The ABI comment is what integrators read, and the number also sets expectations about the lag tolerance in the previous finding. Align the NatSpec with the constant.","line":251,"path":"src/IdentityMachine.sol","reproduction":"Input: `mint(1, max)` at block time T. Actual: `beacon.timeOf(pending[id].drandRound) - T` is in [9, 12]. Expected per NatSpec: about 15. Verified in test/scratch/Leads.t.sol::test_revealRoundDistance.","severity":"info","snippet":"    /// @notice Mint `n` parts (at most 200). Each is revealed by a drand round about 15 seconds later.","title":"mint NatSpec says the reveal round is 'about 15 seconds later'; the code schedules it 9 to 12 seconds later"}],"hash":"a46cbc4ad8d9c6344879aed21c42f76662783d949da98d8c17693c77dcd0a6d0","nodeId":"5f03bf83-a6aa-430e-b17d-d2b6b88fb91e","outcome":"completed","summary":"The review is complete and the findings file is written and validated. Git shows no tracked-file changes (`.imd-findings.json` and `test/scratch/` are ignored).\n\n**Outcome:** no critical, high or medium defects in the math. The findings file holds 2 low and 3 info findings, each with a concrete failing state verified by a scratch Foundry test.\n\n**What was audited, in depth, against the Math Precision, Boundary and Numerical Gap guides:**\n- **Price curve and split.** `priceAt`, per-mint plug rounding versus the batch-level 5/2/2/15/3 % cuts, reserve matching, and the 26 % remainder. The shares sum to 7700 bps so the shared remainder is never negative, and IMD in equals IMD allocated plus burn on every path (mint, place, end, sponsor, fund).\n- **Reward accumulator.** `perWeight` at 1e18 scale, `debt` set at placement, `earned` and `claim` using the same formula, duplicate ids in `claim` and `reveal`, overflow headroom on `w * perWeight` and `amount * MAG`.\n- **Prize table.** The packed bps constant sums to 10000, `placeBps(i)` for i ≥ 10 is 0, `paid ≤ prize`, and `pot - paid` is carried.\n- **Clock arithmetic.** `_live` at the exact deadline, the 30 s extension against the 24 h cap, `uint40` narrowing, round 1 opening at `start`.\n- **Geometry and integer casts.** Rotation of positions and socket masks agree (clockwise with y down), `&` binds tighter than `!=` in Solidity so the edge-match test is correct, `R.open` cannot underflow, `_key` is collision-free across rounds and negative coordinates, `BOUND` is checked before stepping.\n- **Catalog tables.** The TIER and TMPL byte arrays and every slot in `slotParts` were cross-checked by hand against `dropOdds`, whose per-tier odds each sum to 1000.\n- **DrandBeacon.** `roundAt` is the first round at or after the timestamp, non-canonical signature encodings are rejected by the pairing precompile, and the signature length is fixed at 64 bytes.\n- **EthMinter.** Exact-output delta decoding, the `int128` bounds, the price-limit constant, the budget check and refund arithmetic.\n\n**Findings written:**\n1. **Low.** `check()` wires against the expired machine after the clock runs out, while `price()` and `quote()` already price the next round. Agents trusting it will submit builds that revert.\n2. **Low.** A sequencer clock 9 s behind drand's makes a mint's reveal round already public at mint time. The repository's own IM05 test records this limit, so it is reported as a trust assumption with a cheap mitigation of widening `DRAND_MARGIN`.\n3. **Info.** With fewer than ten parts placed, the empty places' share of the 70 % prize carries over, so one builder receives 21 % rather than the documented 70 %.\n4. **Info.** `sponsor()` with nothing placed goes to the prize pot despite the NatSpec promise of paying parts by weight.\n5. **Info.** The `mint` NatSpec says 15 s to reveal; the code schedules 9 to 12 s.\n\n**Checks run.** The full suite except fork and invariant runs: 214 passed, 1 failed only because the generated `art/out/templates.json` fixture is absent in this environment. Invariant and fork tests were skipped for time. No proof files were needed since nothing reached high severity.","treeHash":null,"usage":{"cachedInputTokens":1048783,"inputTokens":294,"model":"claude-fable-5-1","outputTokens":40964,"runtime":"claude","turns":24,"wallClockMs":970207}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"6b16b4979d227241","findings":[{"citation":"resolved","description":"sponsor() is documented as paying 'every part of the running machine, by weight', but unlike build(r, ...) it takes no round id. It reads the global `round` at execution time, and when a round has just been closed (by any mint or endRound()) the new round has totalWeight == 0, so _distribute() falls through to `R.pot += amount` (line 623). The sponsor's IMD then becomes prize money for the next round (70% to its last ten placers, 30% carried) instead of income for the builders the sponsor saw. The only guard, `_live()`, cannot detect this because the new round is live. Flow-gap: build() carries `r` and reverts with RoundOver on a stale round; sponsor() and the pot fallback accept the stale intent silently. Fix: add a `uint256 r` parameter and `if (r != round) revert RoundOver();` (and/or revert when `rounds[round].totalWeight == 0`, since 'pay the builders' with no builders is a no-op by intent). Severity is low because only the sponsor's own funds are redirected and they stay inside the protocol.","line":550,"path":"src/IdentityMachine.sol","reproduction":"State: round 1 has one placed part (a CAP at (0,-2) placed by alice, totalWeight 1) and its deadline D. A sponsor submits sponsor(100e18) at D-5 s intending to reward alice. Before it executes, block.timestamp reaches D and any `mint(1, max)` executes first, which runs _endRound() and opens round 2 (totalWeight 0). Expected: either the sponsorship reaches round 1's builders or the call reverts (as build(1, ...) would with RoundOver). Actual: sponsor() succeeds, rounds[2].pot grows by exactly 100e18, Sponsored(who, 2, 100e18) is emitted, and earned(alice's part) is unchanged. Verified with a Foundry test (test/scratch/Econ.t.sol, test_sponsorHasNoRoundGuard: assertEq(pot after, pot before + 100 ether) and earned(id) unchanged both hold on the current code).","severity":"low","snippet":"    function sponsor(uint256 amount) external {\n        if (!_live()) revert RoundOver();\n        imd.safeTransferFrom(msg.sender, address(this), amount);\n        _distribute(rounds[round], amount);","title":"sponsor() has no round guard: a sponsorship meant for the running machine's builders can land in the next round's empty pot"},{"citation":"resolved","description":"The contract header (line 35) says 'The reserve matches every round's pot money mint for mint', and the split comment says '15% round pot (matched by the reserve)'. In _mint the reserve is debited by min(toPot, reserve) before this mint's own 5% is credited (line 323), and it receives only 5% per mint while paying out up to 15%. Without external fundReserve() deposits the reserve therefore never holds more than the previous mint's 5%, so the effective match is ~5% of the prior mint's price (one mint lag), i.e. the pot receives ~20% of mint volume, not ~30%. app/public/llms.txt does say 'while it lasts', so the behaviour is intended; the on-chain header is the misleading surface. Fix: reword line 35 (and the split comment) to 'matched by the reserve while it lasts'. No value is lost or mis-accounted.","line":311,"path":"src/IdentityMachine.sol","reproduction":"State: fresh deployment, basePrice 0.10 IMD, reserve 0. mint(1): total 0.1, toPot 0.015, matched = min(0.015, 0) = 0, reserve becomes 0.005. mint(1) again (price 0.1*(1501/1500)^2 = 0.1001333...): toPot 0.0150200, matched = min(0.0150200, 0.005) = 0.005, reserve becomes 0.0050067. Expected from the header: pot money matched 1:1 (0.0150200 matched). Actual: 0.005 matched. test/security/Lifecycle.t.sol:249-251 already asserts exactly this one-mint behaviour.","severity":"info","snippet":"        uint256 matched = toPot < reserve ? toPot : reserve;\n        reserve -= matched;\n        devBalance += devCut;\n        R.pot += toPot + matched;","title":"Header overstates the reserve match: with only mint income the reserve matches about 5% of the previous mint, not the 15% pot money, because it is drained every mint"},{"citation":"resolved","description":"Each mint commits to drand round roundAt(block.timestamp) + 3, which drand publishes 9-12 s after the block's timestamp. The commitment is only unpredictable if the block's timestamp is within ~9 s of the time the transaction is actually sequenced. If the L2 timestamp lags wall time by 9 s or more, the signature for `dr` is already public when the mint executes; a minting contract can then read beacon.randomness(dr) (if already proven on chain) or, off chain, pre-compute keccak256(abi.encode(r, nextId)) and submit only when rollTier() yields tier 3 (1% legendary, weight 20, 70% BIGHUB). This is a trust assumption on the sequencer clock, not a code defect: today Robinhood Chain block 90307430 carried timestamp 1792176825 against a wall clock of 1792176826 (1 s drift), so the margin holds. Document the assumption; raising DRAND_MARGIN (e.g. to 10 rounds / 30 s) costs a few seconds of reveal latency and widens the safety margin.","line":285,"path":"src/IdentityMachine.sol","reproduction":"Failing state: a block with block.timestamp = T executed when wall time is >= T + 9 s. Then timeOf(dr) = GENESIS + 3*(roundAt(T)+2) <= T + 12 s < wall time + 3 s, so drand's signature for dr is public before the mint is final. Input: attacker contract calls prove(dr, sig) then mint(1, max) in the same transaction only if rollTier(uint256(keccak256(abi.encode(keccak256(sig), nextId)))) == 3; otherwise it reverts and pays only gas. Expected: the outcome of a mint is unknown to the minter when they commit. Actual (under the lagging-clock state): the minter mints only legendaries. Not reproducible in Foundry without faking the clock relation; included as a documented trust assumption.","severity":"info","snippet":"        uint48 dr = uint48(beacon.roundAt(block.timestamp) + DRAND_MARGIN);","title":"Reveal randomness is bound to block.timestamp with a 9 s margin: a chain clock lagging wall time by 3 drand periods lets a minter choose only favourable mints"}],"hash":"b5d140ed9028aedab598d4a54ce67cb1d2c6400546d5d988ed3699bed8afa0d6","nodeId":"3047cd40-a829-4eac-b1b5-39ca523f4dc9","outcome":"completed","summary":"The review is complete. The findings file `.imd-findings.json` holds three substantiated items and the scratch test directory has been removed, so the tree is otherwise untouched.\n\n**What I reviewed.** All production contracts in `src/`, the deploy script, and the test harness, against the Economic Security, Invariant and Asymmetry (flow gap) guides. I also checked the live IMD token on Robinhood Chain: it is a plain 18-decimal ERC-20 with an owner and no pause, blacklist, mint or upgrade selectors, and the chain clock was within one second of wall time.\n\n**What held.** Each mint's split conserves value exactly, with the shared slice absorbing rounding dust. Plug-pay, shared income, prizes, referral and dev balances are all backed by held IMD. The clock is monotonic and capped, the prize table sums to 100%, the growth-exit rule preserves a buildable boundary socket every build, and the view functions agree with the state-changing paths they mirror. The beacon already rejects non-canonical and negated signature encodings, so the first prover cannot pick a randomness.\n\n**Findings written:**\n\n- **Low, `sponsor()` has no round guard.** A sponsorship sent while round r is running can execute after a mint closed it. The new round has no builders, so the whole amount falls through to the new round's pot and becomes prize money rather than builder income. Confirmed with a Foundry test: the pot grew by the full 100 IMD and the round-1 builder earned nothing. Fix is a round parameter checked like `build` does, or a revert when nothing is placed.\n- **Info, header overstates the reserve match.** The reserve receives 5% per mint and pays up to 15%, so with no outside funding it matches only the previous mint's 5%. The site text says \"while it lasts\"; the on-chain header does not.\n- **Info, reveal randomness relies on the sequencer clock.** A block timestamp lagging wall time by 9 seconds or more would make the committed drand round already public at mint time, letting a contract mint only legendaries. The chain is in sync today, so this is a documented trust assumption, not a defect.\n\n**Not covered.** The long-running invariant fuzz suite and fork tests were not run within the time budget. No critical or high finding was substantiated, so no proof tests are attached.","treeHash":null,"usage":{"cachedInputTokens":1438505,"inputTokens":418,"model":"claude-fable-5-1","outputTokens":39987,"runtime":"claude","turns":33,"wallClockMs":648163}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"6ed09c2eb1ff6aec","findings":[],"hash":"c990bc182bfc1d9445731f9fefd73ec76f8e8378aea9c9b2b55f215e4d482c28","nodeId":"94a7f721-961e-4c03-a310-4284e8e678f9","outcome":"completed","summary":"I found no defects I could back with a concrete failing input, so `.imd-findings.json` holds `{\"findings\":[]}`. I didn't run the test suite or write any proof tests; all of this comes from reading the code. A clean result is not proof that there are no defects.\n\n**What I covered, in depth:**\n- **`IdentityMachine` mint path:** batch limits, price curve, rounding when splitting each mint into its shares, reserve matching, referral cut, and that the shared share can never go negative. The required 50% plug-pay is the sum of rounded-down per-mint amounts, so it never exceeds half the total.\n- **Reveal:** which drand round a mint is tied to (`DRAND_MARGIN`), and that an id can't be revealed twice (pending cleared, and `Parts.mint` rejects an id already issued).\n- **Build and placement:** placement checks (ownership, burn, occupied cells, socket matching, at least one socket plugged, map bounds). The plug-pay list has room for 8 entries, and the largest part, the 3×3 reactor, has exactly 8 sockets, so it can't overflow. Also checked the open-socket count and clock extension, and that the bounding-box growth-exit check is sound (the cell beyond an outer edge is always empty).\n- **Earnings:** `earned`/`claim` bookkeeping (a part earns nothing from income before it was placed; duplicate ids claim nothing extra), `_endRound` prize places and the carry-over to the next pot, and the `fundPot`/`sponsor`/`_distribute` edge cases when nothing is placed or the round is over.\n- **Access control:** every function that changes state. `Parts` only accepts calls from the machine, which creates it in its constructor. `EthMinter`'s swap callback is checked against both the pool manager and the flag it sets itself. The only privileged role is the dev payout address. There is no owner or admin.\n- **`DrandBeacon`:** I checked whether one drand round could be proven with several different signature encodings, giving different randomness. The precompile rejects coordinates at or above the field prime, and `test/security/Beacon.t.sol` already covers those encodings.\n- **Periphery:** `EthMinter`'s quote matches what `_mint` charges, including when the round has just ended. `MachineViews` is read-only and has no effect on state.\n\n**Leads I dropped as not worth a finding:**\n- **Rounding dust:** each payout to placed parts rounds down and leaves less than one wei per unit of weight stranded. The amount is negligible.\n- **Seat 65535:** `seat + 1` would overflow and revert in `claimAgentCore`. I didn't check whether the seat snapshot actually contains a seat that high.\n- **Sequencer timestamps:** the deploy script targets Robinhood Chain (an L2). There, only a sequencer that chose block timestamps in the past could tie a mint to a drand round that is already public. That is a trust assumption about the sequencer, not a code defect.\n\n**Not covered:** the frontend in `app/`, the deploy scripts, and the `src/dev` mocks, beyond what the deployment trace needed.","treeHash":null,"usage":{"cachedInputTokens":407270,"inputTokens":18,"model":"claude-opus-5-5","outputTokens":6864,"runtime":"claude","turns":9,"wallClockMs":109942}}],"verification":[]}