# Audit report

> Read Submission1/AUDIT_README.md first. Primary scope: Submission1/contracts/EmberEvoGenesisCandidate.sol (verified on Sepolia 0x8ce67008570ED41F64e4Be149ecAA66d1aBE2efa, solc 0.8.38). Not in scope: contracts/test, contracts/testnet, OpenZeppelin. 1) Commit-reveal randomness: can a minter, the owner or the signer steer a random skin (timing, chosen-skin mints around a reveal, out-of-order sealing, stale or extra secrets, expired seed block, EIP-2935 fallback, setRevealCommit re-arm)? 2) Supply and per-skin caps: can supply exceed 1,288, a skin exceed its cap, or a random token end with no skin? 3) Signatures: replay of Appearance / PaidAuthorization across wallets, choices, deadlines, signer rotation (signerEpoch), chains or contracts; ERC-1271 signers. 4) Paid mint: exact paidCost burn, order consumed once, revoked orders, fee-on-transfer tokens, reentrancy, the transient one-paid-mint-per-transaction marker with smart wallets. 5) Gas bounds when one mint settles up to 8 epochs x 25 tokens; REVEAL_PENDING griefing. Secondary (trust model): Submission1/offchain signer and GOLD ledger. Report severity, reproduction and fix.

| | |
|---|---|
| Repository | https://github.com/tungweb3/ember-evo-genesis-review.git |
| Commit | `b05b1a9d56a9b9eb9e954d7210097db0fb99cd42` |
| Job | `31bb373a-4cd3-49b2-973c-758dde5b6ec4` |
| Judged | 2026-10-27 02:13 UTC |
| Findings | 2 medium · 5 low · 7 info |

Four agents audited the code as it is at `b05b1a9`, 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. Medium: Owner alone can re-roll a ready random epoch any number of times through repeated setRevealCommit re-arms (README 8.2 says owner + signer, once)

`Submission1/contracts/EmberEvoGenesisCandidate.sol:222`

```
            epochRearmBlock[e] = block.number + 1; ++rearmed; emit EpochRearmed(e, block.number + 1);
```

setRevealCommit (owner, paused) accepts any non-zero commitment different from the current one and re-arms every unsealed epoch whose seed block is already mined to block.number + 1. Nothing records that an epoch was already re-armed, nothing binds the new chain to the approval signer (no SignerAcceptance-style countersignature as for signer rotation), and nothing limits how often the owner may replace the chain. The owner can therefore generate its own hash chain, re-arm, wait one block, compute the epoch's seed (keccak256(abi.encode(blockhash(rearm), secret, e, this))) and the _revealRange draws off chain, and if it dislikes the outcome call setRevealCommit again with a fresh chain: the same epoch is re-armed to a new block and rolled again. Rejection sampling over ~27 attempts (about one hour of paused minting, one transaction every two blocks) puts a chosen token on the 48/1288 molten-gold skin. This contradicts AUDIT_README section 6 property 4 / section 8.2 (re-roll needs 'Owner + signer together', implying one recovery) and the contract comment at line 421 ('nobody (including the owner) can choose a token's skin'). It is an owner power, visible through RevealCommitSet / EpochRearmed, and on mainnet the owner is a multisig, but the audit package understates it. Reported by three specialists (economics, flow, permissions); merged. Fix (keeps the lost-chain recovery): refuse to re-arm an epoch whose epochRearmBlock is already set (require(epochRearmBlock[e] == 0, 'EPOCH_ALREADY_REARMED')) and/or require the new commitment to be countersigned by the appearance signer with an EIP-712 acceptance checked like activateAppearanceSigner, so neither party alone can replace the chain; update README 8.2 to describe the actual power.

**Reproduction**

State: deploy at block 100, one random token minted in epoch 0 (seed block 125), block 126 mined, epoch 0 ready and unsealed. Owner: setMintPaused(true); setRevealCommit(keccak256(abi.encode(A))) -> EpochRearmed(0, 127). Owner computes epoch 0's outcome with secret A and blockhash(127) at block 128. Owner: setRevealCommit(keccak256(abi.encode(B))) at block 128. Expected: refused (one re-arm per epoch, or signer countersignature required). Actual: accepted, EpochRearmed(0, 129), epochRearmBlock(0) == 129; repeatable without bound. Ran the attached test: FAIL 'next call did not revert as expected' on the current code (also Proof_540678224909 from the permissions specialist fails identically).

**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 "Submission1/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;
    }
}

/// Finding: the owner alone can re-arm the same ready, unsealed epoch again and again through setRevealCommit,
/// each time with a commitment of its own choosing, and so pick the best of many random outcomes for that epoch.
/// Fails on the current code (the second re-arm of epoch 0 succeeds); passes once an epoch can be re-armed at most once.
contract OwnerRerollProof is Test {
    EmberEvoGenesisCandidate g;
    MockEmbeo token;
    address owner = address(0xA11CE);
    uint256 signerKey = 0xB0B;
    uint256 constant COST = 1e18;
    uint256 constant GOLD = 5;
    bytes32 constant APPEARANCE_TYPEHASH = keccak256("Appearance(address wallet,bytes32 lookKey,uint8 choice,bytes32 uriHash,uint256 deadline,uint256 signerEpoch)");
    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)");

    function setUp() public {
        token = new MockEmbeo();
        vm.roll(100);
        g = new EmberEvoGenesisCandidate(owner, address(token), vm.addr(signerKey), COST, GOLD, "EEG");
        bytes32 x1 = keccak256("signer-secret-1");
        vm.startPrank(owner);
        g.configureSnapshot(bytes32(0), 0, block.timestamp, block.timestamp);
        g.setRevealURIs("ipfs://pending", "ipfs://base/", true);
        g.setRevealCommit(keccak256(abi.encode(x1)));   // the signer's chain: x0 = keccak(x1)
        g.setMintPaused(false);
        g.approvePaidPrice();
        vm.stopPrank();
    }

    function sign(bytes32 structHash) internal view returns (bytes memory) {
        bytes32 dom = keccak256(abi.encode(keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"),
            keccak256("EmberEvoGenesisCandidate"), keccak256("6"), block.chainid, address(g)));
        (uint8 v, bytes32 r, bytes32 s) = vm.sign(signerKey, keccak256(abi.encodePacked("\x19\x01", dom, structHash)));
        return abi.encodePacked(r, s, v);
    }

    function mintRandom(address w) internal {
        token.mint(w, COST);
        vm.prank(w); token.approve(address(g), COST);
        bytes32 lookKey = keccak256("look");
        uint256 deadline = block.timestamp + 100;
        bytes memory sig = sign(keccak256(abi.encode(APPEARANCE_TYPEHASH, w, lookKey, uint8(3), keccak256(bytes("")), deadline, uint256(1))));
        EmberEvoGenesisCandidate.PaidAuthorization memory o = EmberEvoGenesisCandidate.PaidAuthorization({
            orderId: keccak256("order-1"), wallet: w, lookKey: lookKey, choice: 3, uriHash: keccak256(bytes("")),
            recipient: 0x000000000000000000000000000000000000dEaD, embeoCost: COST, goldCost: GOLD, deadline: deadline, signerEpoch: 1});
        bytes memory gsig = sign(keccak256(abi.encode(PAID_AUTH_TYPEHASH, o)));
        bytes32[] memory none;
        vm.prank(w);
        g.mintPaid(lookKey, 3, "", deadline, sig, 0x000000000000000000000000000000000000dEaD, COST, o, gsig, none);
    }

    function test_owner_cannot_rearm_the_same_epoch_twice() public {
        vm.roll(101);
        mintRandom(address(0x1111));                 // one random token in epoch 0; seed block = 125
        assertEq(g.epochSeedBlock(0), 125);
        vm.roll(126);                                // epoch 0 is ready; the signer's secret x1 is public, nobody sealed it
        vm.startPrank(owner);
        g.setMintPaused(true);
        // First replacement: a legitimate recovery re-arms epoch 0 to block 127 (secrets fixed before that hash exists).
        g.setRevealCommit(keccak256(abi.encode(keccak256("owner-secret-A"))));
        assertEq(g.epochSeedBlock(0), 127);
        vm.roll(128);                                // block 127 is mined: with secret A the owner can compute epoch 0's outcome
        // Second replacement of an epoch that was already re-armed: must be refused, otherwise the owner re-rolls
        // the outcome as often as it likes (every two blocks) and keeps the roll it prefers.
        vm.expectRevert();
        g.setRevealCommit(keccak256(abi.encode(keccak256("owner-secret-B"))));
        vm.stopPrank();
        assertEq(g.epochSeedBlock(0), 127, "epoch 0 must keep its single re-armed seed block");
    }
}
```

### 2. Medium: GOLD book never settles a paid mint sent through a smart-contract wallet or a bundled transaction: receipt.to must equal the Genesis contract and the receipt must hold exactly one GenesisMinted

`Submission1/offchain/gold-book/server/genesis-gold-reader.ts:61`

```
   if(qty(receipt.status)!==1n||String(receipt.to).toLowerCase()!==contract||rBlock>finNumber)fail('MINT_PROOF_CONFLICT');
```

createGenesisR5Reader.read() proves consumption from the receipt of the transaction that emitted PaidOrderConsumed. Line 61 rejects the receipt unless receipt.to is the Genesis contract, and line 67 (minted.length!==1, where minted is every GenesisMinted log of the contract in the receipt, not filtered by tokenId) rejects it unless exactly one token was minted in the whole transaction. Both hold only for a direct EOA call. The contract accepts contract callers (it uses _safeMint and ERC-1271 via SignatureChecker, and the transient marker comment at line 374 is explicitly about smart wallets), and the paid signer issues orders to any wallet address with no EOA check (paid-approval.mjs). A Safe execTransaction, an ERC-4337 bundle (receipt.to = EntryPoint), or an EIP-7702 batch (receipt.to = the EOA; two GenesisMinted logs if the batch also does a mintFree, which the per-sender marker does not stop) makes read() throw MINT_PROOF_CONFLICT. reconcileHeldMintGold (world-gold.ts lines 184-188) only settles when read() returns, so the hold is neither consumed nor released, is retried by every reconciliation run forever, and the wallet's GOLD stays reserved although the token was minted and the EMBEO burned. goldAdminCommand has no action that settles or releases a mint hold ('repair-topup' and 'refund-service' only). Fix: drop the receipt.to requirement (the log's address is already checked), select the GenesisMinted log whose topics[1] == wallet and topics[2] == tokenId instead of requiring exactly one, or prove consumption from the PaidOrderConsumed log (which already carries wallet, tokenId and goldCost) plus the eth_call of paidOrderTokenId.

**Reproduction**

State: paid order O for wallet W (a Safe or any contract implementing onERC721Received), GOLD held in the book. W executes mintPaid(...) through its own execTransaction; the mint succeeds (PaidOrderConsumed(O, W, 7), GenesisMinted(W, 7, ...), EMBEO burned). Reader: paidOrderTokenId(O) at the finalized block == 7 -> eth_getLogs finds the log -> eth_getTransactionReceipt -> String(receipt.to).toLowerCase() is W, not the Genesis contract -> fail('MINT_PROOF_CONFLICT') at line 61. reconcileHeldMintGold records {id: O, status: 'MINT_PROOF_CONFLICT'}; world_gold_mints.state stays 'held' and the wallet's reserved GOLD never decreases. Same with an ERC-4337 EntryPoint.handleOps bundle carrying two users' mintPaid userOps: receipt.to = EntryPoint, and even with the to-check removed minted.length == 2 at line 67 -> both orders stuck. Expected: consumption proven by PaidOrderConsumed(orderId, wallet, tokenId) and the matching GenesisMinted(wallet, tokenId) regardless of the outer transaction target. Verified by reading the reader and world-gold.ts; the on-chain part (a contract wallet's mintPaid succeeds) is shown by test_twoWalletsTwoPaidMintsOneTransaction in test/scratch/Judge.t.sol.

### 3. Low: With nine or more ready epochs every chosen-skin mint reverts with REVEAL_PENDING even when it carries every secret; recovery needs a separate settle() the documented flow never sends

`Submission1/contracts/EmberEvoGenesisCandidate.sol:429`

```
        for (uint256 guard; guard < 8; ++guard) {
```

_settleReady settles at most 8 epochs per call and then returns; _approval for a chosen skin (line 337) requires that no ready epoch remains. When 9 epochs are ready the mint seals and reveals 8 of them and then reverts, undoing that work. The off-chain signer caps GET /v1/reveals at MAX_REVEALS = 8 (reveal-chain.mjs line 11), so the mint page can never supply enough secrets to get through in one transaction: every chosen-skin mint fails until someone calls settle() in a separate transaction, which the README flow (sections 5 and 8.3: fetch fresh secrets and retry) never does. Nine ready epochs need only nine random mints, one per 25-block epoch, with no settle in between: one wallet can create the state deliberately in about 225 blocks while the collection is quiet, or it happens when the signer service is down for about 40 minutes. Random mints are unaffected, so this is availability of chosen-skin minting, not a safety break. Reported by economics (info), flow, permissions and math; merged. Fix: let _settleReady continue while secrets are supplied (bound by gasleft() or raise the fixed bound in step with the gas measurement), raise MAX_REVEALS in the signer accordingly, and have the mint page call settle() first when more than 8 epochs are ready.

**Reproduction**

Mint one random token (choice 3) in each of epochs 0..8 (blocks startBlock+25e+1), no settle in between; roll to epochSeedBlock(8)+1 so all nine are ready. Call mintPaid with choice 0, a non-empty uri and reveals = [x1..x9] in chain order. Expected: the nine epochs are settled and the chosen mint succeeds (nextRevealIndex == 9, pendingRandom == 0). Actual: revert REVEAL_PENDING; nextRevealIndex stays 0 because the whole transaction reverts. The attached test (from the permissions specialist) fails with REVEAL_PENDING on the current code.

**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 "Submission1/contracts/EmberEvoGenesisCandidate.sol";

contract Embeo {
    string public name = "EMBEO"; string public symbol = "EMBEO"; 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 { totalSupply += a; balanceOf[to] += 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;
    }
}

/// Finding: when nine or more epochs are ready, a chosen-skin mint reverts with REVEAL_PENDING even though it carries
/// every secret: _settleReady stops after 8 epochs, _approval then sees the 9th ready epoch and reverts the whole
/// transaction, undoing the 8 settlements it just performed. One random mint per epoch (9 tokens) with no settle in
/// between is enough; every chosen-skin mint then fails until someone calls settle() separately.
/// Expected (fixed): a chosen-skin mint that supplies all pending secrets succeeds (settle more than 8 when the
/// secrets are supplied, or let the mint proceed once its own settlement made progress and nothing ready remains).
contract NineEpochs is Test {
    EmberEvoGenesisCandidate g;
    Embeo token;
    uint256 constant SIGNER_KEY = 0xA11CE;
    address signer = vm.addr(SIGNER_KEY);
    address minter = address(0xBEEF);
    uint256 start;
    bytes32[] x;

    function chain(bytes32 seed, uint256 n) internal pure returns (bytes32[] memory c) {
        c = new bytes32[](n + 1); c[n] = seed;
        for (uint256 i = n; i > 0; --i) c[i - 1] = keccak256(abi.encode(c[i]));
    }

    function setUp() public {
        vm.roll(1000);
        token = new Embeo();
        g = new EmberEvoGenesisCandidate(address(this), address(token), signer, 1e18, 1, "EEG");
        start = block.number;
        g.configureSnapshot(bytes32(0), 0, block.timestamp, block.timestamp);
        g.setRevealURIs("ipfs://pending", "ipfs://base/", true);
        x = chain(keccak256("signer seed"), 16);
        g.setRevealCommit(x[0]);
        g.setMintPaused(false);
        g.approvePaidPrice();
        token.mint(minter, 100e18);
        vm.prank(minter); token.approve(address(g), type(uint256).max);
    }

    function sign(bytes32 digest) internal view returns (bytes memory) {
        (uint8 v, bytes32 r, bytes32 s) = vm.sign(SIGNER_KEY, digest);
        return abi.encodePacked(r, s, v);
    }

    function domain() internal view returns (bytes32) {
        return keccak256(abi.encode(keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"),
            keccak256("EmberEvoGenesisCandidate"), keccak256("6"), block.chainid, address(g)));
    }

    function mintPaid(bytes32 orderId, uint8 choice, string memory uri, bytes32[] memory reveals) internal {
        bytes32 lookKey = keccak256("look");
        uint256 deadline = block.timestamp + 100;
        bytes32 appearance = keccak256(abi.encode(
            keccak256("Appearance(address wallet,bytes32 lookKey,uint8 choice,bytes32 uriHash,uint256 deadline,uint256 signerEpoch)"),
            minter, lookKey, choice, keccak256(bytes(uri)), deadline, g.signerEpoch()));
        EmberEvoGenesisCandidate.PaidAuthorization memory o = EmberEvoGenesisCandidate.PaidAuthorization(
            orderId, minter, lookKey, choice, keccak256(bytes(uri)), g.BURN_ADDRESS(), g.paidCost(), g.paidGoldCost(), deadline, g.signerEpoch());
        bytes32 paid = keccak256(abi.encode(
            keccak256("PaidAuthorization(bytes32 orderId,address wallet,bytes32 lookKey,uint8 choice,bytes32 uriHash,address recipient,uint256 embeoCost,uint256 goldCost,uint256 deadline,uint256 signerEpoch)"), o));
        bytes memory sig = sign(keccak256(abi.encodePacked("\x19\x01", domain(), appearance)));
        bytes memory gsig = sign(keccak256(abi.encodePacked("\x19\x01", domain(), paid)));
        address burn = g.BURN_ADDRESS(); uint256 cost = g.paidCost();
        vm.prank(minter);
        g.mintPaid(lookKey, choice, uri, deadline, sig, burn, cost, o, gsig, reveals);
    }

    function test_chosenMintWithAllNineSecretsSucceeds() public {
        bytes32[] memory none;
        // one random token in each of epochs 0..8, nobody settles in between
        for (uint256 e; e < 9; ++e) {
            vm.roll(start + e * 25 + 1);
            mintPaid(keccak256(abi.encode("order", e)), 3, "", none);
        }
        assertEq(g.randomEpochCount(), 9);
        // every one of the nine epochs is ready
        vm.roll(g.epochSeedBlock(8) + 1);
        (, , bool ready) = g.nextRevealEpoch();
        assertTrue(ready);

        // the chosen-skin minter supplies all nine committed secrets, in order
        bytes32[] memory reveals = new bytes32[](9);
        for (uint256 i; i < 9; ++i) reveals[i] = x[i + 1];

        // Expected: the mint settles the nine epochs and succeeds. Actual: REVEAL_PENDING after settling eight.
        mintPaid(keccak256("chosen"), 0, "ipfs://base/look/0", reveals);
        assertEq(g.nextRevealIndex(), 9);
        assertEq(g.pendingRandom(), 0);
        assertEq(g.totalMinted(), 10);
    }
}
```

### 4. Low: Signer releases an epoch's secret with zero confirmations on the seed block, and the contract still accepts chosen-skin mints inside the seed block itself: a one-block reorg lets the replacement propo

`Submission1/offchain/signer/reveal-chain.mjs:39`

```
  if(Number(seedBlock)>latest)break;         // seed block not mined yet: its secret stays with the signer
```

The commit-reveal argument (README section 5, reveal-chain.mjs header) is that whoever produces the seed block cannot know the secret and whoever knows the secret cannot change the block. readyReveals() breaks only when seedBlock > latest, so it hands out x[k] the moment the seed block is the chain head, with zero confirmations. On chain, readiness and the REVEAL_PENDING freeze use block.number > epochSeedBlock(e) (contract line 316), so transactions included in the seed block itself may still change skin counts (verified: a chosen mint at block.number == seedBlock succeeds). If the seed block S is reorged out after x[k] is public (from the API or a pending settle/mint in the mempool), the proposer of the replacement block at height S knows x[k], can evaluate keccak256(abi.encode(hash(S'), x[k], e, this)) for every candidate block it builds, can grind S' (ordering, extra data, included transactions) toward a preferred draw for a token it holds in epoch e, and can include its own chosen-skin mints in S' to move the remaining[] weights. Single-slot reorgs by the next proposer occur on mainnet; the attacker needs to be the proposer of that slot and hold a pending random token, so likelihood is low and the payoff is one rare skin, hence Low. Reported by economics, permissions and math; merged. Fix: in readyReveals require seedBlock + K <= latest (K = 2, or use the 'safe' tag) before releasing a secret; in the contract start the freeze in the seed block itself (block.number >= epochSeedBlock(e) in the REVEAL_PENDING check) and consider matching readiness to seedBlock + K so chosen mints are not blocked longer than the signer waits; document the residual risk in README 8.2.

**Reproduction**

Off-chain: epoch e has a random token, S = epochSeedBlock(e); the signer's provider reports latest == S; readyReveals (line 39: Number(seedBlock) > latest is false) returns x[k]. On-chain part, test_chosenMintInSeedBlockAllowed in test/scratch/Judge.t.sol: one random token in epoch 0, vm.roll(epochSeedBlock(0)), nextRevealEpoch() returns seedKnown == false and a chosen mintPaid(choice 0) succeeds with skinCount[0] == 1; one block later seedKnown == true. Expected: the hash of S is fixed before anyone knows x[k] and nothing can change the counts in block S. Actual: x[k] is public while S can still be replaced, and a replacement block S' can both grind its hash and include chosen-skin mints.

### 5. Low: Expired seed block degrades the seed to secret-only: the signer knows every such outcome in advance, a secret holder gets a binary choice by waiting, and on any chain without the EIP-2935 contract thi

`Submission1/contracts/EmberEvoGenesisCandidate.sol:455`

```
        if (ok && r.length == 32) h = abi.decode(r, (bytes32));
```

_blockHash returns blockhash(n) for the last 256 blocks, then the EIP-2935 history contract, else zero. A staticcall to an address with no code succeeds with empty return data, so on a chain without the Prague history contract (the Hardhat EDR Cancun chain the test suite uses, Foundry's default, some L2s and testnets) h == 0 for every seed block older than 256 blocks; on Sepolia and mainnet after 8,191 blocks. _seal then uses seed = keccak256(abi.encode(0, secret, e, this)), fully determined by the committed secret, which the signer service knows at deployment for every epoch. README 8.2 calls this 'one extra option for the signer'. It is wider than that: (a) the signer can tell a colluding minter before it mints which token id in which epoch will draw molten gold on the expiry path (remaining[] is frozen by REVEAL_PENDING while the epoch waits), at the cost of withholding the secret for ~27 h during which chosen mints revert (visible, not prevented); (b) GET /v1/reveals hands the secret to anyone once the seed block is mined and nothing auto-settles, so if no other party settles for ~27 h a minter holding a random token in that epoch picks the better of two draws by settling before or after the window; (c) none of the 78 tests exercise the history-contract path. Reported by flow, permissions and math (two entries); merged. Fix: when h == 0 in _seal do not seal; re-arm the epoch (epochRearmBlock[e] = block.number + 1, emit EpochRearmed) so an expired epoch always takes a hash unknown when its secret was fixed (anyone can settle two blocks later), or have the signer/keeper submit settle() itself before the window closes; require BLOCK_HASH_HISTORY.code.length != 0 in the constructor and add a test with the history contract etched.

**Reproduction**

test_expiredSeedBlockSecretOnly in test/scratch/Judge.t.sol (passes on the current code, demonstrating the behaviour): one random token in epoch 0; vm.roll(epochSeedBlock(0) + 300); BLOCK_HASH_HISTORY.code.length == 0; sealEpoch(0, x1). Expected per the commit-reveal intent: the seed depends on a block hash unknown when the chain was committed. Actual: epochSeed(0) == keccak256(abi.encode(bytes32(0), x1, uint256(0), address(g))), computable by the signer at deployment; EpochSealed emitted with blockHash == 0. On mainnet the same state is reached by waiting until block.number > seedBlock + 8191; the secret holder chooses between the pre-expiry draw (settle now) and this one (settle later).

### 6. Low: setRevealCommit accepts a commitment that was already opened (for example the original anchor x0), so a re-armed epoch can be sealed by anyone with a secret that is already public

`Submission1/contracts/EmberEvoGenesisCandidate.sol:214`

```
        require(commit != bytes32(0) && commit != revealCommit, "REVEAL_COMMIT_INVALID");
```

Every secret that sealed an epoch is public (calldata, EpochSealed event) and the original anchor x[0] is in RevealCommitSet. setRevealCommit rejects only zero and the current value. If the owner re-runs the setup tooling with the previous REVEAL_SEED (deploy-testnet.mjs / new-reveal-seed.mjs have no on-chain guard) or sets any earlier chain value, the re-armed epoch needs a preimage everyone already has: the signer is no longer the gate, the re-arm block's proposer knows the secret in advance and can grind the hash, and any user can seal the epoch as soon as the re-arm block is mined. The contract can refuse this cheaply. Reported by math; the economics specialist noted the same weakness inside the re-roll finding. Fix: record every commitment the contract has held or opened (mapping(bytes32 => bool) seenCommit, set in setRevealCommit and in _seal for the consumed secret) and require !seenCommit[commit] in setRevealCommit.

**Reproduction**

test_setRevealCommitAcceptsOpenedCommitment in test/scratch/Judge.t.sol (passes on the current code, demonstrating the behaviour): mint one random token in epoch 0, settle it with x1 (x1 now public, revealCommit == x1), mint another random token in epoch 2, roll past its seed block, setMintPaused(true), setRevealCommit(x0). Expected: REVEAL_COMMIT_INVALID (x0 was already opened). Actual: accepted, epochRearmBlock(2) == block.number + 1, and two blocks later sealEpoch(2, x1) succeeds with the public secret x1 (epochSeed(2) != 0).

### 7. Low: setRevealCommit re-arms every ready unsealed epoch in one unbounded loop (~28.7k gas each): with enough one-token epochs the lost-chain recovery no longer fits in a block

`Submission1/contracts/EmberEvoGenesisCandidate.sol:218`

```
        for (uint256 i = nextRevealIndex; i < randomEpochs.length; ++i) {
```

Random mints are allowed while a reveal is pending (REVEAL_PENDING only stops chosen-skin mints), so while the signer is not publishing secrets (service down, key or seed lost) every 25-block epoch can still gain random tokens, each adding one unsealed epoch to randomEpochs. setRevealCommit, the only recovery for a lost or compromised chain, then re-arms all of them in one call (SLOADs of randomEpochs[i], epochSeed[e], epochRearmBlock[e], one SSTORE and one event per epoch). Measured: 8,623,569 gas for 300 ready one-token epochs, about 28.7k per epoch; at the 1,288-token maximum (one random token per epoch, about 5.4 days of a silent signer) the call needs about 37M gas, above a 36M block gas limit, so the owner can never replace the chain and every pending random token stays unrevealed (skin 0, pendingURI) for good. Minters are not otherwise harmed, which keeps this Low, but it defeats the designed recovery exactly when it is needed. Reported by economics. Fix: do not re-arm in a loop. Store a single rearmFromBlock = block.number + 1 together with rearmFromIndex = nextRevealIndex and have epochSeedBlock(e) return max(natural seed block, rearmFromBlock) for unsealed epochs at or after that index; or bound the loop with a persisted cursor and let the owner call setRevealCommit repeatedly.

**Reproduction**

test_gasSetRevealCommitManyEpochs in test/scratch/Judge.t.sol: one random mint per epoch for 300 epochs with no settle, roll past the last seed block, setMintPaused(true), setRevealCommit(keccak256('new')). Measured 8,623,569 gas (log 'gas setRevealCommit with 300 ready epochs'); epochRearmBlock(299) == block.number + 1. Expected: recovery cost independent of the number of pending epochs. Actual: linear, about 28.7k per epoch, exceeding the block gas limit beyond roughly 1,250 pending epochs.

### 8. Info: Transient one-paid-mint-per-transaction marker is keyed on msg.sender only: N contract wallets (or a 4337 bundle) can do N paid mints in one transaction, and a free+paid pair by one wallet is not limi

`Submission1/contracts/EmberEvoGenesisCandidate.sol:376`

```
        bytes32 slot = keccak256(abi.encode(PAID_TX_SLOT_PREFIX, msg.sender));
```

The marker slot is derived from msg.sender and never cleared, so inside one transaction the same caller cannot mintPaid twice (also across sequential calls from a Safe batch or an EIP-7702 EOA; a smart-wallet batch with two paid mints reverts as a whole). It does not stop (a) mintFree followed by mintPaid by the same wallet in one batch (two GenesisMinted logs in one receipt, which the GOLD reader rejects, see the reader finding); (b) one outer transaction driving N distinct contract wallets each holding its own signer-issued order (the paid signer issues orders to any address, no EOA or allowlist check); (c) ERC-4337 bundles, where every userOp has a different msg.sender. The README says there is no per-wallet total limit across transactions, so the marker only shapes transaction composition; it must not be relied on as a rate or supply limit, and the off-chain reader must not assume one mint per receipt. Answers README 8.7. Reported by economics and flow; merged. No contract change needed if the limit is understood as per-sender; otherwise key the slot on a constant (one paid mint per transaction for everyone) and accept that 4337 bundles then serialize paid mints, and state the batching behaviour for wallet integrations.

**Reproduction**

test_twoWalletsTwoPaidMintsOneTransaction in test/scratch/Judge.t.sol: two minimal contract wallets A and B (forwarding call + onERC721Received) with EMBEO and approvals and one signer-issued order each; from the test contract call A.exec(mintPaid(orderA)) then B.exec(mintPaid(orderB)) in one transaction. Actual: both succeed, totalMinted == 2; a second mintPaid through A in the same transaction reverts ONE_PAID_MINT_PER_TRANSACTION. Expected per the README 8.7 wording 'one paid mint per transaction': only one.

### 9. Info: Appearance signature has no nonce and is never consumed: the same signature is accepted by mintFree and mintPaid and can be reused with every later PaidAuthorization of the wallet for the same look un

`Submission1/contracts/EmberEvoGenesisCandidate.sol:342`

```
        bytes32 digest = _hashTypedDataV4(keccak256(abi.encode(APPEARANCE_TYPEHASH, msg.sender, lookKey, choice, keccak256(bytes(uri)), deadline, signerEpoch)));
```

The Appearance struct binds wallet, lookKey, choice, uriHash, deadline, signerEpoch, chain id and contract, but has no nonce or route byte and the digest is not marked used. Today this is harmless: a second mintFree by the same wallet is stopped by freeClaimed, and a second mintPaid needs a fresh PaidAuthorization (orderId consumed once). But an Appearance issued for a paid order is also valid for mintFree, and for every later PaidAuthorization of that wallet with the same look/uri until the deadline. If either gate is relaxed later (several free claims per wallet, an Appearance-only paid path) the missing nonce becomes a replay. Reported by flow. Fix: add a route byte (free/paid) and a per-wallet nonce to Appearance, or mark the digest used in mintFree.

**Reproduction**

test_appearanceReusedAcrossRoutes in test/scratch/Judge.t.sol: snapshot with wallet W (single-leaf tree, root == leaf), one Appearance A = (W, look L, choice 0, uri 'u', deadline D, epoch 1). W calls mintFree(L, 0, 'u', D, A, [], []) -> succeeds. W then calls mintPaid(L, 0, 'u', D, A, BURN, cost, order O1, goldSig, []) with the same A -> succeeds; totalMinted == 2. Expected per README property 3 (one approval, one mint): the second use of A is refused. Actual: one Appearance, two mints, bounded only by freeClaimed and the paid order.

### 10. Info: GOLD book stores the Genesis EIP-712 domain as version '5' while the deployed contract and the signer use version '6'

`Submission1/offchain/gold-book/server/genesis-gold.ts:13`

```
export const GENESIS_R5_DOMAIN={name:'EmberEvoGenesisCandidate',version:'5'} as const;
```

genesisAuthDocument writes domain {name, version:'5', chainId, verifyingContract} into every stored hold (auth_json). The deployed contract's EIP712 domain is ('EmberEvoGenesisCandidate', '6') (constructor, contract line 148) and the signer's DOMAIN_VERSION is '6' (free-approval.mjs line 7). The stored document is only used for byte-identical retry comparison and binding, so there is no live mismatch, but anything that later recomputes the PaidAuthorization digest from the stored document (audit replay, dispute tooling) derives a digest the contract never verified, and the mainnet domain change announced in README 7 would leave the constant stale again. Reported by economics, flow and permissions; merged. Fix: set the version to '6' or source it from the GenesisDeployment registry entry next to codeHash, and bump GENESIS_R5_AUTH_SCHEMA.

**Reproduction**

Compute _hashTypedDataV4(PaidAuthorization) for any stored order with the stored domain (version '5') and compare with the digest the contract verified (version '6', eip712Domain() on 0x8ce67008570ED41F64e4Be149ecAA66d1aBE2efa): they differ. Expected: identical. Confirmed by reading genesis-gold.ts line 13 against the contract constructor at line 148 and free-approval.mjs line 7.

### 11. Info: Reveal chain length 2,048 is below SUPPLY_CEILING 2,222 and nothing ties the two: a supply configured above 2,048 could exhaust the committed secrets

`Submission1/offchain/signer/reveal-chain.mjs:10`

```
export const REVEAL_CHAIN_LENGTH=2048;   // >= maxSupply: at most one epoch per random token
```

The signer derives exactly 2,048 secrets and the comment claims this is >= maxSupply. The contract allows configureSupply up to SUPPLY_CEILING = 2222 (line 36, checked in _setSupply at line 187) before minting opens, and each epoch holding at least one random token consumes one secret, so more than 2,048 random epochs is reachable with a larger supply. After the 2,048th seal chain.next(commit) returns null, readyReveals throws REVEAL_CHAIN_MISMATCH (503), the next ready epoch cannot be sealed and every chosen-skin mint reverts with REVEAL_PENDING until the owner pauses and replaces the chain (re-rolling the stuck epoch). With the deployed caps (1,288, fixed by the first mint) this is unreachable, so informational. Reported by math. Fix: have the setup tooling assert REVEAL_CHAIN_LENGTH >= maxSupply after lockSupply, or store the chain length on chain next to the commitment and check it in configureSupply.

**Reproduction**

State: before opening, owner calls configureSupply([1702,168,168,68,68,48]) (sum 2222, accepted by _setSupply); minting then produces at least one random mint in 2,049 distinct epochs. Expected (reveal-chain.mjs header): the chain covers every epoch. Actual: at the 2,049th ready epoch readyReveals returns 503 REVEAL_CHAIN_MISMATCH and nobody can seal; chosen mints revert with REVEAL_PENDING until setRevealCommit. Verified by reading reveal-chain.mjs lines 10, 28, 41-42 and the contract's SUPPLY_CEILING.

### 12. Info: Gas bound measured: a chosen-skin mint that settles 8 epochs x 25 tokens costs about 3.27M gas, and that cost always falls on the first chosen-skin minter after a backlog (random minters never pay)

`Submission1/contracts/EmberEvoGenesisCandidate.sol:427`

```
    function _settleReady(bytes32[] calldata reveals) private returns (bool progressed) {
```

Answer to brief question 5 and README 8.4. Measured in Foundry (solc 0.8.38, viaIR, 200 runs, cancun, mock 18-decimal token): a chosen paid mint that seals and reveals 8 full epochs of 25 random tokens costs 3,274,698 gas versus 220,823 for a plain chosen mint, about 380k per full epoch (README 10 quotes 650-690k per epoch on EDR/Osaka rules). The bound holds and fits a 36M block, so no liveness defect. The cost allocation is asymmetric: a random minter may always pass an empty reveals array and never pays for settlement, so a griefer who fills epochs with random mints (200 paid mints for the full 3.3M, or one random mint per epoch so that every chosen mint signed before a 25-block boundary and included after it without the new secret reverts with REVEAL_PENDING and must be re-sent) pushes the cost onto chosen-skin minters. Not economical as an attack; a cost and UX note. Reported by economics, flow and math; merged. Suggested: have the page call settle() separately when more than one epoch is ready so the minter is not surprised by a 10x gas estimate; document the 25-block inclusion window for chosen mints; re-measure under the target network's gas schedule before mainnet.

**Reproduction**

test_gasSettle8x25 in test/scratch/Judge.t.sol: 25 random paid mints from distinct wallets in each of epochs 0..7 with reveals = [], roll to epochSeedBlock(7)+1, then a chosen mintPaid(choice 0, reveals = [x1..x8]). Logged: 'gas chosen mint settling 8x25: 3274698', afterwards pendingRandom == 0 and nextRevealIndex == 8; a following plain chosen mint logs 220823. A chosen mint with reveals = [] in the same state reverts REVEAL_PENDING.

### 13. Info: Random-skin odds are path dependent: chosen-skin mints before an epoch's seed block move every pending random token's probabilities (expected-value steering, not outcome steering)

`Submission1/contracts/EmberEvoGenesisCandidate.sol:481`

```
            for (uint256 c; c < SKIN_COUNT; ++c) { count[c] = skinCount[c]; remaining[c] = skinCaps[c] - count[c]; total += remaining[c]; }
```

Each draw is weighted by remaining capacity (skinCaps[c] - skinCount[c]) at reveal time. Chosen mints consume only skins 0-2, so every chosen mint raises the probability of the random-only skins (pink, orange, gold) for all pending random tokens. The REVEAL_PENDING freeze stops this only once the seed block is mined; before that any minter may shift the weights. Because the seed is unknown before the freeze this cannot target a specific outcome, so README property 4 holds in its strict sense (answer to brief question 1, 'chosen-skin mints around a reveal'), but the odds a random minter faces are not fixed at mint time and can be moved by other participants. Reported by math. Fix (design choice): document the weighting on the mint page, or draw from fixed cap proportions and fall back to remaining capacity only when the drawn skin is sold out.

**Reproduction**

With production caps, before any chosen mint P(gold) for a random token = 48/1288 = 3.73%. If all 768 green, 168 ice and 168 purple have been minted as chosen skins (allowed; SKIN_SOLD_OUT fires only at the cap), the remaining pool is 184 for 184 random tokens: P(gold) = 48/184 = 26.1%. Follows directly from lines 481-487; no exploit.

### 14. Info: reveals[] matching is order-sensitive: the right secrets in the wrong order lose the later secret and a chosen mint reverts with REVEAL_PENDING

`Submission1/contracts/EmberEvoGenesisCandidate.sol:435`

```
                while (used < reveals.length && keccak256(abi.encode(reveals[used])) != commit) ++used;
```

The scan cursor `used` only moves forward. With two ready epochs and reveals = [x2, x1], the first scan skips x2 (its hash is x1, not the current commit x0) and consumes x1; the second epoch needs x2 but the cursor is already past it, so _settleReady returns and a chosen mint reverts although it carried every needed secret. The signer returns secrets in order, so this is a robustness note for third-party callers of settle() who build the list from events or several API responses. Reported by math. Fix: restart the scan from index 0 for each epoch (reveals has at most 8 entries) or match the whole array against the current commit.

**Reproduction**

test_outOfOrderRevealsLoseSecret in test/scratch/Judge.t.sol: one random token in epoch 0 and one in epoch 1, both seed blocks mined, chosen mintPaid with reveals = [x2, x1]. Expected: both epochs sealed (both secrets present). Actual: the call reverts with REVEAL_PENDING and nextRevealIndex stays 0.

---

Judge's submission `9d11a56700669e2550572c120f8315aa2cd2fbfee19f087b5d6acd020cba44b5`, 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.
