{"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":"d726b1cd-bb4c-49cf-adb8-8be595c9abab","kind":"audit","nodes":[{"acceptedSubmissionHash":"6e08a2a29687f03d282c8b9320671f790eeda6879d3c21cfa386477898ce9498","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"bc5bfc8455464381f2db18badb2b55cf8bec1e535c019349a0378fa990741171","skillId":"audit-specialist","subagents":3,"tools":[]},"key":"audit_economics","kind":"code","role":"review","skillHash":"bc5bfc8455464381f2db18badb2b55cf8bec1e535c019349a0378fa990741171","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"d1fdd956245cde1a2fb32458935cf4f90e598467b48ef2e6fd6f2637475f77b0","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"bc5bfc8455464381f2db18badb2b55cf8bec1e535c019349a0378fa990741171","skillId":"audit-specialist","subagents":3,"tools":[]},"key":"audit_flow","kind":"code","role":"review","skillHash":"bc5bfc8455464381f2db18badb2b55cf8bec1e535c019349a0378fa990741171","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"ea4e0dfbb0bb9b111f298bbeb8849560ef79fb3f0ef1585d45a13bd482d60d31","dependsOn":["audit_math","audit_permissions","audit_economics","audit_flow"],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"b89279670f5a00f5d26286edee00c97dd158b997031350a01cafe6e0329ad227","skillId":"audit-judge","subagents":4,"tools":[]},"key":"audit_judge","kind":"code","role":"review","skillHash":"b89279670f5a00f5d26286edee00c97dd158b997031350a01cafe6e0329ad227","skillId":"audit-judge","state":"accepted"},{"acceptedSubmissionHash":"fd987220a173c4205eaa80b89efeff5726c4b19c0145610cd089bd89b1c10cca","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"bc5bfc8455464381f2db18badb2b55cf8bec1e535c019349a0378fa990741171","skillId":"audit-specialist","subagents":3,"tools":[]},"key":"audit_math","kind":"code","role":"review","skillHash":"bc5bfc8455464381f2db18badb2b55cf8bec1e535c019349a0378fa990741171","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"08613f1a428e76e5040aa402d20e1cb51545d8a2dafb1d0b871aba7bea75cfb8","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"bc5bfc8455464381f2db18badb2b55cf8bec1e535c019349a0378fa990741171","skillId":"audit-specialist","subagents":3,"tools":[]},"key":"audit_permissions","kind":"code","role":"review","skillHash":"bc5bfc8455464381f2db18badb2b55cf8bec1e535c019349a0378fa990741171","skillId":"audit-specialist","state":"accepted"}],"objective":"Review Submission3/ only. Read Submission3/AUDIT_README.md, then Submission3/FINDINGS_RESPONSE.md (how each finding of the second audit, job 388b71cb, was handled, plus internal review items L-1 and L-2). Primary scope: Submission3/contracts/EmberEvoGenesisCandidate.sol (verified on Sepolia 0x0FB5B6E5c1B026c611900a864755faC86dAd4085, solc 0.8.38). Not in scope: contracts/test, contracts/testnet, OpenZeppelin. 1) Check the fixes: an expired seed block keeps its public secret and re-arms to block.number+1 (no secret-only seal, never a zero block hash); REARM_COOLDOWN after the current seed block, a pending re-arm included; chosen-skin freeze counted from the epoch's own seed block; MAX_COMMIT_REARMS = 2 per epoch, shared across a backlog (_commitRearms, L-1); RevealCommitAcceptance bound to the commitment it replaces (previous, L-2); 32-hash walk above every anchor. 2) New hand-packed storage: revealState (nextRevealIndex, epoch count, re-arm seed block, re-arm epoch, expired flag, rearm count) and randomEpochWords (8 x 32-bit epochs per word). Can any shift, mask or stale read break the reveal order, the re-arm bound or a draw? 3) Can a minter, the owner alone, the signer alone or a block builder steer a random skin beyond what README 8.2 states; is the keeper assumption stated correctly? 4) Secondary: the signer's readyReveals with epochSecretOpened. Report severity, reproduction and fix.","parentJobId":null,"planHash":"417c8e9588ebc30c1ed1e5f0b457fe99e661fde16c51e9c26c945fb8f381bb4c","previousHash":"0000000000000000000000000000000000000000000000000000000000000000","projectId":"d726b1cd-bb4c-49cf-adb8-8be595c9abab","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":"52590","feedbackHash":"b388f787614e8c657bfe7d1e11748bf25dc76d55662c4a6cfdc063c6cc3288c1","nodeKey":"audit_economics","submissionHash":"6e08a2a29687f03d282c8b9320671f790eeda6879d3c21cfa386477898ce9498","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51332","feedbackHash":"f2ba4c988316d48efa06dabc689d025fabbdb7203c24d91c8e9e5bf20369842d","nodeKey":"audit_flow","submissionHash":"d1fdd956245cde1a2fb32458935cf4f90e598467b48ef2e6fd6f2637475f77b0","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"53910","feedbackHash":"d3b525fab948d65914a07ba53849225c8c428f75c1ba3fc1dc813220995964db","nodeKey":"audit_judge","submissionHash":"ea4e0dfbb0bb9b111f298bbeb8849560ef79fb3f0ef1585d45a13bd482d60d31","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51193","feedbackHash":"a580098d1d8ba35864d25a2cefb7d1b65308ec064d6e235bd80ed9072273ab23","nodeKey":"audit_math","submissionHash":"fd987220a173c4205eaa80b89efeff5726c4b19c0145610cd089bd89b1c10cca","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52158","feedbackHash":"ab4aff1dfb0ea99c7fa314aa3d07c5228f502a61313f108c662121b39f7f7fc0","nodeKey":"audit_permissions","submissionHash":"08613f1a428e76e5040aa402d20e1cb51545d8a2dafb1d0b871aba7bea75cfb8","tag1":"review:submission","tag2":"acceptance-v2","value":1}],"site":null,"snapshotHash":"95205e61c0f99c16ebe6467958cbeb4138c1cf99aec64b6eb09549f3d36dd73d","state":"completed","submissions":[{"artifacts":[],"attempt":1,"bundleHash":null,"device":"4dd67dae195771b6","findings":[{"citation":"resolved","description":"The L-1 fix makes an epoch inherit the commitment re-arm count of the last re-armed epoch when `e >= from` and the *current* re-arm block is later than e's natural seed block. But the current re-arm block is also rewritten by every expiry re-arm (`_settleReady` calls `_rearm(e, true, _commitRearms(e))`, which copies the count and sets a new, much later seed block). An epoch e2 that closed after the last commitment re-arm (so it was never re-drawn by a commitment) but before a later expiry re-arm of the same waiting epoch therefore satisfies `r > natural(e2)` and inherits a count it never earned. Its own commitment re-arms are then capped at MAX_COMMIT_REARMS minus the inherited count: with a carried count of 1 it gets one replacement, with 2 it gets none. README section 5 and the MAX_COMMIT_REARMS comment promise two per epoch as the way out if a chain is lost; for every epoch behind an expiry that follows a replacement, that way out is narrower than documented, and when the count carried is 2 a lost chain stalls that epoch, every later random reveal and every chosen-skin mint (REVEAL_PENDING) for good. No steering is possible through this path (the bound errs on the strict side), so the impact is liveness and a mismatch with the stated design, not randomness.","line":324,"path":"Submission3/contracts/EmberEvoGenesisCandidate.sol","reproduction":"startBlock = 1000, EPOCH_BLOCKS 25. (1) Epoch e0 gets a random mint; its natural seed block is 1025. (2) At block 8226 (> 1025 + 7200), with minting paused, the owner replaces the chain: e0 is re-armed to r1 = 8227 with commitment count 1. (3) At block 8257 a random token is minted in epoch e2 (natural seed block > r1, so e2 was not moved by the replacement). (4) The new chain's first secret is not supplied until block 16519 (> r1 + 8191); settle([secret]) finds the hash of 8227 out of reach and re-arms e0 to 16520 with the carried count 1; e2 now reads epochSeedBlock = 16520 as well. (5) At 16521 settle([]) seals e0. (6) At 23722 (> 16520 + 7200) the owner replaces the chain: allowed, e2 is re-armed, count becomes 2 although this is e2's first replacement. (7) At 30925 (> 23723 + 7200) a second replacement for e2 is refused with EPOCH_REARM_LIMIT. Expected: e2 has had one commitment re-arm, so a second is allowed. Actual: EPOCH_REARM_LIMIT. The attached test fails on this code with that revert. Fix: inherit only from epochs that were closed at the time of the last *commitment* re-arm. Keep that block separately (e.g. use the spare bits 195-255 of revealState for the last commitment re-arm block, written in setRevealCommit only) and in _commitRearms compare `_naturalSeedBlock(e)` with that block instead of the current re-arm block; expiry re-arms keep copying the count for the from-epoch itself. The existing L-1 behaviour (an epoch already closed at the replacement shares the count) is unchanged.","severity":"low","snippet":"        return (e >= uint64(rs >> 128) && uint64(rs >> 64) > _naturalSeedBlock(e)) ? (rs >> 193) & 3 : 0;","title":"_commitRearms attributes commitment re-arms to epochs that were only moved by an expiry re-arm, so an epoch can lose its two replacements (EPOCH_REARM_LIMIT after one)"},{"citation":"resolved","description":"The four reads in Promise.all (and the per-epoch reads that follow) are issued without a block tag, so each may be answered at a different chain head. The loop pairs list index i (from `next`) with the i-th preimage above `commit`. If `nextRevealIndex` is answered before a settle that seals one epoch and `revealCommit` after it, `next` is one too small while `open` is already the sealed epoch's secret: for list index j the loop pushes chain.next(open), which is the secret of epoch j+1, after checking only that epoch j's seed block is CONFIRMATIONS deep. The last pushed value is then the secret of an epoch whose seed block may not be mined, or not yet 2 deep. The endpoint is public (GET /v1/reveals), so the builder of that seed block learns the secret before building it and can grind the hash: exactly the property the 2-confirmation rule and the EXPIRY_MARGIN rule exist to protect. The reverse ordering (commit stale, next fresh) only hands out one secret too few. ethers v6 batches the calls, which makes the race rare but not impossible (batch entries are not evaluated atomically, and a load-balanced RPC can answer from different nodes).","line":57,"path":"Submission3/offchain/signer/reveal-chain.mjs","reproduction":"State at head X: nextRevealIndex = 0, revealCommit = x0, list = [e0, e1], epochSeedBlock(e0) = X - 3, epochSeedBlock(e1) = X + 5 (epoch e1 is still open). A settle([x1]) is mined in block X + 1. readyReveals: getBlockNumber -> X + 1, nextRevealIndex answered at X -> 0, revealCommit answered at X + 1 -> x1. Loop: i = 0, e0, seed block X - 3 + 2 <= X + 1, not opened -> out.push(chain.next(x1)) = x2; i = 1, e1, X + 5 + 2 > X + 1 -> break. Response: {reveals: [x2]} where x2 is e1's secret, published 6 blocks before e1's seed block exists. Expected: an empty list (e0 is already sealed, e1 is not ready). Fix: read `latest` first and pass `{blockTag: latest}` to every subsequent call (ethers Contract methods accept an overrides object), so all values describe one state; additionally re-read revealCommit and nextRevealIndex after the loop and return an empty list (or retry) if either changed, and assert that the number of secrets handed out never exceeds the number of epochs whose seed block is CONFIRMATIONS deep as counted from nextRevealIndex at the same block.","severity":"low","snippet":" const [latest,commit,count,next]=await Promise.all([provider.getBlockNumber(),c.revealCommit(),c.randomEpochCount(),c.nextRevealIndex()]);","title":"readyReveals reads revealCommit and nextRevealIndex at different heads; a settle mined in between shifts the loop by one and can hand out the secret of an epoch whose seed block is not mined yet"},{"citation":"resolved","description":"Section 8.2 says the re-armed-block grind 'needs 8,191 blocks during which nobody settled' and that the keeper turns it into an alert-backed assumption. Nobody can settle an epoch whose secret the signer has not published, so the signer alone forces that expiry by withholding for 8,191 blocks; the keeper only alerts. The signer then chooses the block T in which settle([s_e]) is mined, which fixes the re-arm block T + 1, and since PBS any builder, the signer included, can win T + 1 and grind its hash with s_e and every later closed epoch's secret (all known to the signer) to choose the outcomes. The 'withholding' bullet does admit this in a parenthesis ('unless it also builds that next block'), but its summary 'never a chosen one' and the keeper bullet point the other way. This is strictly easier than grinding the natural seed block: there the signer must build the one block natural(e), here it picks T so that any block it is about to build follows. The code behaves as documented; only the trust statement is misleading. Suggested wording for 7 and 8.2: the keeper protects against expiry when a secret is public; against a withholding signer the only guarantees are the 27-hour visible delay, the chosen-mint freeze, and that the signer's lever is bounded to the closed backlog; a signer that is or hires a builder for one block of its choosing gets a chosen draw for that backlog. If that is not acceptable for the 48 gold skins, a VRF (already listed) is the remedy.","line":176,"path":"Submission3/AUDIT_README.md","reproduction":"Epoch e with a gold-eligible random token; seed block natural(e) mined at block N. Signer alone: do not publish s_e. Keeper alerts at about N + 300; nothing it can do (settle([s_e]) is impossible without s_e; chosen mints revert with REVEAL_PENDING). At block T >= N + 8192 the signer (a builder, or paying one) includes settle([s_e]) in T: the history contract has no hash for N, EpochRearmed(e, T + 1, true). It builds T + 1, iterating extra data / transaction order until keccak256(abi.encode(blockhash(T+1), s_e, e, contract)) and the per-token draws give the wanted skins, then anyone (or it) settles at T + 2. Expected per README 8.2 summary: 'one fresh draw ... never a chosen one'. Actual: a chosen outcome for e and for every later epoch that closed before T, at the cost of one 27-hour delay. No code change is required for this item; it is a documentation and trust-model correction, with VRF as the design alternative the README already names.","severity":"info","snippet":"   - **Builder of a re-armed block.** After an expiry, the epoch's secret is public before the re-armed block is built, and so are any secrets already handed out for later ready epochs. Whoever builds that block could grind it (audit 388b71cb, finding 6). This needs 8,191 blocks during which nobody settled. The keeper (section 7) makes it an alert-backed assumption rather than a design property.","title":"README 7 / 8.2: the keeper cannot prevent a signer-forced expiry, so the 'builder of a re-armed block' case is a signer-alone lever, not a liveness accident"}],"hash":"08613f1a428e76e5040aa402d20e1cb51545d8a2dafb1d0b871aba7bea75cfb8","nodeId":"07f5f882-c836-474b-9b23-b0e0eea12fe8","outcome":"completed","summary":"Review complete. The findings file holds 3 findings (2 low, 1 info); no critical, high or medium defect was substantiated in the primary contract.\n\n**What I verified on the contract** (Submission3/contracts/EmberEvoGenesisCandidate.sol), each with a Foundry probe run against the real code under solc 0.8.38 and a mock EIP-2935 history contract:\n\n- **Expiry keeps the public secret** and re-arms to block+1. A second expiry re-arms again with the same secret, settles with `settle([])`, and no epoch is ever sealed with a zero hash.\n- **Cooldown** is measured from the current seed block including a pending re-arm. A replacement is refused at r+7200 and accepted at r+7201.\n- **Chosen-skin freeze** holds from the epoch's natural seed block through the re-arm block.\n- **MAX_COMMIT_REARMS** is shared across a backlog as L-1 intends, and the third replacement is refused.\n- **Acceptance bound to previous**: a spare acceptance is refused after another replacement. The held-value walk refuses keccak^32 of the anchor and accepts keccak^33.\n- **Packed storage**: I checked every shift and mask in `revealState`, `randomEpochWords`, `epochTokens` and `tokenBits`. Field widths fit their bounds, the count bits cannot reach 3, stale re-arm fields can never exceed a future epoch's natural seed block, and the expired flag only ever applies to the pending from-epoch. I found no way for a shift, mask or stale read to break reveal order, the re-arm bound or a draw.\n- **Steering**: a minter, the owner alone and a builder of a natural seed block stay within what README 8.2 states.\n\n**Findings written**\n\n1. **Low, with proof.** The L-1 inheritance in `_commitRearms` compares against the *current* re-arm block, which expiry re-arms also rewrite. An epoch that closed after the last replacement but before an expiry re-arm inherits a count it never earned, so its own two replacements shrink to one or zero. Liveness only, since the bound errs strict. The attached test fails on this code with `EPOCH_REARM_LIMIT`. Fix: track the last commitment re-arm block separately.\n2. **Low, signer.** `readyReveals` reads `nextRevealIndex` and `revealCommit` without a pinned block. A settle mined between the two answers shifts the loop by one and can publish the secret of an epoch whose seed block is not yet mined. Fix: pass one `blockTag` to every read and re-check.\n3. **Info, README.** The keeper cannot prevent a signer-forced expiry. A withholding signer picks the publication block and so picks which builder gets the re-armed block, which is a chosen draw for the closed backlog, not the \"never a chosen one\" the summary states. Documentation correction only.\n\n**Limits.** The project's Hardhat suite was not run. The signer race is argued from the code and the ethers batching behaviour, not reproduced live. Only test/scratch and the findings file were written.","treeHash":null,"usage":{"cachedInputTokens":1524318,"inputTokens":482,"model":"claude-fable-5-1","outputTokens":62226,"runtime":"claude","turns":21,"wallClockMs":959954}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"99e4819277ea59fc","findings":[{"citation":"resolved","description":"The L-1 fix makes an epoch inherit the stored re-arm count whenever it is at or after the last re-armed epoch and the stored re-arm block is later than its own natural seed block. That test does not distinguish a commitment re-arm (which re-draws the epoch) from an expiry re-arm (which only moves the epoch behind a public secret). An expiry re-arm of epoch e0 carries e0's commitment count c into revealState together with a new, later block B3. Every later epoch whose natural seed block lies between the last commitment re-arm block and B3 was never re-drawn by any commitment, yet _commitRearms now returns c for it. With c = MAX_COMMIT_REARMS (2) such an epoch can never be replaced: setRevealCommit reverts with EPOCH_REARM_LIMIT. If the chain that covers it is lost or withheld, that epoch and everything behind it can never be sealed, chosen-skin mints revert with REVEAL_PENDING for good, and the owner has no way out, contrary to README section 5 (Re-arm: 'Two keep a way out if a chain is lost') and to the stated bound of 2 commitment re-arms per epoch, which the code over-approximates. The over-count cannot be used to steer a draw (it only removes owner+signer options), but it turns a liveness safeguard into a permanent stall. Fix: keep the block of the last commitment re-arm separately from the seed block an expiry re-arm sets (both fit in the free bits of revealState), set it only in the setRevealCommit branch, and have _commitRearms compare _naturalSeedBlock(e) against that commitment re-arm block instead of the current re-arm block. Expiry re-arms keep carrying the count for the epochs that were actually re-drawn.","line":324,"path":"Submission3/contracts/EmberEvoGenesisCandidate.sol","reproduction":"State: one random token in epoch e0 (natural seed block N0). Owner pauses, replaces the commitment at N0+7201 (re-arm 1, B1 = N0+7202) and again at B1+7201 (re-arm 2, B2). Unpause, mint one random token at B2+1 in epoch e1, whose natural seed block N1 > B2, so no commitment ever re-drew e1. The signer withholds the third chain's first secret z1 until B2 leaves the hash window; settle([z1]) then re-arms e0 by expiry to B3 = block+1 with count 2 carried, and epochSeedBlock(e1) == B3 (e1 is moved behind the public secret, not re-drawn). At B3+1 settle([]) seals e0 with hash(B3); e1 is now next. Expected: _commitRearms(e1) == 0, so the owner and signer can still replace the chain for e1 up to two times. Actual: _commitRearms(e1) == 2 and setRevealCommit(w0, deadline, acceptance) at B3+7201 reverts with EPOCH_REARM_LIMIT. Foundry scratch test test_inherited_count_blocks_never_redrawn_epoch (local chain, so the hash window is 256 blocks and the expiry needs vm.roll(n1 + 300)) observes exactly this revert on the current code.","severity":"low","snippet":"        return (e >= uint64(rs >> 128) && uint64(rs >> 64) > _naturalSeedBlock(e)) ? (rs >> 193) & 3 : 0;","title":"_commitRearms inherits the commitment re-arm count into epochs an expiry re-arm moved but no commitment ever re-drew, leaving them with no replacement left"},{"citation":"resolved","description":"The L-2 fix binds an acceptance to the commitment it replaces by including revealCommit in the signed struct. revealCommit only changes when a secret is opened or the chain is replaced, so an acceptance the signer produced for the current chain (for example a standby replacement prepared at setup, or one signed for a replacement the owner then did not send) remains valid for as long as no epoch of that chain has been sealed. README 8.2 ('Owner alone') states the opposite: 'an unused acceptance signed earlier names a commitment that is no longer current'. In the window before the first reveal of a chain the owner alone can therefore replace the chain and, if the next random epoch has closed and REARM_COOLDOWN has passed, re-arm it without any signer involvement at that time. The outcome is still a fresh draw under a future block hash (the owner cannot choose it unless it also builds that block), and the count bound applies, so this is a trust-model inaccuracy rather than a steering lever, but it weakens the 'owner cannot re-arm anything alone' claim that MAX_COMMIT_REARMS and the pause are documented to rest on, and the signer cannot revoke an acceptance it has handed over. Fix (keeps the design): bound the acceptance lifetime in the contract, e.g. require(deadline <= block.timestamp + MAX_ACCEPTANCE_TTL) with a short TTL (an hour), and/or include a per-setRevealCommit nonce that the signer reads just before signing so that an acceptance is only valid for the very next replacement; document in 8.2 that an acceptance for the current chain is live until used, opened past, or expired.","line":279,"path":"Submission3/contracts/EmberEvoGenesisCandidate.sol","reproduction":"State: deployed, commitment x0 set with the signer's acceptance, minting open. The signer signs a second acceptance for (commit = y0, previous = x0, signerEpoch = 1, deadline = now + 30 days) and hands it to the owner; it is never used. A random token is minted in epoch e0 (natural seed block N0); no secret of chain x is ever opened on chain. At block N0+7201 the owner, alone, calls setMintPaused(true) then setRevealCommit(y0, deadline, thatAcceptance). Expected per README 8.2: refused, the acceptance should no longer name a current commitment. Actual: it is accepted, revealCommit becomes y0 and epochSeedBlock(e0) becomes N0+7202 (EpochRearmed emitted, expired = false). Foundry scratch test test_spare_acceptance_rearms_without_signer observes this on the current code.","severity":"low","snippet":"        bytes32 digest = _hashTypedDataV4(keccak256(abi.encode(REVEAL_COMMIT_TYPEHASH, commit, revealCommit, signerEpoch, deadline)));","title":"A RevealCommitAcceptance signed earlier stays usable by the owner alone until the first secret of the current chain is opened, including after the next random epoch has closed"},{"citation":"resolved","description":"The code is consistent with its own comments: an expired seed block keeps the public secret and re-arms to block+1, with no limit on how often that can repeat (the count bits only limit commitment re-arms). README 8.2 'Owner and signer together' then states a hard per-epoch total ('at most: the original draw, one after a withheld secret, and two replacements, each of which can again be withheld once'). That total only holds if some party calls settle within 8,191 blocks of each re-armed block; otherwise the next call re-arms again and every such re-arm is one more fresh draw, and its block is chosen by whoever sends that call (block+1). The README does say this under 'Liveness', but the bound statement and the 'Builder of a re-armed block' paragraph ('This needs 8,191 blocks during which nobody settled. The keeper makes it an alert-backed assumption') present the keeper as the control. The keeper can only settle an epoch whose secret it has: while the signer withholds, the keeper cannot act, so the first re-arm of a withheld epoch, and the block whose builder knows the secret, is reached regardless of the keeper. The keeper assumption should be stated as covering only epochs whose secret has been published (and the repeated-expiry case), with the signer-withholding path listed as unprotected by it. Suggested doc fix in 8.2: replace the fixed total by 'bounded only while someone settles each re-armed block within the hash window', and in section 7 state that the keeper alert fires on a ready epoch without a published secret as well (signer outage), since that is the path that leads to a builder-grindable re-arm.","line":588,"path":"Submission3/contracts/EmberEvoGenesisCandidate.sol","reproduction":"State: epoch e0 closed, its chain replaced twice (count 2) so EPOCH_REARM_LIMIT is reached, then the signer withholds the third secret until the re-armed block expires and settle([z1]) re-arms e0 by expiry (EpochRearmed expired = true). Nobody calls settle for a further 8,191 blocks. Expected per README 8.2: no further draw for e0. Actual: the next settle([]) re-arms e0 again (EpochRearmed, a new seed block = block+1, secret unchanged), i.e. another fresh draw; this repeats for every further window without a call. Foundry scratch test test_commit_rearm_bound_and_cooldown observes an expiry re-arm followed by a second expiry re-arm after EPOCH_REARM_LIMIT, then a seal at the following block, on the current code (local chain: 256-block window).","severity":"info","snippet":"            if (h == bytes32(0)) { _rearm(e, true, _commitRearms(e)); return progressed; }","title":"README 8.2 bounds the draws per epoch at six, but expiry re-arms are unbounded and the keeper cannot shorten them while a secret is withheld"}],"hash":"6e08a2a29687f03d282c8b9320671f790eeda6879d3c21cfa386477898ce9498","nodeId":"e0e69d9b-6668-43a6-9c6e-9c3663046deb","outcome":"completed","summary":"Review complete. The findings file is written and validated against the source tree. Nothing else in the repository was touched. The scratch Foundry project lives outside the repository.\n\n**Outcome.** The Submission 3 fixes hold as described. No critical or high defect was found. Three substantiated items are in `.imd-findings.json`, two low and one informational.\n\n**What was checked and found correct** (each reproduced in a scratch Foundry harness against the real contract with OpenZeppelin 5.4.0 and solc 0.8.38):\n- An expired seed block keeps its public secret, re-arms to the next block, and is later sealed with that block's hash by a call carrying no secret. No zero block hash path exists.\n- The cooldown is measured from the pending re-armed block. A replacement in the re-arm block is refused.\n- The chosen-skin freeze holds in the re-armed block itself and is lifted only by the reveal.\n- Two commitment re-arms per epoch are enforced, and an expiry re-arm in between carries the count.\n- The packed `revealState` and `randomEpochWords` fields: every shift and mask was traced. Field widths, the `&` before `==` precedence in `epochSecretOpened`, the low-64-bit preservation in `_rearm`, and the per-iteration storage re-reads in `_settleReady` are all sound. The weighted draw always has capacity for every pending random token.\n- The 32-hash walk covers keccak^32 of every anchor, current and retired.\n- The signer's `readyReveals` is consistent with `epochSecretOpened` and hands out nothing behind an epoch near expiry.\n\n**Findings.**\n- **Low, line 324.** The L-1 inheritance rule assigns the commitment re-arm count to any epoch an expiry re-arm moves, even one no commitment ever re-drew. With the count at 2, such an epoch can never be replaced. A lost or withheld chain then stalls reveals and chosen mints permanently, which contradicts the README's \"two keep a way out\". Reproduced: `EPOCH_REARM_LIMIT` on an epoch minted after both replacements.\n- **Low, line 279.** The L-2 binding includes `revealCommit`, which does not change until a secret is opened. An acceptance the signer handed over earlier stays usable by the owner alone until then, including to re-arm a closed epoch after the cooldown. The README 8.2 \"owner alone\" claim is inaccurate in that window. Reproduced. Suggested fix is a contract-enforced short acceptance deadline or a per-replacement nonce.\n- **Info, line 588.** The per-epoch draw total in README 8.2 holds only while someone settles each re-armed block inside the hash window. Expiry re-arms are unbounded, and the keeper cannot act while the signer withholds a secret, so the keeper assumption should be stated as covering published-secret liveness only.\n\n**Not covered in depth.** The GOLD book settlement code and the mint web page were outside the assigned area and were not reviewed. The project's own Hardhat test suite was not run.","treeHash":null,"usage":{"cachedInputTokens":2041368,"inputTokens":514,"model":"claude-fable-5-1","outputTokens":69017,"runtime":"claude","turns":43,"wallClockMs":1067170}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"b12a68c6f8a8ce99","findings":[{"citation":"resolved","description":"In _settleReady, an expired seed block calls _rearm(e, true, ...). That writes one record (seed block r = block.number+1, first epoch e), and epochSeedBlock (line 420) applies it to every later epoch whose natural seed block is earlier than r. Those later epochs were already closed and keep their own unpublished secrets, so the signer can compute each of their outcomes under r once r is mined. The signer can then withhold the next epoch's secret until r expires, which re-arms that epoch, and every epoch behind it, a second time. Random mints stay open while a reveal is pending, so a backlog builds by itself during the 27 h of each withholding. Result: the k-th epoch of a backlog is drawn under its natural hash plus one block hash per earlier expiry, and the signer sees each draw before it decides whether to let it stand. This is the same compounding that L-1 removed for commitment re-arms (_commitRearms), but the signer-alone expiry path still has it. README 8.2 ('Signer alone: withholding') says withholding buys 'one fresh draw per epoch' and 'two unpredictable families' for later epochs. In fact the natural family is already known to the signer, and the number of options grows with the epoch's position in the backlog. Each step costs about 27 h and keeps chosen-skin mints frozen, which is visible, so the impact is limited to the trust model. Fix that keeps the design: when the re-arm comes from an expiry (expired flag), apply its seed block only to epoch e, i.e. in epochSeedBlock/_commitRearms use `e == first` instead of `e >= first` when bit 192 is set. Later epochs then keep their natural (past, fixed) seed block. If that block has expired too, each later epoch goes through its own expiry, so the bound really is one fresh draw per epoch, and a later secret that is already out no longer lets the builder of r grind that later epoch (this also narrows audit finding 6). Otherwise, restate 8.2 with the k+1 bound.","line":588,"path":"Submission3/contracts/EmberEvoGenesisCandidate.sol","reproduction":"Foundry, local chain (no EIP-2935 code, so the window is 256 blocks), startBlock S=1000, signer holds x2 -> x1=keccak(x2) -> x0=keccak(x1), commit x0. Random mintFree at S+1 (epoch 0, token 1) and at S+26 (epoch 1, token 2). epochSeedBlock(1) = 1050 (natural; mined, so the signer knows epoch 1's draw). The signer withholds x1; at block 1325 settle([x1]) expires epoch 0: EpochRearmed(0,1326,true) and epochSeedBlock(1) = 1326. At 1327 settle([]) seals epoch 0; epoch 1 waits for x2. The signer computes epoch 1 under hash(1326), withholds x2; at 1626 settle([x2]) gives EpochRearmed(1,1627,true) and epochSeedBlock(1) = 1627; settle([]) at 1628 reveals token 2. Expected (README 8.2): one fresh draw for epoch 1 beyond its original. Actual: epoch 1 was drawn under hashes of blocks 1050, 1326 and 1627, and the signer saw each before choosing. With a third epoch in the backlog it would be four. Verified with a local Foundry test (all asserts pass: natural 1050, r1 1326, r2 1627).","severity":"low","snippet":"            if (h == bytes32(0)) { _rearm(e, true, _commitRearms(e)); return progressed; }","title":"Expiry re-arm moves every later closed epoch: by withholding alone the signer gets k+1 draws for the k-th epoch of a backlog (README 8.2 says one fresh draw per epoch)"},{"citation":"resolved","description":"readyReveals walks the chain from `commit` (open=commit; s=chain.next(open)) and starts at epoch index `next`. The four reads are issued at once, and the per-epoch reads after them use 'latest' with no blockTag, so they can be answered at different heights (load-balanced RPC, or a block arriving between them). If `commit` is read after epoch e was revealed (commit = x[k+1]) but `next` before (next still points at e), the loop checks e's seed block (mined, passes) and pushes chain.next(x[k+1]) = x[k+2], which is the secret of the following epoch e2. It then breaks at e2 only because e2 is not ready. So e2's secret is published on the unauthenticated GET /v1/reveals before e2's seed block is built, and whoever builds that block can grind its hash for a chosen e2 outcome. README 8.2 lists that power only for the signer. The secrets are meant to be released only CONFIRMATIONS blocks after the seed block. Fix: read getBlockNumber first and pass {blockTag: latest} to every contract call (revealCommit, randomEpochCount, nextRevealIndex, randomEpochAt, epochSeedBlock, epochSecretOpened), so the walk and the index come from the same state. Optionally re-check that keccak(first secret) equals revealCommit at that block.","line":57,"path":"Submission3/offchain/signer/reveal-chain.mjs","reproduction":"State: epochs e (index i, seed block mined and 2 deep) and e2 (index i+1, natural seed block not mined yet), revealCommit = x[k]. A settle revealing e lands in block B. The provider answers revealCommit() at B (x[k+1]) and nextRevealIndex() at B-1 (i). The loop then takes e = randomEpochAt(i), finds seedBlock(e)+2 <= latest and epochSecretOpened(e) false, and pushes chain.next(x[k+1]) = x[k+2]; at e2 it breaks. Expected: the response holds no secret for e2 until e2's seed block is 2 deep. Actual: {reveals:[x[k+2]]}, which is e2's secret, published before e2's seed block exists. Not run: the vendored ethers bundle (../vendor/ethers.min.js) is not in the package; the trace is from the code at lines 57-71.","severity":"low","snippet":" const [latest,commit,count,next]=await Promise.all([provider.getBlockNumber(),c.revealCommit(),c.randomEpochCount(),c.nextRevealIndex()]);","title":"readyReveals reads revealCommit and nextRevealIndex without a pinned block: a reveal mined between the reads hands out the next epoch's secret before its seed block exists"},{"citation":"resolved","description":"The only check on a rotation is that the proposed key signs its own acceptance. Nothing stops the owner from proposing an address it controls. AUDIT_README.md line 172 ('Owner alone ... cannot ... rotate the signer (needs the new key's acceptance), so it cannot re-arm anything') is therefore wrong as written. The next bullet concedes the point in parentheses, so the package contradicts itself. After such a rotation the owner alone has the full 'owner and signer together' power: it can accept a chain it generated, and re-arm the next closed epoch up to MAX_COMMIT_REARMS times after REARM_COOLDOWN. It then knows that chain's secrets, so it learns each re-armed outcome as soon as the re-arm block is mined and can withhold it, as in the signer bounds. This is a trust assumption, not a bypass. Fix: state in 8.2 that the owner alone can become owner+signer after a paused, visible rotation (AppearanceSignerChanged). If that should be impossible, add a delay between proposal and activation, or require the outgoing signer's co-signature.","line":345,"path":"Submission3/contracts/EmberEvoGenesisCandidate.sol","reproduction":"Owner EOA O also holds key K. With minting paused: proposeAppearanceSigner(K); activateAppearanceSigner(deadline, sign_K(SignerAcceptance(K, signerEpoch+1, nonce, deadline))) gives AppearanceSignerChanged(old, K, epoch+1). Then, more than REARM_COOLDOWN blocks after the next closed epoch's seed block: setRevealCommit(c, deadline, sign_K(RevealCommitAcceptance(c, revealCommit, signerEpoch, deadline))), with c the anchor of a chain O generated, gives EpochRearmed(e, block+1, false). Expected per README 8.2 'Owner alone': impossible. Actual: succeeds with no participation from the previous signer.","severity":"info","snippet":"        require(SignatureChecker.isValidSignatureNow(next, digest, acceptance), \"SIGNER_ACCEPTANCE_INVALID\");","title":"README 8.2 'Owner alone' says the owner cannot rotate the signer, but proposeAppearanceSigner/activateAppearanceSigner accept a key the owner holds"}],"hash":"d1fdd956245cde1a2fb32458935cf4f90e598467b48ef2e6fd6f2637475f77b0","nodeId":"3595cd92-ab8d-4950-8fe8-907b753e2b42","outcome":"completed","summary":"I found three issues and wrote them to `.imd-findings.json`: two low and one info. None is critical, high or medium. No repository files were changed.\n\n**Findings**\n\n1. **Low: the signer alone can get more re-draws than README 8.2 says** (`Submission3/contracts/EmberEvoGenesisCandidate.sol:588`, which feeds `epochSeedBlock` at line 420).\n   - When a seed block expires, the re-arm moves every later epoch that has already closed, not just the expired one. Those epochs keep their own secrets, so the signer can see each new draw and withhold again.\n   - Random mints stay open while a reveal is pending, so a queue of waiting epochs builds up during each 27-hour withholding. The k-th epoch in that queue gets up to k+1 draws, and the signer sees each one before deciding. README 8.2 says one fresh draw per epoch.\n   - This is the same compounding that L-1 fixed for replaced commitments, but on the expiry path.\n   - **I reproduced it with a local Foundry test** (built from `build/standard-input.json`, in a scratch folder outside the repository). Epoch 1 was drawn under the hashes of blocks 1050, 1326 and 1627, and every assertion passed.\n   - **Fix:** when the re-arm comes from an expiry, apply it only to the expired epoch (`e == first` instead of `e >= first` when the expired flag is set). That gives a true bound of one fresh draw per epoch, and also narrows the second audit's finding 6.\n\n2. **Low: `readyReveals` can hand out the next epoch's secret too early** (`Submission3/offchain/signer/reveal-chain.mjs:57`).\n   - It reads `revealCommit` and `nextRevealIndex` without pinning them to one block.\n   - If a reveal is mined between those two reads, the walk is off by one. It then publishes the following epoch's secret before that epoch's seed block exists, so whoever builds that block could grind it.\n   - **Fix:** pass the same `blockTag` to every read.\n   - I traced this through the code only and did not run it: the ethers bundle the signer imports is not in the package.\n\n3. **Info: README 8.2 says the owner alone cannot rotate the signer, but it can.**\n   - The new key only has to sign its own acceptance, and the owner can propose a key it holds.\n   - It then has the full \"owner and signer together\" power. The next bullet in the README already admits this in parentheses, so the README contradicts itself.\n\n**What checked out**\n- **Expiry:** the secret is kept, the epoch re-arms to the next block, and a seal never uses a zero block hash.\n- **Cooldown:** it is measured from the current seed block, including a pending re-arm.\n- **Chosen-skin freeze:** it counts from the epoch's own seed block.\n- **Replacement limit:** the cap of 2 is shared across waiting epochs (L-1), with no way I found to undercount it.\n- **L-2:** an acceptance is bound to the commitment it replaces.\n- **Commitment check:** the walk covers 32 hashes above each anchor.\n- **Packed storage:** every shift and mask in `revealState`, `randomEpochWords`, `epochTokens` and `tokenBits` is correct, and I found no stale read that changes the reveal order, the re-arm bound or a draw.\n- **Minters and the owner alone:** I found no way for either to steer a skin beyond the expected-value shift the README already documents (8.9).\n- **Keeper:** the README describes the keeper assumption correctly.\n\n**Coverage:** I did the Execution Trace, Periphery and First Principles passes myself; no helpers were used. I did not review the gold-book code, the other signer files, or the remaining nine specialist passes.","treeHash":null,"usage":{"cachedInputTokens":949044,"inputTokens":26,"model":"claude-opus-5-5","outputTokens":43712,"runtime":"claude","turns":14,"wallClockMs":503827}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"be306690d4a9deec","findings":[{"citation":"resolved","description":"revealState keeps one (seed block r, from-epoch, count) triple for the LAST re-arm of any kind, and _commitRearms(e) returns that count for every unsealed e >= from whose natural seed block is below r. Two things make r a wrong yardstick for 'commitment re-arms that re-drew e'. (a) An expiry re-arm (_settleReady line 588) rewrites r to block.number+1 while carrying the count over, so every epoch that closed between the last commitment re-arm and that expiry inherits a count it never earned (it was moved behind a public secret, not re-drawn by a new chain). (b) Even with commitment re-arms only, the count is cumulative across from-epochs: after e0 was replaced once (R1, count 1) and sealed, a replacement for e1 writes (R2, from e1, count 2); an epoch e2 with R1 < natural(e2) < R2 was moved by R2 only but reads 2. In both cases the owner+signer lose one or both of the two replacements the README (section 5 'Re-arm', 8.2 'Owner and signer together', contract comment lines 64-65 'Two keep a way out if a chain is lost') promise per epoch. With a carried count of 2 and a chain that is lost or withheld, the epoch can never be replaced, every later random reveal stalls, and chosen-skin mints revert with REVEAL_PENDING from its natural seed block for good; the owner has no way out. The over-count never grants extra re-rolls, so randomness is not affected: this is a liveness / documented-bound defect. Fix (keeps the design): count commitment re-arms by their blocks, not by one carried number. Shrink the from-epoch field to 32 bits (epochs are stored as uint32 in randomEpochWords anyway) and keep the blocks of the last two commitment re-arms in the freed bits (e.g. bits 160-199 and 200-239, 40 bits each; expired flag at bit 240), written only in setRevealCommit's branch; drop the count bits. Then _commitRearms(e) = (Ra > natural(e) ? 1 : 0) + (Rb > natural(e) ? 1 : 0): only the last two matter because the check is n < 2. Expiry re-arms keep writing r / from / flag and no longer touch the count. The L-1 behaviour (an epoch already closed at a replacement shares it) is preserved because its natural block is below that replacement's block. Merged from audit_economics, audit_math and audit_permissions (same root cause, same line); instance (b) is this review's addition.","line":324,"path":"Submission3/contracts/EmberEvoGenesisCandidate.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.38;\n\nimport \"forge-std/Test.sol\";\nimport {EmberEvoGenesisCandidate} from \"Submission3/contracts/EmberEvoGenesisCandidate.sol\";\n\ncontract MockEmbeo {\n    string public name = \"T\"; string public symbol = \"T\"; uint8 public decimals = 18;\n    uint256 public totalSupply;\n    mapping(address => uint256) public balanceOf;\n    mapping(address => mapping(address => uint256)) public allowance;\n    function mint(address to, uint256 a) external { balanceOf[to] += a; totalSupply += a; }\n    function approve(address s, uint256 a) external returns (bool) { allowance[msg.sender][s] = a; return true; }\n    function transfer(address to, uint256 a) external returns (bool) { balanceOf[msg.sender] -= a; balanceOf[to] += a; return true; }\n    function transferFrom(address f, address to, uint256 a) external returns (bool) {\n        allowance[f][msg.sender] -= a; balanceOf[f] -= a; balanceOf[to] += a; return true;\n    }\n}\n\n/// _commitRearms makes an epoch that was only moved by an EXPIRY re-arm inherit the commitment re-arm count of the\n/// epoch before it, so a replacement README 5 / 8.2 allow (0 commitment re-arms for this epoch) is refused with\n/// EPOCH_REARM_LIMIT. Fails on the current code.\ncontract RearmCountInheritTest is Test {\n    EmberEvoGenesisCandidate g;\n    MockEmbeo tok;\n    uint256 constant SIGNER_PK = 1;\n    address signer;\n    address constant DEAD = 0x000000000000000000000000000000000000dEaD;\n    bytes32 constant APPEARANCE_TYPEHASH = keccak256(\"Appearance(address wallet,bytes32 lookKey,uint8 choice,bytes32 uriHash,uint256 deadline,uint256 signerEpoch,bool paid)\");\n    bytes32 constant PAID_AUTH_TYPEHASH = keccak256(\"PaidAuthorization(bytes32 orderId,address wallet,bytes32 lookKey,uint8 choice,bytes32 uriHash,address recipient,uint256 embeoCost,uint256 goldCost,uint256 deadline,uint256 signerEpoch)\");\n    bytes32 constant REVEAL_COMMIT_TYPEHASH = keccak256(\"RevealCommitAcceptance(bytes32 commit,bytes32 previous,uint256 signerEpoch,uint256 deadline)\");\n\n    function setUp() public {\n        signer = vm.addr(SIGNER_PK);\n        vm.roll(1000);\n        vm.warp(1_700_000_000);\n        tok = new MockEmbeo();\n        g = new EmberEvoGenesisCandidate(address(this), address(tok), signer, 1e18, 1, \"EEG\");\n        g.configureSnapshot(bytes32(0), 0, block.timestamp, block.timestamp);\n        g.setRevealURIs(\"pending\", \"base/\", true);\n        g.approvePaidPrice();\n    }\n\n    function domain() internal view returns (bytes32) {\n        return keccak256(abi.encode(keccak256(\"EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)\"),\n            keccak256(\"EmberEvoGenesisCandidate\"), keccak256(\"7\"), block.chainid, address(g)));\n    }\n    function sign(bytes32 structHash) internal view returns (bytes memory) {\n        bytes32 d = keccak256(abi.encodePacked(\"\\x19\\x01\", domain(), structHash));\n        (uint8 v, bytes32 r, bytes32 s) = vm.sign(SIGNER_PK, d);\n        return abi.encodePacked(r, s, v);\n    }\n    function chain(bytes32 seed, uint256 n) internal pure returns (bytes32[] memory x) {\n        x = new bytes32[](n + 1); x[n] = seed;\n        for (uint256 i = n; i > 0; --i) x[i - 1] = keccak256(abi.encode(x[i]));\n    }\n    function setCommit(bytes32 anchor) internal {\n        bytes memory acc = sign(keccak256(abi.encode(REVEAL_COMMIT_TYPEHASH, anchor, g.revealCommit(), uint256(g.signerEpoch()), block.timestamp + 1)));\n        g.setRevealCommit(anchor, block.timestamp + 1, acc);\n    }\n    function mintRandom(address wallet, bytes32 orderId) internal {\n        tok.mint(wallet, 1e18);\n        vm.prank(wallet); tok.approve(address(g), 1e18);\n        bytes32 look = keccak256(abi.encode(wallet, orderId));\n        uint256 dl = block.timestamp + 1;\n        bytes memory app = sign(keccak256(abi.encode(APPEARANCE_TYPEHASH, wallet, look, uint8(3), keccak256(\"\"), dl, uint256(g.signerEpoch()), true)));\n        EmberEvoGenesisCandidate.PaidAuthorization memory o = EmberEvoGenesisCandidate.PaidAuthorization(orderId, wallet, look, 3, keccak256(\"\"), DEAD, 1e18, 1, dl, g.signerEpoch());\n        bytes memory gold = sign(keccak256(abi.encode(PAID_AUTH_TYPEHASH, o)));\n        bytes32[] memory none;\n        vm.prank(wallet);\n        g.mintPaid(look, 3, \"\", dl, app, DEAD, 1e18, o, gold, none);\n    }\n\n    function test_epochMovedOnlyByExpiryInheritsCommitRearmCount() public {\n        bytes32[] memory c1 = chain(keccak256(\"seed1\"), 8);\n        bytes32[] memory c2 = chain(keccak256(\"seed2\"), 8);\n        bytes32[] memory c3 = chain(keccak256(\"seed3\"), 8);\n        bytes32[] memory c4 = chain(keccak256(\"seed4\"), 8);\n        setCommit(c1[0]);\n        g.setMintPaused(false);\n\n        // epoch 0 (blocks 1000..1024): one random token; own seed block 1025\n        vm.roll(1010); mintRandom(address(0xA1), bytes32(uint256(1)));\n        assertEq(g.epochSeedBlock(0), 1025);\n\n        // nobody settles; owner + signer replace the chain twice: epoch 0 count = 2 = MAX_COMMIT_REARMS\n        vm.roll(1025 + 7201); g.setMintPaused(true); setCommit(c2[0]);   // re-arm #1 -> 8227\n        assertEq(g.epochSeedBlock(0), 8227);\n        vm.roll(8227 + 7201); setCommit(c3[0]);                          // re-arm #2 -> 15429\n        assertEq(g.epochSeedBlock(0), 15429);\n        g.setMintPaused(false);\n\n        // a NEW epoch closes after re-arm #2: epoch 577 (15425..15449), own seed block 15450; never moved by a commitment\n        vm.roll(15439); mintRandom(address(0xA2), bytes32(uint256(2)));\n        assertEq(g.randomEpochAt(1), 577);\n        assertEq(g.epochSeedBlock(577), 15450);\n\n        // seed block 15429 expires (no EIP-2935 here: 256-block window). c3[1] is supplied and kept; epoch 0 re-arms\n        // to the next block, count 2 carried over; epoch 577 is moved by this EXPIRY re-arm only\n        vm.roll(15429 + 257);\n        bytes32[] memory r = new bytes32[](1); r[0] = c3[1];\n        g.settle(r);\n        assertTrue(g.epochSecretOpened(0));\n        assertEq(g.epochSeedBlock(0), 15687);\n        assertEq(g.epochSeedBlock(577), 15687);\n\n        // epoch 0 settles with no secret; 577 is next and would need c3[2]\n        vm.roll(15688);\n        bytes32[] memory none;\n        g.settle(none);\n        assertEq(g.nextRevealIndex(), 1);\n        (bool exists, uint256 e, ) = g.nextRevealEpoch();\n        assertTrue(exists); assertEq(e, 577);\n\n        // Chain lost: README 5 / 8.2 allow a replacement to re-arm epoch 577 (0 commitment re-arms so far) once\n        // REARM_COOLDOWN has passed after its current seed block 15687.\n        vm.roll(15687 + 7201);\n        g.setMintPaused(true);\n        setCommit(c4[0]);                       // actual: EPOCH_REARM_LIMIT\n        assertEq(g.epochSeedBlock(577), block.number + 1);\n    }\n}","reproduction":"Foundry, local chain (256-block hash window; identical logic under EIP-2935 with 8,191). startBlock 1000, EPOCH_BLOCKS 25, mock 18-decimal token, signer key 1. Instance (a), the attached proof: block 1010 random mint -> epoch 0 (natural 1025). Block 8226: pause, setRevealCommit(c2) -> EpochRearmed(0, 8227, false), count 1. Block 15428: setRevealCommit(c3) -> re-arm to 15429, count 2. Unpause; block 15439 random mint -> epoch 577 (natural 15450 > 15429: never moved by a commitment). Block 15686: settle([c3[1]]) -> hash(15429) out of reach -> EpochRearmed(0, 15687, true), epochSeedBlock(577) == 15687. Block 15688: settle([]) seals epoch 0; nextRevealEpoch() == 577. Block 22888: pause, setRevealCommit(c4, deadline, signer acceptance). Expected: accepted, EpochRearmed(577, 22889, false) (epoch 577 has 0 commitment re-arms). Actual: revert EPOCH_REARM_LIMIT, because _commitRearms(577) = (577 >= 0 && 15687 > 15450) ? 2 : 0. `forge test --match-path test/scratch/Proof_b6de2e61ef45.t.sol` fails with [FAIL: EPOCH_REARM_LIMIT] on this code. Instance (b), no expiry involved: random mints at 1010 (e0, natural 1025) and 1030 (e1, natural 1050); block 8226 setRevealCommit(c2) -> R1 = 8227 (moves e0 and e1, count 1); block 8228 settle([c2[1]]) seals e0; block 8240 random mint -> epoch 289 (natural 8250 > R1); block 15428 setRevealCommit(c3) -> e1 inherits 1, R2 = 15429, count 2, epochSeedBlock(289) == 15429 (moved once); block 15430 settle([c3[1]]) seals e1; block 22630 setRevealCommit(c4). Expected: allowed (epoch 289 shared one re-arm). Actual: revert EPOCH_REARM_LIMIT (_commitRearms(289) = (289 >= 1 && 15429 > 8250) ? 2 : 0).","severity":"low","snippet":"        return (e >= uint64(rs >> 128) && uint64(rs >> 64) > _naturalSeedBlock(e)) ? (rs >> 193) & 3 : 0;","title":"_commitRearms charges an epoch with commitment re-arms that never moved it (expiry re-arm block, or a re-arm of an earlier from-epoch), so a replacement README 5/8.2 allow reverts EPOCH_REARM_LIMIT"},{"citation":"resolved","description":"On expiry _settleReady calls _rearm(e, true, ...), which writes one record (r = block.number+1, from = e), and epochSeedBlock (line 420) applies r to every later unsealed epoch whose natural seed block is below r. Those epochs closed long ago, their natural block hashes are known, and the signer holds their secrets, so it already knows each of their natural draws when it decides whether to withhold e's secret; after the re-arm it learns each of their draws under hash(r) as soon as r is mined, and can withhold the next epoch's secret until r expires, moving the rest of the backlog again. Random mints stay open during the 27 h of every withhold, so a backlog grows by itself. README 8.2 ('Signer alone: withholding') says withholding buys 'one fresh draw per epoch that the signer cannot predict' and that later epochs give a choice between 'two unpredictable families'; in fact the natural family is known to the signer before it chooses, and the k-th epoch of a backlog is drawn under its natural hash plus one re-arm hash per earlier expiry plus its own, each seen before the next withhold. This is the compounding L-1 removed for commitment re-arms, left in place on the signer-only path. Each step costs about 27 h, freezes chosen mints and emits EpochRearmed, so the impact is on the trust model (what a signer that is willing to be visible can obtain), not a silent bypass. Fix that keeps the design: an expiry re-arm needs to move only the expired epoch, because the chain is unchanged and later epochs' secrets were fixed before their natural blocks; make epochSeedBlock and _commitRearms use `e == from` instead of `e >= from` when the expired flag (bit 192) is set. A later epoch whose natural block has expired too then goes through its own expiry (one settle call carrying its secret, after which the secret is public and no further withhold is possible), which gives exactly one fresh draw per epoch and also narrows the 'builder of a re-armed block' case (audit 388b71cb finding 6) to the one expired epoch. Otherwise restate 8.2 with the k+1 bound and drop 'two unpredictable families'.","line":588,"path":"Submission3/contracts/EmberEvoGenesisCandidate.sol","reproduction":"Foundry, local chain (256-block window), startBlock 1000, chain x2 -> x1 = keccak(x2) -> x0 = keccak(x1), commit x0. Random mint at block 1001 (epoch 0, natural 1025) and at 1026 (epoch 1, natural 1050). epochSeedBlock(1) == 1050, mined at 1050, so the signer (holding x2) knows epoch 1's natural draw. The signer withholds x1; at block 1325 settle([x1]) -> EpochRearmed(0, 1326, true) and epochSeedBlock(1) == 1326 (epoch 1 moved although nothing about its secret changed). Block 1327: settle([]) seals epoch 0; epoch 1 is next with seed block 1326, epochSecretOpened(1) == false, so only the signer can settle it. The signer sees epoch 1's draw under hash(1326); it withholds x2; at block 1626 settle([x2]) -> EpochRearmed(1, 1627, true), epochSeedBlock(1) == 1627; block 1628 settle([]) -> EpochSealed(1, ..., seedBlock 1627, ...). Expected per README 8.2: one fresh draw for epoch 1 beyond the one it already had. Actual: epoch 1 was drawn under blocks 1050, 1326 and 1627 and the signer saw each before deciding; with a third epoch in the backlog it would be four. Scratch test test_expiry_compounds_for_later_epochs asserts exactly these values and passes on the current code.","severity":"low","snippet":"            if (h == bytes32(0)) { _rearm(e, true, _commitRearms(e)); return progressed; }","title":"An expiry re-arm moves every later closed epoch, so a withholding signer compounds draws across a backlog (k+1 for the k-th epoch) instead of the one fresh draw per epoch README 8.2 states"},{"citation":"resolved","description":"The 'Builder of a re-armed block' bullet says the grind 'needs 8,191 blocks during which nobody settled' and that the keeper makes it 'an alert-backed assumption'. In the withholding path (the previous bullet) nobody but the signer can settle: settle needs the preimage, setRevealCommit needs the signer's acceptance and REARM_COOLDOWN. So the 8,191 idle blocks are produced by the signer alone, the keeper only alerts, and after expiry the signer chooses the block T in which settle([s]) is mined (private relay to a builder of its choice, or its own slot), which fixes the re-arm block T+1 with s public. Whoever builds T+1 grinds its hash for the epoch's outcome (and, with the signer's secrets, for every later closed epoch the re-arm moved: finding on line 588). The withholding bullet concedes this only in a parenthesis ('unless it also builds that next block') while its summary says 'never a chosen one' and the keeper bullet points the other way. Also, 'Owner and signer together' gives a hard per-epoch total ('the original draw, one after a withheld secret, and two replacements, each of which can again be withheld once'), but expiry re-arms are unbounded by the code (the count bits limit only commitment re-arms): every further 8,191-block gap without a call is one more fresh draw, whose block the caller of the next settle chooses. The code behaves as its comments say; the trust statement is what is overstated, and it is the statement on which the acceptability of 48 gold skins under commit-reveal (8.2, VRF question) is decided. Fix (documentation, 8.2 and 7): say that the keeper covers only an epoch whose secret is public (published and not used); that a withhold ends in a block the signer chooses, whose next builder it can be or hire, so the 'fresh draw' after a withhold relies on the signer not controlling one block; that the draw total per epoch is bounded only while someone settles each re-armed block inside the window; and that the keeper alert should also fire on a ready epoch without a published secret (signer outage or withhold). If that is not acceptable, the VRF option already listed is the design alternative. Merged from audit_economics, audit_math and audit_permissions.","line":176,"path":"Submission3/AUDIT_README.md","reproduction":"Against the contract: epoch e with a random token, seed block N mined, signer holds s with keccak(s) == revealCommit and does not publish. Any settle(reveals) without s returns NOTHING_TO_SETTLE (line 579 returns progressed = false), setRevealCommit reverts REVEAL_ACCEPTANCE_INVALID without the signer's acceptance (line 280) and EPOCH_REARM_COOLDOWN before N + 7200 (line 288): the keeper cannot act. At any block T with T - N > 8,191 (256 locally) the signer sends settle([s]): _blockHash(N) is zero, line 588 emits EpochRearmed(e, T+1, true) with s now public, and T is whichever block the signer's relay lands in. Scratch test test_expiry_compounds_for_later_epochs shows this timing on the local chain: the signer's settle at 1325 fixes the re-arm block 1326, and again at 1626 fixes 1627; nobody else could have settled in between. Expected per README 8.2: the re-armed-block case 'needs 8,191 blocks during which nobody settled' with the keeper as the control, and a per-epoch total of at most four draws. Actual: the signer alone produces the idle window and the re-arm block, and a second idle window after an expiry re-arm (no call for the window length) produces a further re-arm with no limit (line 588 again, same secret).","severity":"low","snippet":"   - **Builder of a re-armed block.** After an expiry, the epoch's secret is public before the re-armed block is built, and so are any secrets already handed out for later ready epochs. Whoever builds that block could grind it (audit 388b71cb, finding 6). This needs 8,191 blocks during which nobody settled. The keeper (section 7) makes it an alert-backed assumption rather than a design property.","title":"README 8.2 / 7 state the keeper assumption for the wrong path: a withholding signer alone forces the expiry, picks the re-arm block, and the per-epoch draw total is not bounded by expiries"},{"citation":"resolved","description":"The L-2 fix binds an acceptance to `previous` = revealCommit at the time of use. revealCommit changes only when a secret is opened (line 581) or the chain is replaced (line 282). Before the first epoch of a chain is sealed, `previous` is still the live anchor, so any acceptance the signer produced for (commit y0, previous x0) and did not use (a standby replacement prepared at setup, or one signed for a replacement the owner then did not send) remains valid until its deadline, which the contract does not bound (line 278 only requires block.timestamp <= deadline). README 8.2 'Owner alone' states that 'an unused acceptance signed earlier names a commitment that is no longer current' and concludes the owner 'cannot re-arm anything'; that holds only after a reveal. In the window before the first reveal of a chain (which the signer itself can prolong by not publishing), the owner alone can pause and, REARM_COOLDOWN after the next closed epoch's seed block, replace the chain and re-arm that epoch, with no signer action at that time and no way for the signer to revoke. The result is a fresh draw under a future block hash, bounded by MAX_COMMIT_REARMS, so this is a trust-model inaccuracy rather than a steering lever; the deploy script (tools/deploy-testnet.mjs line 41) uses a one-hour deadline, which limits it operationally but not by contract. Fix (keeps the design): bound the acceptance lifetime in setRevealCommit, e.g. require(deadline <= block.timestamp + 1 hours), or include signerProposalNonce-style per-replacement nonce (a counter incremented by every setRevealCommit) in the struct so an acceptance is valid for exactly the next replacement; and reword 8.2 to say an acceptance for the current commitment is live until used, superseded by a reveal, or expired.","line":279,"path":"Submission3/contracts/EmberEvoGenesisCandidate.sol","reproduction":"Foundry, startBlock 1000. setRevealCommit(x0) with the signer's acceptance (previous = 0). The signer signs a second acceptance for (commit = y0, previous = x0, signerEpoch = 1, deadline = now + 30 days) and hands it over; it is not used. Minting opens; block 1010 random mint -> epoch 0 (natural 1025); no secret of chain x is ever opened. Block 8226 (> 1025 + 7200): owner alone calls setMintPaused(true) then setRevealCommit(y0, deadline, spareAcceptance). Expected per README 8.2: refused (the acceptance 'names a commitment that is no longer current'). Actual: accepted, EpochRearmed(0, 8227, false), revealCommit() == y0, epochSeedBlock(0) == 8227. Scratch test test_spare_acceptance_owner_alone_rearms passes on the current code.","severity":"low","snippet":"        bytes32 digest = _hashTypedDataV4(keccak256(abi.encode(REVEAL_COMMIT_TYPEHASH, commit, revealCommit, signerEpoch, deadline)));","title":"A RevealCommitAcceptance signed earlier for the current commitment stays usable by the owner alone until the first secret of that chain is opened; README 8.2 says it is refused"},{"citation":"resolved","description":"The four reads are independent JSON-RPC requests with no blockTag, and the per-epoch reads in the loop (randomEpochAt, epochSeedBlock, epochSecretOpened) are further unpinned calls; nothing checks that they describe one state. The function's guarantee (comment lines 6-7 and 51-54, README 5 'only after the seed block is 2 blocks deep', README 8.2 'Builder of the seed block') depends on `commit` (the walk's start) and `next` (the loop's start) naming the same epoch. If nextRevealIndex is answered before a settle that seals one epoch and revealCommit after it (a new head arriving between the batch entries, or a load-balanced provider answering from different nodes), the loop labels secret k+1 as the secret of list index k: it checks only that epoch k's seed block is 2 deep and publishes chain.next(commit), which is epoch k+1's secret, while epoch k+1's seed block may be in the future. GET /v1/reveals is public, so the builder of that seed block then knows the secret and can grind its hash, the exact case the 2-confirmation rule exists to exclude (audit 2026-10-27 finding 4). The reverse ordering only hands out one secret too few. The race window is one block boundary during a batch, so it is rare, but the harm is a chosen draw and the signer cannot take a published secret back. Fix: read `latest` first, then pass {blockTag: latest} to every contract call (ethers v6 Contract view methods accept an overrides object), or use one multicall pinned to that block; and after the loop re-read nextRevealIndex and revealCommit at the same tag and return an empty list if either moved. Merged from audit_math, audit_permissions and audit_flow.","line":57,"path":"Submission3/offchain/signer/reveal-chain.mjs","reproduction":"Run against the module itself with a scripted provider (the vendored ethers bundle is not in the package: a stub exported Contract, keccak256 (sha256 stand-in; only the read ordering is under test) and isHexString). Chain x0 -> x1 -> x2 from revealChain(top, 8). State: list = [e0, e1], epochSeedBlock(e0) = 97, epochSeedBlock(e1) = 105, head L = 100; settle([x1]) is mined in block 101. Scripted answers: getBlockNumber -> 100 and nextRevealIndex -> 0 (answered at 100, before the settle); revealCommit -> x1 (answered at 101, after it); epochSecretOpened -> false. readyReveals returns {block: 100, reveals: [x2]}: loop i = 0, e0, 97 + 2 <= 100, not opened, push chain.next(x1) = x2; i = 1, e1, 105 + 2 > 100, break. Expected: no secret for e1 until block 107 (its seed block 2 deep); with all reads at 101 the same function returns [] (also run). Actual: x2, the secret of e1, is published 5 blocks before e1's seed block exists.","severity":"low","snippet":" const [latest,commit,count,next]=await Promise.all([provider.getBlockNumber(),c.revealCommit(),c.randomEpochCount(),c.nextRevealIndex()]);","title":"readyReveals reads revealCommit and nextRevealIndex at different heights; a settle mined between the reads shifts the chain walk by one and hands out the secret of an epoch whose seed block is not min"},{"citation":"resolved","description":"The only check on a rotation is that the proposed key signs its own SignerAcceptance (contract line 345); nothing involves the outgoing signer or a delay. The owner can therefore propose a key it controls, activate it, generate its own hash chain, accept it with that key, and from then on hold every 'owner and signer together' power (replace the chain, re-arm the next closed epoch up to MAX_COMMIT_REARMS times after REARM_COOLDOWN, know the chain's secrets and so learn each re-armed outcome and withhold). The next bullet and the contract comment (lines 90-92) concede this in parentheses, so the package contradicts itself on the line that defines the owner's powers. This is an intentional role, not a bypass: report it as the trust assumption it is. Fix (documentation): in 8.2 'Owner alone' state that the owner alone can become owner+signer after a paused, visible rotation (AppearanceSignerChanged) and remove 'so it cannot re-arm anything'. If that should be impossible, add a delay between proposal and activation or require the outgoing signer's co-signature (a design change for the owner to decide).","line":172,"path":"Submission3/AUDIT_README.md","reproduction":"Foundry: owner O also holds key K. With a random token in epoch 0 (natural seed block 1025) and minting paused: proposeAppearanceSigner(K); activateAppearanceSigner(deadline, sign_K(SignerAcceptance(K, 2, signerProposalNonce, deadline))) -> appearanceSigner() == K, signerEpoch 2. At block 8226 (> 1025 + 7200): setRevealCommit(y0, deadline, sign_K(RevealCommitAcceptance(y0, x0, 2, deadline))) with y0 the anchor of a chain O generated. Expected per README 8.2 'Owner alone': impossible ('cannot ... rotate the signer ... so it cannot re-arm anything'). Actual: EpochRearmed(0, 8227, false) with no participation of the previous signer. Scratch test test_owner_rotates_signer_to_own_key passes on the current code.","severity":"info","snippet":"   - **Owner alone.** It cannot set or replace the chain (needs the current signer's acceptance for that exact replacement: an unused acceptance signed earlier names a commitment that is no longer current) or rotate the signer (needs the new key's acceptance), so it cannot re-arm anything.","title":"README 8.2 'Owner alone' says the owner cannot rotate the signer, but proposeAppearanceSigner/activateAppearanceSigner accept any key the owner holds, so the owner alone can obtain the owner+signer po"}],"hash":"ea4e0dfbb0bb9b111f298bbeb8849560ef79fb3f0ef1585d45a13bd482d60d31","nodeId":"be9dc61a-df56-4857-ad6f-c4423fc5a02c","outcome":"completed","summary":"The review is complete. The findings file at `.imd-findings.json` holds six merged findings, every one reproduced against the Submission3 code, and the only other file I placed in the tree is the proof under `test/scratch/`.\n\n**What reproduced (kept, merged from the twelve specialist items)**\n\n- **Low, re-arm count over-count** at `EmberEvoGenesisCandidate.sol:324`. The specialist proof fails on this code with EPOCH_REARM_LIMIT for an epoch that no commitment ever re-drew, as the three specialists claimed. I found a second instance of the same root cause with no expiry at all: the count is cumulative across from-epochs, so an epoch moved by one replacement reads 2. Both are liveness defects, never extra re-rolls. The proposed fix stores the blocks of the last two commitment re-arms and counts those above an epoch's natural seed block.\n- **Low, expiry compounding** at line 588. An expiry re-arm moves every later closed epoch, so a withholding signer gets natural plus one re-arm draw per earlier expiry, each seen before the next withhold. My test confirms epoch 1 drawn under blocks 1050, 1326 and 1627. The README's \"one fresh draw per epoch\" and \"two unpredictable families\" understate this.\n- **Low, keeper assumption** in `AUDIT_README.md:176`. During a withhold nobody but the signer can settle, so the signer alone produces the idle window and chooses the re-arm block. Expiry re-arms are also unbounded. Documentation fix.\n- **Low, spare acceptance** at line 279. An acceptance for the current anchor stays valid until the first reveal of that chain. The owner alone re-armed epoch 0 with a 30-day acceptance in my test. The deploy script's one-hour deadline limits this only operationally.\n- **Low, signer read race** in `reveal-chain.mjs:57`. Run against the real module with a scripted provider, mixed-height answers hand out the next epoch's secret five blocks before its seed block exists.\n- **Info, owner self-rotation** at README line 172. The owner alone can rotate to its own key and then replace and re-arm. The package concedes this one bullet later, so the line contradicts itself.\n\n**What I checked and found sound.** All six Submission3 fixes behave as described: the expiry keeps the public secret and re-arms to the next block with no zero-hash seal, the cooldown uses the re-armed seed block, the chosen-skin freeze uses the natural seed block, the walk covers 32 hashes above every anchor. The hand-packed revealState and randomEpochWords have no shift, mask or stale-read defect I could trigger: the low 64 bits survive every re-arm write, the count fits its two bits, and the settle loop re-reads state each iteration. No minter path steers a draw.\n\n**Limits.** The repository has no Foundry configuration, so I ran the Solidity tests in a scratch project outside the tree with OpenZeppelin 5.4.0 taken from the submitted standard input and solc 0.8.38. The off-chain reproduction used a stub for the absent vendored ethers bundle, with sha256 standing in for keccak, since only the read ordering was under test.","treeHash":null,"usage":{"cachedInputTokens":1938196,"inputTokens":642,"model":"claude-fable-5-1","outputTokens":45791,"runtime":"claude","turns":26,"wallClockMs":598317}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"fedfd42dfee2f4e6","findings":[{"citation":"resolved","description":"revealState keeps one (rearmBlock R, fromEpoch, count) triple for the LAST re-arm of any kind. _commitRearms(e) returns that count for every e >= fromEpoch whose natural seed block is below R. An expiry re-arm (_settleReady, line 588) rewrites R to block.number+1 while carrying the commitment count of the expired epoch over. Every epoch that closed between the last COMMITMENT re-arm and that expiry re-arm now has natural < R and inherits a count it never earned: it was never drawn again by a new chain (the intent of L-1 is to share the count only with epochs the commitment re-arm itself redrew). With count already at MAX_COMMIT_REARMS (2) this is the documented 'way out if a chain is lost' (comment at line 64-65, README 5 'at most 2 times per epoch'): setRevealCommit refuses with EPOCH_REARM_LIMIT for an epoch with 0 commitment re-arms, and since that epoch is next to reveal and can only be sealed with the (lost) chain, every later random reveal and every chosen-skin mint (REVEAL_PENDING from its seed block) stalls permanently. The over-count only restricts the owner+signer (it never grants extra re-rolls), so the randomness bound holds; the defect is a liveness/boundary one in the count's attribution. Fix: make the inherited count apply only to epochs that had closed when the last COMMITMENT re-arm happened, e.g. keep the commitment re-arm's block separately from the expiry re-arm block (one more 64-bit field in revealState, bits 195-258 do not fit; use bits 195-254 for a 60-bit block or store the commit re-arm block in place of the expired flag + a 1-bit flag elsewhere), and have _commitRearms compare _naturalSeedBlock(e) against that commitment block rather than against the latest R. Alternatively, in the expiry branch pass count = (e == fromEpoch ? old count : 0) AND record the old commitment R so later epochs are compared against it.","line":324,"path":"Submission3/contracts/EmberEvoGenesisCandidate.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.38;\n\nimport \"forge-std/Test.sol\";\nimport {EmberEvoGenesisCandidate} from \"Submission3/contracts/EmberEvoGenesisCandidate.sol\";\n\ncontract MockEmbeo {\n    string public name = \"T\"; string public symbol = \"T\"; uint8 public decimals = 18;\n    uint256 public totalSupply;\n    mapping(address => uint256) public balanceOf;\n    mapping(address => mapping(address => uint256)) public allowance;\n    function mint(address to, uint256 a) external { balanceOf[to] += a; totalSupply += a; }\n    function approve(address s, uint256 a) external returns (bool) { allowance[msg.sender][s] = a; return true; }\n    function transfer(address to, uint256 a) external returns (bool) { balanceOf[msg.sender] -= a; balanceOf[to] += a; return true; }\n    function transferFrom(address f, address to, uint256 a) external returns (bool) {\n        allowance[f][msg.sender] -= a; balanceOf[f] -= a; balanceOf[to] += a; return true;\n    }\n}\n\n/// _commitRearms makes an epoch that was only moved by an EXPIRY re-arm inherit the commitment re-arm count of the\n/// epoch before it, so a replacement README 5 / 8.2 allow (0 commitment re-arms for this epoch) is refused with\n/// EPOCH_REARM_LIMIT. Fails on the current code.\ncontract RearmCountInheritTest is Test {\n    EmberEvoGenesisCandidate g;\n    MockEmbeo tok;\n    uint256 constant SIGNER_PK = 1;\n    address signer;\n    address constant DEAD = 0x000000000000000000000000000000000000dEaD;\n    bytes32 constant APPEARANCE_TYPEHASH = keccak256(\"Appearance(address wallet,bytes32 lookKey,uint8 choice,bytes32 uriHash,uint256 deadline,uint256 signerEpoch,bool paid)\");\n    bytes32 constant PAID_AUTH_TYPEHASH = keccak256(\"PaidAuthorization(bytes32 orderId,address wallet,bytes32 lookKey,uint8 choice,bytes32 uriHash,address recipient,uint256 embeoCost,uint256 goldCost,uint256 deadline,uint256 signerEpoch)\");\n    bytes32 constant REVEAL_COMMIT_TYPEHASH = keccak256(\"RevealCommitAcceptance(bytes32 commit,bytes32 previous,uint256 signerEpoch,uint256 deadline)\");\n\n    function setUp() public {\n        signer = vm.addr(SIGNER_PK);\n        vm.roll(1000);\n        vm.warp(1_700_000_000);\n        tok = new MockEmbeo();\n        g = new EmberEvoGenesisCandidate(address(this), address(tok), signer, 1e18, 1, \"EEG\");\n        g.configureSnapshot(bytes32(0), 0, block.timestamp, block.timestamp);\n        g.setRevealURIs(\"pending\", \"base/\", true);\n        g.approvePaidPrice();\n    }\n\n    function domain() internal view returns (bytes32) {\n        return keccak256(abi.encode(keccak256(\"EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)\"),\n            keccak256(\"EmberEvoGenesisCandidate\"), keccak256(\"7\"), block.chainid, address(g)));\n    }\n    function sign(bytes32 structHash) internal view returns (bytes memory) {\n        bytes32 d = keccak256(abi.encodePacked(\"\\x19\\x01\", domain(), structHash));\n        (uint8 v, bytes32 r, bytes32 s) = vm.sign(SIGNER_PK, d);\n        return abi.encodePacked(r, s, v);\n    }\n    function chain(bytes32 seed, uint256 n) internal pure returns (bytes32[] memory x) {\n        x = new bytes32[](n + 1); x[n] = seed;\n        for (uint256 i = n; i > 0; --i) x[i - 1] = keccak256(abi.encode(x[i]));\n    }\n    function setCommit(bytes32 anchor) internal {\n        bytes memory acc = sign(keccak256(abi.encode(REVEAL_COMMIT_TYPEHASH, anchor, g.revealCommit(), uint256(g.signerEpoch()), block.timestamp + 1)));\n        g.setRevealCommit(anchor, block.timestamp + 1, acc);\n    }\n    function mintRandom(address wallet, bytes32 orderId) internal {\n        tok.mint(wallet, 1e18);\n        vm.prank(wallet); tok.approve(address(g), 1e18);\n        bytes32 look = keccak256(abi.encode(wallet, orderId));\n        uint256 dl = block.timestamp + 1;\n        bytes memory app = sign(keccak256(abi.encode(APPEARANCE_TYPEHASH, wallet, look, uint8(3), keccak256(\"\"), dl, uint256(g.signerEpoch()), true)));\n        EmberEvoGenesisCandidate.PaidAuthorization memory o = EmberEvoGenesisCandidate.PaidAuthorization(orderId, wallet, look, 3, keccak256(\"\"), DEAD, 1e18, 1, dl, g.signerEpoch());\n        bytes memory gold = sign(keccak256(abi.encode(PAID_AUTH_TYPEHASH, o)));\n        bytes32[] memory none;\n        vm.prank(wallet);\n        g.mintPaid(look, 3, \"\", dl, app, DEAD, 1e18, o, gold, none);\n    }\n\n    function test_epochMovedOnlyByExpiryInheritsCommitRearmCount() public {\n        bytes32[] memory c1 = chain(keccak256(\"seed1\"), 8);\n        bytes32[] memory c2 = chain(keccak256(\"seed2\"), 8);\n        bytes32[] memory c3 = chain(keccak256(\"seed3\"), 8);\n        bytes32[] memory c4 = chain(keccak256(\"seed4\"), 8);\n        setCommit(c1[0]);\n        g.setMintPaused(false);\n\n        // epoch 0 (blocks 1000..1024): one random token; own seed block 1025\n        vm.roll(1010); mintRandom(address(0xA1), bytes32(uint256(1)));\n        assertEq(g.epochSeedBlock(0), 1025);\n\n        // nobody settles; owner + signer replace the chain twice: epoch 0 count = 2 = MAX_COMMIT_REARMS\n        vm.roll(1025 + 7201); g.setMintPaused(true); setCommit(c2[0]);   // re-arm #1 -> 8227\n        assertEq(g.epochSeedBlock(0), 8227);\n        vm.roll(8227 + 7201); setCommit(c3[0]);                          // re-arm #2 -> 15429\n        assertEq(g.epochSeedBlock(0), 15429);\n        g.setMintPaused(false);\n\n        // a NEW epoch closes after re-arm #2: epoch 577 (15425..15449), own seed block 15450; never moved by a commitment\n        vm.roll(15439); mintRandom(address(0xA2), bytes32(uint256(2)));\n        assertEq(g.randomEpochAt(1), 577);\n        assertEq(g.epochSeedBlock(577), 15450);\n\n        // seed block 15429 expires (no EIP-2935 here: 256-block window). c3[1] is supplied and kept; epoch 0 re-arms\n        // to the next block, count 2 carried over; epoch 577 is moved by this EXPIRY re-arm only\n        vm.roll(15429 + 257);\n        bytes32[] memory r = new bytes32[](1); r[0] = c3[1];\n        g.settle(r);\n        assertTrue(g.epochSecretOpened(0));\n        assertEq(g.epochSeedBlock(0), 15687);\n        assertEq(g.epochSeedBlock(577), 15687);\n\n        // epoch 0 settles with no secret; 577 is next and would need c3[2]\n        vm.roll(15688);\n        bytes32[] memory none;\n        g.settle(none);\n        assertEq(g.nextRevealIndex(), 1);\n        (bool exists, uint256 e, ) = g.nextRevealEpoch();\n        assertTrue(exists); assertEq(e, 577);\n\n        // Chain lost: README 5 / 8.2 allow a replacement to re-arm epoch 577 (0 commitment re-arms so far) once\n        // REARM_COOLDOWN has passed after its current seed block 15687.\n        vm.roll(15687 + 7201);\n        g.setMintPaused(true);\n        setCommit(c4[0]);                       // actual: EPOCH_REARM_LIMIT\n        assertEq(g.epochSeedBlock(577), block.number + 1);\n    }\n}","reproduction":"Foundry (256-block blockhash window; under EIP-2935 the expiry is 8,191 blocks, same logic). startBlock=1000, chain c1 committed, minting open. Block 1010: random mint -> epoch 0, own seed block 1025. Nobody settles. Block 8226: pause, setRevealCommit(c2) re-arms epoch 0 to 8227 (count 1). Block 15428: setRevealCommit(c3) re-arms to 15429 (count 2). Unpause; block 15439: random mint -> epoch 577, own seed block 15450 (closed AFTER the last commitment re-arm; 0 commitment re-arms). Block 15686: settle([c3[1]]) finds blockhash(15429)==0 -> expiry re-arm: R=15687, from=0, count 2 carried; epochSeedBlock(577)==15687 (moved by the expiry re-arm only). Block 15688: settle([]) seals epoch 0 with hash(15687); nextRevealEpoch()=577. Block 22888 (> 15687+7200): pause, setRevealCommit(c4) with the signer's acceptance. Expected (README 5, 8.2, line 64-65): accepted, EpochRearmed(577, 22889, false), epoch 577 count 1. Actual: revert EPOCH_REARM_LIMIT because _commitRearms(577) = (577 >= 0 && 15687 > 15450) ? 2 : 0 = 2. The proof test fails with that revert on the current code.","severity":"low","snippet":"        return (e >= uint64(rs >> 128) && uint64(rs >> 64) > _naturalSeedBlock(e)) ? (rs >> 193) & 3 : 0;","title":"_commitRearms charges an epoch that only an EXPIRY re-arm moved with the previous epoch's commitment re-arm count, so a replacement README 5/8.2 allow reverts EPOCH_REARM_LIMIT"},{"citation":"resolved","description":"The four reads are independent JSON-RPC requests with no blockTag; the per-epoch reads in the loop (randomEpochAt, epochSeedBlock, epochSecretOpened) are further unpinned calls. Nothing checks that they describe the same state. The function's invariant (comment line 51-54, README 5 'only after the seed block is 2 blocks deep', README 8.2 'Builder of the seed block') is that a secret handed out always belongs to an epoch whose seed block is already mined. That invariant is derived from `open` (revealCommit) and the loop start (nextRevealIndex) agreeing on the same epoch. If revealCommit is served from a block after a settle and nextRevealIndex from before it (two eth_call answered by different nodes of a load-balanced provider, or a new head arriving between them), the loop labels secret k+1 as the secret of epoch k and publishes it while epoch k+1's seed block is still in the future. A builder of that block then knows the secret and can grind the epoch's outcome, which is exactly the case the 2-confirmation rule exists to exclude (audit 2026-10-27 finding 4). Fix: read `latest` first, then pass {blockTag: latest} to every contract call (ethers supports overrides on view calls), or do all reads in one multicall/eth_call batch pinned to one block, and additionally re-read nextRevealIndex/revealCommit after the loop and return nothing if they changed.","line":57,"path":"Submission3/offchain/signer/reveal-chain.mjs","reproduction":"State: random epochs e1 (list index 0, seed block S1) and e2 (index 1, seed block S2), chain secrets s1 = preimage(x0), s2 = preimage(s1). Head L with S1+2 <= L < S2 (e1 ready, e2 open and not closed). Sequence: (1) GET /v1/reveals -> readyReveals issues the four requests; getBlockNumber and nextRevealIndex are answered at height L: latest=L, next=0; (2) settle([s1]) is mined in block L+1 (anyone can send it: s1 was published earlier) -> revealCommit=s1, nextRevealIndex=1; (3) the revealCommit request is answered at height L+1: commit=s1. Loop: i=0, e1, epochSeedBlock(e1)=S1, S1+2<=L -> not opened -> s=chain.next(s1)=s2, out=[s2]; i=1, e2, S2+2 > L -> break. Returns {block:L, reveals:[s2]}. Expected: [] or [s1] (nothing for an epoch whose seed block S2 > L). Actual: s2, e2's secret, is public before block S2 exists.","severity":"low","snippet":" const [latest,commit,count,next]=await Promise.all([provider.getBlockNumber(),c.revealCommit(),c.randomEpochCount(),c.nextRevealIndex()]);","title":"readyReveals reads the head block, revealCommit and nextRevealIndex at different heights, so a settle landing between the reads makes it hand out the secret of an epoch whose seed block is not mined y"},{"citation":"resolved","description":"The bullet says the grind by the builder of a re-armed block 'needs 8,191 blocks during which nobody settled' and that the keeper makes it an 'alert-backed assumption'. In the withholding path (line 174) the expiry is produced by the signer alone: while it holds the secret no keeper, minter or owner can settle (settle needs the preimage; setRevealCommit needs the signer's acceptance and the cooldown), so the keeper only alerts and never has a settle to send. After expiry the signer picks the block X in which it publishes (settle([secret]) through a private relay to a chosen builder, or a block it builds) and the epoch re-arms to X+1: whoever builds X+1 grinds its hash with the secret already public. Line 174 describes this as a draw the signer 'cannot predict (unless it also builds that next block)' and treats the builder case as a separate, keeper-mitigated bullet; combined, a signer that can buy one block from any builder turns one withhold into one chosen draw per epoch, bounded only by the 7,200-block cooldown and the visibility of the expiry. The contract behaves as documented; the trust statement is what is overstated. Fix (documentation, section 8.2 and 7): state that the keeper covers only the non-withholding expiry (a published secret nobody used), that a withhold ends in a block the signer chooses and whose next builder it can select, and that the 'fresh draw per epoch' claim therefore relies on the signer not colluding with a builder; if that is not acceptable, the design change is the VRF option already listed.","line":176,"path":"Submission3/AUDIT_README.md","reproduction":"State: epoch e with random tokens, seed block S mined, signer holds secret s (keccak(s)=revealCommit). Signer does not publish. Keeper alert fires at S+300; keeper cannot settle (no preimage), owner cannot replace before S+7200 and not without the signer. At block X >= S+8192 (EIP-2935 window gone) the signer sends settle([s]) through builder B's private relay for B's slot X, with B also building X+1 (or the signer builds both). Expected per README 8.2: 'one fresh draw per epoch that the signer cannot predict'. Actual: in X the epoch re-arms to X+1 with s public; B tries transaction orderings/extra data for X+1 until keccak(abi.encode(hash(X+1), s, e, this)) draws the wanted skins for the epoch's tokens, then any call at X+2 seals it. The precondition 'nobody settled for 8,191 blocks' was satisfied by the signer alone, so the keeper assumption in section 7 did not apply.","severity":"info","snippet":"   - **Builder of a re-armed block.** After an expiry, the epoch's secret is public before the re-armed block is built, and so are any secrets already handed out for later ready epochs. Whoever builds that block could grind it (audit 388b71cb, finding 6). This needs 8,191 blocks during which nobody settled. The keeper (section 7) makes it an alert-backed assumption rather than a design property.","title":"README 8.2 states the keeper assumption for the wrong path: during a signer withhold nobody can settle, so the 'builder of a re-armed block' case needs no 8,191 idle blocks of anyone else and the sign"}],"hash":"fd987220a173c4205eaa80b89efeff5726c4b19c0145610cd089bd89b1c10cca","nodeId":"3982fecc-1e51-4204-9b16-88e9c99d2c66","outcome":"completed","summary":"Review complete. The findings file is written and validated, and the proof behaves as required.\n\n**Outcome**\n\n`.imd-findings.json` holds three findings. The contract's re-arm and reveal math otherwise held up under the Math Precision, Boundary and Numerical Gap passes.\n\n- **Low, contract line 324.** `_commitRearms` charges an epoch that only an expiry re-arm moved with the previous epoch's commitment re-arm count. Once that count is at the limit, a replacement that README 5 and 8.2 allow reverts with `EPOCH_REARM_LIMIT`, and if the chain is lost every later random reveal and chosen-skin mint stalls. The proof test fails on the current code with that revert and passes under a minimal fix that compares against the last commitment re-arm block instead of the latest re-arm block. The author's L-1 test only covers epochs already closed at the last commitment re-arm, so this path was untested.\n- **Low, signer `reveal-chain.mjs` line 57.** `readyReveals` reads the head block, `revealCommit` and `nextRevealIndex` as separate unpinned RPC calls. A `settle` landing between them makes the loop publish the secret of an epoch whose seed block is not mined yet, which is the case the two-confirmation rule exists to exclude. Fix is a single `blockTag` for all reads plus a re-check after the loop.\n- **Info, README line 176.** The keeper assumption is stated for the wrong path. During a signer withhold nobody else can settle, so the \"8,191 idle blocks\" precondition is met by the signer alone, and it chooses the block in which it publishes and so which builder gets the public secret for the re-armed block.\n\n**What was verified as sound**\n\n- Expired seed blocks keep the public secret and re-arm to the next block; no zero-hash seal exists.\n- Cooldown counts from the current seed block including a pending re-arm; chosen-skin freeze uses the epoch's own seed block.\n- The 32-hash walk above every anchor, the `previous` binding in the acceptance, and all shifts and masks in `revealState`, `randomEpochWords`, `epochTokens`, `tokenBits` and `skinState` fit their layouts with no overlap. The `& 1 == 1` precedence in `epochSecretOpened` was checked directly.\n- Minters and the owner alone cannot steer a draw beyond what README 8.2 states.\n\n**Limits**\n\n- The proof runs under Foundry's 256-block `blockhash` window rather than EIP-2935's 8,191 blocks, which is the same code path.\n- The signer race was traced by hand, not executed, since the repository does not vendor its `ethers` dependency.\n- No scratch files were left in the repository. The Foundry workspace used for checking lives under `/tmp` only.","treeHash":null,"usage":{"cachedInputTokens":696182,"inputTokens":48,"model":"claude-fable-5-1","outputTokens":58702,"runtime":"claude","turns":19,"wallClockMs":767776}}],"verification":[]}