# Audit report

> 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.

| | |
|---|---|
| Repository | https://github.com/tungweb3/ember-evo-genesis-review.git |
| Commit | `ebbd94621dad1f56592ffb50c530e2d63f35b177` |
| Job | `d726b1cd-bb4c-49cf-adb8-8be595c9abab` |
| Judged | 2026-10-27 16:25 UTC |
| Findings | 5 low · 1 info |

Four agents audited the code as it is at `ebbd946`, each in one area (math, permissions, economics, control flow),
and a judge reproduced, merged and ranked what they found, then read the code once more itself. Nothing in the repository was changed or deployed.

## Findings

### 1. Low: _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

`Submission3/contracts/EmberEvoGenesisCandidate.sol:324`

```
        return (e >= uint64(rs >> 128) && uint64(rs >> 64) > _naturalSeedBlock(e)) ? (rs >> 193) & 3 : 0;
```

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.

**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).

**Proof**: a Foundry test that fails on this code and passes once it is fixed.

```solidity
// SPDX-License-Identifier: MIT
pragma solidity 0.8.38;

import "forge-std/Test.sol";
import {EmberEvoGenesisCandidate} from "Submission3/contracts/EmberEvoGenesisCandidate.sol";

contract MockEmbeo {
    string public name = "T"; string public symbol = "T"; uint8 public decimals = 18;
    uint256 public totalSupply;
    mapping(address => uint256) public balanceOf;
    mapping(address => mapping(address => uint256)) public allowance;
    function mint(address to, uint256 a) external { balanceOf[to] += a; totalSupply += a; }
    function approve(address s, uint256 a) external returns (bool) { allowance[msg.sender][s] = a; return true; }
    function transfer(address to, uint256 a) external returns (bool) { balanceOf[msg.sender] -= a; balanceOf[to] += a; return true; }
    function transferFrom(address f, address to, uint256 a) external returns (bool) {
        allowance[f][msg.sender] -= a; balanceOf[f] -= a; balanceOf[to] += a; return true;
    }
}

/// _commitRearms makes an epoch that was only moved by an EXPIRY re-arm inherit the commitment re-arm count of the
/// epoch before it, so a replacement README 5 / 8.2 allow (0 commitment re-arms for this epoch) is refused with
/// EPOCH_REARM_LIMIT. Fails on the current code.
contract RearmCountInheritTest is Test {
    EmberEvoGenesisCandidate g;
    MockEmbeo tok;
    uint256 constant SIGNER_PK = 1;
    address signer;
    address constant DEAD = 0x000000000000000000000000000000000000dEaD;
    bytes32 constant APPEARANCE_TYPEHASH = keccak256("Appearance(address wallet,bytes32 lookKey,uint8 choice,bytes32 uriHash,uint256 deadline,uint256 signerEpoch,bool paid)");
    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)");
    bytes32 constant REVEAL_COMMIT_TYPEHASH = keccak256("RevealCommitAcceptance(bytes32 commit,bytes32 previous,uint256 signerEpoch,uint256 deadline)");

    function setUp() public {
        signer = vm.addr(SIGNER_PK);
        vm.roll(1000);
        vm.warp(1_700_000_000);
        tok = new MockEmbeo();
        g = new EmberEvoGenesisCandidate(address(this), address(tok), signer, 1e18, 1, "EEG");
        g.configureSnapshot(bytes32(0), 0, block.timestamp, block.timestamp);
        g.setRevealURIs("pending", "base/", true);
        g.approvePaidPrice();
    }

    function domain() internal view returns (bytes32) {
        return keccak256(abi.encode(keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"),
            keccak256("EmberEvoGenesisCandidate"), keccak256("7"), block.chainid, address(g)));
    }
    function sign(bytes32 structHash) internal view returns (bytes memory) {
        bytes32 d = keccak256(abi.encodePacked("\x19\x01", domain(), structHash));
        (uint8 v, bytes32 r, bytes32 s) = vm.sign(SIGNER_PK, d);
        return abi.encodePacked(r, s, v);
    }
    function chain(bytes32 seed, uint256 n) internal pure returns (bytes32[] memory x) {
        x = new bytes32[](n + 1); x[n] = seed;
        for (uint256 i = n; i > 0; --i) x[i - 1] = keccak256(abi.encode(x[i]));
    }
    function setCommit(bytes32 anchor) internal {
        bytes memory acc = sign(keccak256(abi.encode(REVEAL_COMMIT_TYPEHASH, anchor, g.revealCommit(), uint256(g.signerEpoch()), block.timestamp + 1)));
        g.setRevealCommit(anchor, block.timestamp + 1, acc);
    }
    function mintRandom(address wallet, bytes32 orderId) internal {
        tok.mint(wallet, 1e18);
        vm.prank(wallet); tok.approve(address(g), 1e18);
        bytes32 look = keccak256(abi.encode(wallet, orderId));
        uint256 dl = block.timestamp + 1;
        bytes memory app = sign(keccak256(abi.encode(APPEARANCE_TYPEHASH, wallet, look, uint8(3), keccak256(""), dl, uint256(g.signerEpoch()), true)));
        EmberEvoGenesisCandidate.PaidAuthorization memory o = EmberEvoGenesisCandidate.PaidAuthorization(orderId, wallet, look, 3, keccak256(""), DEAD, 1e18, 1, dl, g.signerEpoch());
        bytes memory gold = sign(keccak256(abi.encode(PAID_AUTH_TYPEHASH, o)));
        bytes32[] memory none;
        vm.prank(wallet);
        g.mintPaid(look, 3, "", dl, app, DEAD, 1e18, o, gold, none);
    }

    function test_epochMovedOnlyByExpiryInheritsCommitRearmCount() public {
        bytes32[] memory c1 = chain(keccak256("seed1"), 8);
        bytes32[] memory c2 = chain(keccak256("seed2"), 8);
        bytes32[] memory c3 = chain(keccak256("seed3"), 8);
        bytes32[] memory c4 = chain(keccak256("seed4"), 8);
        setCommit(c1[0]);
        g.setMintPaused(false);

        // epoch 0 (blocks 1000..1024): one random token; own seed block 1025
        vm.roll(1010); mintRandom(address(0xA1), bytes32(uint256(1)));
        assertEq(g.epochSeedBlock(0), 1025);

        // nobody settles; owner + signer replace the chain twice: epoch 0 count = 2 = MAX_COMMIT_REARMS
        vm.roll(1025 + 7201); g.setMintPaused(true); setCommit(c2[0]);   // re-arm #1 -> 8227
        assertEq(g.epochSeedBlock(0), 8227);
        vm.roll(8227 + 7201); setCommit(c3[0]);                          // re-arm #2 -> 15429
        assertEq(g.epochSeedBlock(0), 15429);
        g.setMintPaused(false);

        // a NEW epoch closes after re-arm #2: epoch 577 (15425..15449), own seed block 15450; never moved by a commitment
        vm.roll(15439); mintRandom(address(0xA2), bytes32(uint256(2)));
        assertEq(g.randomEpochAt(1), 577);
        assertEq(g.epochSeedBlock(577), 15450);

        // seed block 15429 expires (no EIP-2935 here: 256-block window). c3[1] is supplied and kept; epoch 0 re-arms
        // to the next block, count 2 carried over; epoch 577 is moved by this EXPIRY re-arm only
        vm.roll(15429 + 257);
        bytes32[] memory r = new bytes32[](1); r[0] = c3[1];
        g.settle(r);
        assertTrue(g.epochSecretOpened(0));
        assertEq(g.epochSeedBlock(0), 15687);
        assertEq(g.epochSeedBlock(577), 15687);

        // epoch 0 settles with no secret; 577 is next and would need c3[2]
        vm.roll(15688);
        bytes32[] memory none;
        g.settle(none);
        assertEq(g.nextRevealIndex(), 1);
        (bool exists, uint256 e, ) = g.nextRevealEpoch();
        assertTrue(exists); assertEq(e, 577);

        // Chain lost: README 5 / 8.2 allow a replacement to re-arm epoch 577 (0 commitment re-arms so far) once
        // REARM_COOLDOWN has passed after its current seed block 15687.
        vm.roll(15687 + 7201);
        g.setMintPaused(true);
        setCommit(c4[0]);                       // actual: EPOCH_REARM_LIMIT
        assertEq(g.epochSeedBlock(577), block.number + 1);
    }
}
```

### 2. Low: 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

`Submission3/contracts/EmberEvoGenesisCandidate.sol:588`

```
            if (h == bytes32(0)) { _rearm(e, true, _commitRearms(e)); return progressed; }
```

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'.

**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.

### 3. Low: 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

`Submission3/AUDIT_README.md:176`

```
   - **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.
```

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.

**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).

### 4. Low: 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

`Submission3/contracts/EmberEvoGenesisCandidate.sol:279`

```
        bytes32 digest = _hashTypedDataV4(keccak256(abi.encode(REVEAL_COMMIT_TYPEHASH, commit, revealCommit, signerEpoch, deadline)));
```

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.

**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.

### 5. Low: 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

`Submission3/offchain/signer/reveal-chain.mjs:57`

```
 const [latest,commit,count,next]=await Promise.all([provider.getBlockNumber(),c.revealCommit(),c.randomEpochCount(),c.nextRevealIndex()]);
```

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.

**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.

### 6. Info: 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

`Submission3/AUDIT_README.md:172`

```
   - **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.
```

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).

**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.

---

Judge's submission `ea4e0dfbb0bb9b111f298bbeb8849560ef79fb3f0ef1585d45a13bd482d60d31`, accepted on the IdentityMD network. Acceptance means the report met the job's checks;
it is not a guarantee that the code has no other defects.
