# Audit report

> Review Submission11/ only (read Submission11/AUDIT_README.md first; Submission10 to 5 AUDIT_README are the reference for everything unchanged). Not deployed. This is the remediation of audit 90ae2f92 (4M 3L 2I, all in the deployment tools) on the build intended for Ethereum mainnet. The contract changes only in one @notice comment; runtime and creation code are identical to Submission10 without the trailing CBOR metadata (runtime 24,427 bytes). The tools changed a lot: tools/deploy-mainnet.ps1, deploy-mainnet.mjs, safe-batch.mjs, new tool-errors.mjs; the diff is in build/tools-and-tests.Submission10-to-11.diff. Section 2 maps each finding to its fix; sections 6 and 8 say what is accepted and what is unverified.
>
> 1. Resume: can any order of stops, resends, lagging reads and a killed process deploy a second contract, skip a step, record a reverted step as done, or leave a partial record that misleads the next run?
> 2. Nonce and fees: fixed nonces, the outbid logic, stableNonce. Can a resend be stuck or use a nonce since spent? Does the gas floor (estimate x 1.2, at least 300,000) cause a new failure?
> 3. Lock move: the URIs now lock in the Safe's open batch, not in the deployer's second transaction. Does this add a way to open or mint with URIs that can still change? Do the batch's order and checks hold when it executes late?
> 4. State checks: can a stale read pass a check it should fail, in the read-backs or in safe-batch chainState?
> 5. Secrets: can the deployer key, RPC URL, signer key or reveal seed reach stdout, stderr, the partial or deployment record, or a child process that does not need it? What can redact miss?
> 6. Builder file: does it match what Transaction Builder accepts and shows? Is calldataOf a fair copy of how the app encodes?
> 7. Paid root check: does SNAPSHOT_MISMATCH come at the right point in prepare, and could it stop a valid order?
>
> Report severity, reproduction and fix. Not in scope: artwork, the Mint web page, World 3D.

| | |
|---|---|
| Repository | https://github.com/tungweb3/ember-evo-genesis-review.git |
| Commit | `5fe28808c7f4bd324e49183c252767339762108b` |
| Job | `358f7f40-4d24-4088-9cdf-c31064d035fa` |
| Judged | 2026-11-03 05:10 UTC |
| Findings | 1 medium · 3 low · 4 info |

Four agents audited the code as it is at `5fe2880`, 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: Resume reads the nonce before the record's last block is confirmed visible, never re-derives it, and a rejected send keeps the same fee: two stale nonce reads stall the run and the final checkpoint er

`Submission11/tools/deploy-mainnet.mjs:115`

```
 let nonce=await stableNonce(),contract,planNonce;
```

Merged from audit_flow (nonce before settled) and audit_economics (last=fee only after a successful send; nonce never re-read). On --resume the nonce is taken at line 115 by stableNonce(), which accepts any two equal eth_getTransactionCount answers. The wait that makes every other read at least as new as the record, settled() on the last recorded step at line 203, runs after it, so the nonce is the one read the record's block does not protect: a load-balanced RPC whose answering node has not seen the last recorded block returns the same old count twice. send() never reads the nonce again, and the record's own lower bound (planNonce + steps.length) is not applied. Every attempt then sends at a spent nonce and fails with NONCE_EXPIRED; because hashes stays empty, line 170 writes pending:{name,nonce:<stale>,hashes:[],fee:null}, overwriting the seed (hashes and fee of the transaction the killed run really left waiting, line 124/195). The next resume no longer knows that transaction: with seed.fee null, fees(null) prices the replacement at the market fee, the node answers REPLACEMENT_UNDERPRICED while the original waits in the mempool, and because `last=fee` at line 156 is only assigned after a successful sendTransaction, attempts 2..8 recompute the identical fee and are refused the same way. The outbid logic added by the remediation (README section 3, 'A resume did not outbid a transaction the stopped run left waiting') is silently lost. No second contract, no skipped step and no reverted step recorded as done (fixed nonces and the chain's own guards hold), but the README section 5 question 'leave a partial record that misleads the next run' has the answer yes, and the run is stuck until the waiting transaction lands, expires or the market fee rises above 1.1x its fee. Fix: (1) on resume, after settled() on the last recorded step, derive the nonce as Math.max(await stableNonce(), resume.planNonce + resume.steps.length) and only then start sending; (2) on NONCE_EXPIRED re-run stableNonce() before the next attempt; (3) set last=fee before sendTransaction (or on REPLACEMENT_UNDERPRICED) so every attempt bumps 1.25x on the previous attempt; (4) at line 170 keep the seed's hashes and fee when the step's own hashes are empty, so a failed resume never thins the record.

**Reproduction**

State: partial record {planNonce:0, steps:[deploy@0 block 98, setRevealURIs@1 block 99, setRevealCommit@2 block 100], pending:{name:'addPaymentToken(USDC, 1600 GOLD)',nonce:3,hashes:[T1],fee:{maxFeePerGas:'30000000000',maxPriorityFeePerGas:'2000000000'}}}; T1 is still in the mempool (the run was killed while waiting for its receipt). Run deploy-mainnet.mjs --resume through an RPC whose eth_getTransactionCount is answered twice by a node still at block 99 (count 2) while every other call is current (the test suite's own lagger, tests/contract/deploy-mainnet-cli.test.mjs line 46, answers count-1 on 12% of reads, so both reads stale is about 1.4% of resumes). Expected: nonce >= 3 (the record proves three steps landed), the seed matches, the resend goes out at nonce 3 with at least 37.5 gwei / 2.5 gwei. Actual, run with harness /tmp/imdh/f-nonce.mjs (the unmodified deployMainnet from Submission11/tools/deploy-mainnet.mjs, a real ethers 6.15 Wallet and JsonRpcProvider over HTTP, a scripted JSON-RPC node; base fee 10 gwei, tip 1 gwei): run 1 output 'eth_getTransactionCount #1 -> 2 (STALE)', '#2 -> 2 (STALE)', then eight 'eth_sendRawTransaction nonce 2 -> REJECTED nonce too low', sends=[{nonce:2,maxFeePerGas:21.0,tip:1.0} x8], error NONCE_EXPIRED, last checkpoint pending={name:'addPaymentToken(USDC, 1600 GOLD)',nonce:2,hashes:[],fee:null}: T1 and its 30/2 gwei fee are gone from the partial record. Run 2, resuming from that record with the nonce fresh (3) and T1 still waiting: eight 'eth_sendRawTransaction nonce 3 at 21.0/1.0 gwei -> REJECTED replacement transaction underpriced', all eight sends identical (21.0/1.0), error REPLACEMENT_UNDERPRICED, pending again {nonce:3,hashes:[],fee:null}. Control (/tmp/imdh/f-gate.mjs case a): the same resume with the original record and a fresh nonce sends once at nonce 3 with 37.5/2.5 gwei and is accepted, so the outbid works until the stale nonce erases the seed.

### 2. Low: Resume waits for the last recorded step but not for the pending one, and each 'already done?' gate is one eth_call retried only on error: a lagging node makes the tool resend a step the chain already

`Submission11/tools/deploy-mainnet.mjs:203`

```
  if(steps.length)await settled({hash:steps.at(-1).hash,blockNumber:steps.at(-1).blockNumber});   // no read below may be older than the record
```

Merged from audit_math, audit_economics, audit_flow and audit_permissions (same mechanism, same fix). On --resume settled() at line 203 proves only that the block of the last entry of steps[] is visible over visibleReads reads. The transaction a killed run left in flight is recorded in `pending`, not in steps[], so its block is never waited for. Each gating read at lines 207, 209, 216, 218 and 220 is a single read through retry(), which repeats only when the read throws (line 104): a well-formed stale answer (0n, 0x0, false, old string) is accepted at once. A load-balanced RPC can therefore answer the gate from a node behind the pending transaction's block (README section 5 item 4 names this model) and the tool resends a finished step at the next nonce: setRevealURIs, approvePaidPrice and transferOwnership are idempotent (one wasted transaction); addPaymentToken reverts on chain with TOKEN_IDENTITY_REQUIRED (contract line 417-418, paymentTokens[token] must be 0) and setRevealCommit reverts with REVEAL_COMMIT_INVALID (_heldBefore, line 312) up to three times because of revertRetries:2. The revert reaches the chain whenever eth_estimateGas is also served by the lagging node (README section 8 says the lag model does not cover eth_estimateGas); otherwise every attempt throws at the estimate and the run stops after 8 attempts (~1 min) with that error. In both cases the seed is dropped when its nonce differs (line 151) and line 170 rewrites pending with hashes:[], so the hash that did land is lost from the record and the finished record has no steps[] entry or block for that step (the section 6 case, now reachable by a single lagging read). If the seeded pending step has a lower fee, the resend of a different step at the same nonce (audit_flow's variant) replaces it. No second contract and no skipped step can follow (a stale read only ever says 'not done'), so this is low. The package's lagger (tests/contract/deploy-mainnet-cli.test.mjs line 47) answers a lagging eth_call with '0x', which ethers turns into a BAD_DATA throw that retry() absorbs, so the suite never exercises a stale-but-valid answer. Fix: on resume, when resume.pending exists, poll getTransactionReceipt over pending.hashes (firstReceipt) and settled() on the receipt before any gate read, pushing it into steps[] when status is 1; require each gate to agree over visibleReads consecutive reads, or pin the gate reads to a blockTag at or above the record's last block so a lagging node throws and retry() does its job; keep the seed's hashes in the resent step's hashes.

**Reproduction**

State: a run was killed after send('addPaymentToken(USDC, 1600 GOLD)') broadcast h4 at nonce 3 and checkpointed pending:{name,nonce:3,hashes:[h4],fee} but before its receipt; h4 is mined in block 103; the partial record's steps[] ends at setRevealCommit in block 100. Resume through an RPC where eth_getTransactionCount answers 4, eth_blockNumber and the receipt of the setRevealCommit step are current (settled passes), and the single eth_call paymentTokens(USDC) at line 216 is answered by a node at block 102 with 0. Expected: nothing is sent, the step is recorded from h4's receipt. Actual, run with harness /tmp/imdh/f-gate.mjs case (b) (the unmodified deployMainnet, real ethers Wallet + JsonRpcProvider, scripted node whose eth_estimateGas is also served by the lagging node): 'paymentTokens(USDC) read #1 -> 0 (STALE: node at block 102, h4 landed in 103)', then 'ACCEPTED nonce 4 at 21.0/1.0 gwei -> mined with status 0 (reverted: TOKEN_IDENTITY_REQUIRED)', sends=[{nonce:4,...}], error 'TX_FAILED addPaymentToken(USDC, 1600 GOLD)', last checkpoint: pending undefined and steps still [deploy@98, setRevealURIs@99, setRevealCommit@100]: h4 is gone from the record and the step has no entry. When the estimate is current instead, all 8 attempts throw CALL_EXCEPTION(TOKEN_IDENTITY_REQUIRED) and line 170 writes pending:{...,hashes:[]}. Same path with pending = setRevealCommit (gate at line 209 returning 0x0) sends up to three reverting transactions. To see it in the suite: make the lagger's eth_call branch (test line 47) answer the state of blockNumber-1 instead of '0x' and rerun 'the whole run through a lagging RPC'.

### 3. Low: safe-batch: chainState takes one unpinned read per value and holds() returns on the first check that passes, so one stale read passes a check that fresh state fails (CHAIN_DIFFERS_FROM_RECORD, NOT_PEN

`Submission11/tools/safe-batch.mjs:160`

```
  const holds=async check=>{let last;for(let i=0;i<10;i++){try{return check(await chainState(provider,d));}catch(e){last=e;await new Promise(r=>setTimeout(r,3000));}}throw last;};
```

Merged from audit_math, audit_flow and audit_permissions. The comment on line 159 says a refusal must hold for a while before it counts, but the loop is one-directional: a check that passes is accepted on its first evaluation, and a fresh refusal is discarded as soon as any of the next nine attempts, three seconds apart, passes. chainState (line 84-95) reads about 19 values with steady(), which retries only when the call throws (line 82), with no blockTag and no block-number check against the record, so each value may come from a different node at a different height and a single lagging value is enough. The default reader is a public load-balanced RPC (line 35). The accept/open checks exist to catch what the deployer key did after the record (audit 90ae2f92 finding 6), and such a change is by definition the newest state, the one most likely missing from a lagging node, so the stale answer is exactly the one that equals the record and passes. In accept mode the .safe-accept.json is written for a contract whose pendingSigner, URIs, caps or commitment the deployer key has changed since the record; in open mode a second open file can be written after the batch executed. The harm is bounded: the contract's own guards and the all-or-nothing MultiSend make a batch built on stale state revert on chain (setMintPaused(false) refuses a pending signer, configureSnapshot refuses twice, the open batch rewrites the URIs), so the cost is a misleading 'wrote ...' / exit 0 at the point this tool was added to detect, and a Safe transaction signed by two owners that fails. Fix: read eth_blockNumber first, require it to be at least the record's last step block and identical over two reads, pass that blockTag to every read in chainState, and make holds() require the same passing result from several consecutive chainState reads (as settled() does with visibleReads=3) while still retrying refusals.

**Reproduction**

Harness /tmp/imdh/f-safe-stale.mjs runs the unmodified CLI `node safe-batch.mjs accept <record>` with READ_RPC_URL pointing at a scripted JSON-RPC node. State: record with signer S, pendingSigner expected 0x0, pendingOwner = Safe, owner = deployer. After the record the deployer key called proposeAppearanceSigner(R). The node answers pendingAppearanceSigner() = R on the first chainState pass (fresh) and 0x0 on the second (one backend a block behind); every other read is current. Expected: 'CHAIN_DIFFERS_FROM_RECORD pendingSigner' and no file. Actual output: 'node: pendingAppearanceSigner read #1 -> R (fresh)', 'read #2 -> 0x0 (STALE, one block behind)', 'wrote /tmp/imdh/outA/1-1762000000.safe-accept.json', exit 0, file present (attempt 1 refused, attempt 2 passed and was accepted at once). The same single-read path applies to pendingURI/revealBaseURI (deployer re-set them), skinCap, revealCommit, and in open mode to snapshotConfigured/mintPaused (a second open file after the batch executed).

### 4. Low: Opening time is not bounded to uint40 on the ISO path: with --far-opening a six-digit ISO year writes an open batch whose configureSnapshot reverts SNAPSHOT_TIME when the Safe executes it

`Submission11/tools/safe-batch.mjs:38`

```
 const t=Date.parse(v);if(!Number.isFinite(t))throw Error('bad date '+v);return Math.floor(t/1000);};
```

From audit_math, reproduced. unix() caps a numeric input at exactly 10 digits (<= 9,999,999,999 < 2^40) but the ISO path returns Math.floor(Date.parse(v)/1000) with no upper bound, and openBatch (line 63-79) checks only start >= now+6h, start >= takenAt and, without --far-opening, start <= now+60d. The contract requires start <= type(uint40).max = 1,099,511,627,775 (contracts/EmberEvoGenesisCandidate.sol line 291). JavaScript accepts expanded ISO years up to +275760, so with --far-opening a start such as '+040000-01-01T00:00:00Z' passes every local check, the owner is asked to confirm '+040000-01-01T00:00 <count>' and .safe-open.json is written; the batch fails only when the Safe executes it, after two signatures. No funds or state at risk (the MultiSend reverts as a whole), but audit 90ae2f92 finding 3 asked this tool to refuse an impossible opening time locally. Fix: in openBatch add if(start>1099511627775)throw Error('the opening time does not fit uint40'), or bound the ISO path in unix() to 10-digit seconds like the numeric path.

**Reproduction**

Harness /tmp/imdh/f-uint40.mjs imports unix and openBatch from the unmodified safe-batch.mjs with d={maxSupply:'1688',pendingURI,metadataBase,safe,contract,chainId:1}, wallets=[one address], takenAt=1761900000, now=1762000000, farOpening=true. Input start='+040000-01-01T00:00:00Z': unix() returns 1200110860800 (> 1099511627775) and openBatch returns a file whose configureSnapshot 'start' input is '1200110860800'. Expected: the tool throws. Actual output: '+040000-01-01T00:00:00Z -> unix()=1200110860800 fitsUint40=false | file returned, configureSnapshot start=1200110860800'; on chain configureSnapshot(root,count,takenAt,1200110860800) reverts SNAPSHOT_TIME. Control: '9999999999 -> unix()=9999999999 fitsUint40=true | file returned' (the 10-digit cap works on the numeric path only).

### 5. Info: When the RPC refuses the PaymentTokenAdded log range, checkRecord treats the extra-token check as passed and the Safe file is still written

`Submission11/tools/safe-batch.mjs:104`

```
 if(s.extraTokens!==null&&s.extraTokens!=='1')bad.push('extraTokens');
```

From audit_permissions, reproduced. chainState turns a failed queryFilter('PaymentTokenAdded') into extraTokens:null plus a console.warn (lines 91-92), and checkRecord passes null. In accept mode this is the only check that would show a second payment token the deployer key added during its window (the contract has no public count, a token can never be removed, and its GOLD price is whatever the caller chose). The warning goes to stderr once per holds() iteration while the decision (write the file, print 'wrote ...', exit 0) is the same as when the check passed, so an operator reading the last line hands the Safe an acceptance without the check having run. README section 3 states the intention that a refused range 'must not hide that it went unchecked'; the output hides it from anyone who reads only the outcome. Fix: make extraTokens===null a refusal (LOGS_UNAVAILABLE) in accept and open, with an explicit flag such as --logs-checked-on-etherscan to proceed; query the range as fromBlock=d.startBlock to a toBlock read once, in chunks if needed.

**Reproduction**

Harness /tmp/imdh/f-safe-stale.mjs (second case): the scripted node answers eth_getLogs with a JSON-RPC error (-32005 'query returned more than 10000 results') while every state read is current and matches the record. Run `node safe-batch.mjs accept <record>`. Expected: no file, an error naming the unchecked condition. Actual output: '[stderr] could not read the PaymentTokenAdded logs: check on Etherscan that only USDC was added', then 'wrote /tmp/imdh/outB/1-1762000000.safe-accept.json', exit 0, file present.

### 6. Info: checkAccept and checkRecord do not read tokenURIsLocked or supplyLocked: an irreversible lock set by the deployer key before acceptance is not reported

`Submission11/tools/safe-batch.mjs:29`

```
 'function skinCap(uint256) view returns (uint256)','function paidPriceApproved() view returns (bool)',
```

From audit_flow, confirmed against the READ ABI (lines 26-30) and the contract. The accept/open checks exist to catch what the deployer key did after the record (finding 6 of audit 90ae2f92). chainState reads owner, pendingOwner, snapshotConfigured, mintPaused, revealURIsLocked, the URIs, revealCommit, the signers, maxSupply, the six skinCaps, paidPriceApproved and the PaymentTokenAdded count. It does not read tokenURIsLocked or supplyLocked (contract lines 148-149, `bool public`), both one-way owner switches (lockTokenURIs line 439, lockSupply line 273) that the record does not store. lockSupply at the reviewed caps is harmless; lockTokenURIs() permanently removes the owner's setTokenURI override (line 433 requires !tokenURIsLocked) and neither accept nor open would show it. Fix: add both getters to READ, read them in chainState and refuse in checkRecord when either is true (the record's expected value is false for both).

**Reproduction**

State: deployment record written; before the Safe accepts, the deployer key (still owner) sends lockTokenURIs(). Run `safe-batch.mjs accept <record>`: chainState has no field for it, checkAccept and checkRecord pass, .safe-accept.json is written; later `open` passes checkOpen too. Expected: 'CHAIN_DIFFERS_FROM_RECORD tokenURIsLocked'. Actual: no warning; the Safe takes ownership of a contract on which setTokenURI always reverts TOKEN_URIS_LOCKED.

### 7. Info: The pre-check child process receives DEPLOYER_PRIVATE_KEY although preflight only needs the deployer address

`Submission11/tools/deploy-mainnet.ps1:78`

```
  node --import $RuntimeHook contract/tools/deploy-mainnet.mjs @modeArgs
```

From audit_economics, confirmed. README section 5 item 5 asks whether a secret reaches a child process that does not need it. Line 66 sets $env:DEPLOYER_PRIVATE_KEY process-wide and only the finally block (line 109) removes it, so the PREFLIGHT_ONLY run at line 78 inherits the key and deploy-mainnet.mjs line 323 constructs a Wallet from it even though preflight() takes only an address, which the script already holds in $deployer (line 70). The key is therefore in three node processes (validation one-liner, pre-check, deploy) where one would do, and in the PowerShell environment through the interactive 'Type DEPLOY' wait. Nothing prints it (redact and the CLI test's secret scan were checked), so this is exposure surface, not a leak. Fix: in PREFLIGHT_ONLY mode let deploy-mainnet.mjs accept DEPLOYER_ADDRESS instead of the key, and set $env:DEPLOYER_PRIVATE_KEY only around the deploy call at line 102, the way RPC_URL is already scoped.

**Reproduction**

Run deploy-mainnet.ps1, answer the prompts, stop at 'Type DEPLOY'. Inspect the environment of the node process started at line 78 (Get-Process | Select -Expand StartInfo, or /proc/<pid>/environ on a POSIX host). Expected: a pre-check that sends nothing has no private key in its environment. Actual: DEPLOYER_PRIVATE_KEY is set for it (line 66 sets it, line 109 removes it).

### 8. Info: Paid prepare reads snapshotRoot in a second round trip after the batched state reads, so the root and `configured` can come from different nodes and a valid order is refused as SNAPSHOT_MISMATCH inste

`Submission11/offchain/signer/paid-approval.mjs:96`

```
  if(this.snapshotRoot&&await c.snapshotRoot()!==this.snapshotRoot)fail('SNAPSHOT_MISMATCH',503);
```

From audit_permissions, confirmed. The free route reads snapshotRoot inside the same Promise.all as snapshotConfigured, so ethers batches both into one JSON-RPC request answered by one backend; the paid route reads it in a separate call after that batch resolved (line 96 against line 91-93). The position of the check answers README question 7 correctly (after MINT_NOT_READY, before MINT_PAUSED, like the free route), and once the opening block is visible on every backend it cannot stop a valid order because the root is set once. In the minutes around the open batch, however, a load-balanced RPC can answer configured=true from one backend and snapshotRoot=0x0 from a lagging one, and the order fails SNAPSHOT_MISMATCH (503, the 'stale Worker' code) rather than MINT_NOT_READY. Transient, the client retries, but it misreports the cause to the operator watching the Worker. Fix: add c.snapshotRoot() to the Promise.all at line 91 (read unconditionally, as the free route does) and compare the batched value.

**Reproduction**

State: the Safe's open batch executed in block B; the Worker's RPC has one backend at B-1. POST /v1/paid/prepare {wallet, look, choice, token: USDC} right after. Expected: success, or MINT_NOT_READY / MINT_PAUSED (503) until all backends show B. Actual: the Promise.all batch is served by a backend at B (configured=true), the separate snapshotRoot() call by the backend at B-1 (0x0), so the order fails SNAPSHOT_MISMATCH 503.

---

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