# Audit report

> Review Submission13/ only (read Submission13/AUDIT_README.md first; Submission12 to 5 AUDIT_README are the reference for everything unchanged). Not deployed. This is the remediation of audit 161d71af (1M 5L 3I, all in the deployment tools) on the build intended for Ethereum mainnet. The contract did not change: runtime, creation code and ABI are identical to Submission12, metadata included. Changed: tools/deploy-mainnet.ps1, deploy-mainnet.mjs, safe-batch.mjs; the diff is in build/tools-and-tests.Submission12-to-13.diff. Section 2 maps each finding to its fix, section 3 lists new code beyond the report, sections 5 and 7 say what is accepted and what is unverified.
>
> 1. recordedReceipt: can an entry leave the record when the step is on the chain, in a way the gates cannot recover? Can a stale node reach the drop?
> 2. failed[] and the floor: can the floor exceed the real nonce and leave a run on a nonce gap?
> 3. seedLanded: can a true gate fire for the wrong step or after a reorg? Is the nonce right on both branches?
> 4. EXTRA_TOKEN_COUNT_SLOT = 11 replaces the log count and the bypass flag is gone. Is anything else needed?
> 5. Fees: can mayHoldBid miss an error that means a bid is held? Can the ceiling (3x today's tip, at least 2 gwei) stop a valid run or leave a transaction unmined?
> 6. Can TIP_CEILING or STEP_STUCK block a valid run for good?
> 7. deploy-mainnet.ps1: every prompt is now in the try. Is any exit path left?
> 8. Anything in audit 161d71af that is not actually fixed.
>
> 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 | `674b95a1d0e05b0d9fb3ed607a424aac99698f3c` |
| Job | `8b5de2de-ea16-44ad-92c4-2a6437b27c5c` |
| Judged | 2026-11-03 15:49 UTC |
| Findings | 6 low · 2 info |

Four agents audited the code as it is at `674b95a`, 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: recordedReceipt drops a mined entry on the word of two different backends: a receipt-serving node that lacks the receipt while the head read is past the block takes a steps[] or failed[] entry out of

`Submission13/tools/deploy-mainnet.mjs:159`

```
   const n=await provider.getBlockNumber().catch(()=>-1);let answered=n>=top.blockNumber;
   for(const h of hashes){
    const r=await provider.getTransactionReceipt(h).catch(()=>{answered=false;return null;});
    if(r)return r;
   }
   empty=answered?empty+1:0;
   await sleep(pollMs);
  }
  if(empty>=visibleReads)return null;
```

The drop condition in recordedReceipt is built from two separate JSON-RPC requests: eth_blockNumber (line 159, `answered=n>=top.blockNumber`) and eth_getTransactionReceipt per hash (line 161). On a load-balanced or method-routed RPC (the one model the tool documents) the node that reported the height is not the node that answered the receipt read, so `answered` does not show that a node holding the block lacked the receipt. A node that has not indexed the transaction returns null, not an error, so `empty` grows and after visibleTries polls with no receipt the function returns null. Note this needs every one of the visibleTries receipt reads to be null (line 162 returns on the first receipt), so random per-request lag does not reach it; persistent routing (receipt calls served by a backend whose tx index is behind or has a gap, head calls by a current head tracker) does. Line 179 then splices the entry and line 277 checkpoints the record without it. For a steps[] entry the gate recovers the step from the chain but it is never re-recorded (notDone returns false on a done gate without recording), so stepBlock() is undefined, totalGas and safe-batch's recordBlock miss it. For a failed[] entry there is no gate at all: the reverted transaction's gasUsed leaves totalGas for good, and the count floor planNonce+steps+failed (the guard audit 161d71af finding 3 added against stale nonce reads) drops by one. With the drop and two equal stale eth_getTransactionCount answers, the resume signs the next step at the spent nonce and depends on the node's 'nonce too low' to stop (NONCE_MOVED). The package's own test only covers a node behind on both reads together. Merged from audit_permissions and audit_flow. Fix: prove the absence in one request answered by one node: read eth_getBlockByNumber(top.blockNumber, true) (or by the recorded blockHash, stored in record()) and drop only when that block exists, its hash differs from the recorded one, and its transaction list holds none of top.hashes; a null block counts as 'not answered'. Otherwise throw RECEIPT_NOT_VISIBLE as for a node behind.

**Reproduction**

Reproduced with the Submission13 deploy-mainnet.mjs behind a scripted JSON-RPC (ethers 6.17, real Wallet signing). State: a run where the first setRevealCommit was mined with status 0 (record: steps [deploy, setRevealURIs], failed [setRevealCommit at block 1003, hash R], pending setRevealCommit nonce 3 with no hashes); the chain holds receipt R with status 0x0, head 1003, nonce 3. Resume with a script that answers eth_getTransactionReceipt(R) with null on every call and everything else (eth_blockNumber 1003 >= 1003, nonce, code, calls) truthfully. Expected: the entry stays (R is mined) or the run stops with RECEIPT_NOT_VISIBLE because no node holding block 1003 was shown to lack R. Actual: recordedReceipt returns null, the first checkpoint written on the resume has failed=[] and R appears in no checkpoint; the run finishes with final failed=[] and totalGas 126000 (R's gas gone). Variant with the first two eth_getTransactionCount answers stale at planNonce+2 (=2): nonce = max(2, 0, planNonce+2+0=2) = 2, setRevealCommit is signed and sent at the spent nonce 2 ('nonce too low'), and the run ends with 'NONCE_MOVED setRevealCommit: the chain is past nonce 2'. Without the drop the floor planNonce+steps+failed = 3 would have sent at 3.

### 2. Low: A send whose reply is lost after the node took the transaction (TIMEOUT, dropped connection, 502 on the response) throws the hash away: the landed step is never recorded, the run stops with NONCE_MOVE

`Submission13/tools/deploy-mainnet.mjs:218`

```
    const tx=await deployer.sendTransaction({...req,gasLimit:limit,nonce,...fee});
    hashes.push(tx.hash);knownLimit=limit;last=placed=fee;sent=true;log(name,tx.hash);
```

`deployer` is an ethers Wallet: the transaction is signed locally, so its hash is known before eth_sendRawTransaction goes out, but line 219 pushes `tx.hash` into `hashes` only when `deployer.sendTransaction` returns. If the RPC call fails after the node accepted the transaction, the hash is lost. mayHoldBid (line 26) counts TIMEOUT as a held bid, so attempt a+1 sends a 25% higher replacement at the same nonce; when the first transaction is mined before that, the replacement gets NONCE_EXPIRED with `hashes` empty, stableNonce() > nonce, and line 230 throws NONCE_MOVED after a step that in fact succeeded. The checkpoint at line 230 holds no hash for the step. On the resume the gate says done and the step is skipped, but it never enters steps: stepBlock(name) stays undefined, totalGas and the record miss it, and for addPaymentToken(USDC, 1600 GOLD) the usableAt read-back at line 330 is silently skipped. This is the 'lost hash' class the README accepts in section 5, but here the tool's own send path creates it. Merged from audit_permissions and audit_math. Fix: populate and sign first (`const signed=await deployer.signTransaction(await deployer.populateTransaction({...req,gasLimit:limit,nonce,...fee}))`), push `ethers.Transaction.from(signed).hash` into `hashes`, checkpoint(state(waiting())), then `provider.broadcastTransaction(signed)`; a broadcast error then leaves a hash the receipt logic can wait for.

**Reproduction**

Reproduced with the Submission13 deploy-mainnet.mjs behind a scripted JSON-RPC. Fresh run; deployer.sendTransaction is wrapped so that for the second step (setRevealURIs, nonce 1) the real send runs (the node takes and mines H at block 1002) and then {code:'TIMEOUT'} is thrown, as a lost HTTP reply does. Expected: the tool knows H (it signed it), waits for its receipt, records the step and goes on. Actual: sent nonces [0, 1, 1] with tips [1.0, 1.0, 1.25 gwei]: the retry bids 25% higher at nonce 1, the node answers 'nonce too low', and the run throws 'NONCE_MOVED setRevealURIs (not locked): the chain is past nonce 1'. H appears in no checkpoint. Resuming from the last checkpoint finishes with readBackMismatch [] but d.steps is [deploy, setRevealCommit, addPaymentToken, approvePaidPrice, transferOwnership]: setRevealURIs is never recorded and its gas is missing from totalGas.

### 3. Low: mayHoldBid misses reth's refusal wording, so on a reth-backed RPC a held bid is never climbed and every attempt resends the same refused bid (audit 161d71af finding 5, partially fixed)

`Submission13/tools/deploy-mainnet.mjs:26`

```
const mayHoldBid=e=>e.code==='REPLACEMENT_UNDERPRICED'||e.code==='TIMEOUT'||/already known|known transaction|underpriced/i.test(String(e.message));
```

The list is geth's wording: ethers' REPLACEMENT_UNDERPRICED (its regex in provider-jsonrpc.js needs both 'replacement transaction' and 'underpriced'), TIMEOUT, and /already known|known transaction|underpriced/. reth (crates/transaction-pool/src/error.rs at main, fetched 2026-11-03) answers a refused replacement with 'insufficient gas price to replace existing transaction', a duplicate with 'already imported', and a tip under its minimum with 'transaction priority fee below minimum required priority fee N'. None of these match (only reth's generic 'transaction underpriced' does), ethers maps them to UNKNOWN_ERROR, mayHoldBid is false and `last` is not raised. The paths that depend on the climb are the ones Submission 13 added: the ceiling branch that sets last=null when getTransaction answered null (README section 3, 'the bids climb again up to the ceiling'), and a resume whose waiting transaction is held under a hash the record does not know. On reth the identical market bid is refused on every remaining attempt, each followed by the receipt wait, and send() ends in STEP_STUCK; every resume repeats it while the node holds the earlier bid. Fix: widen the test, e.g. /already known|known transaction|already imported|underpriced|insufficient gas price to replace|priority fee below minimum/i, and add a CLI test that scripts eth_sendRawTransaction to answer {code:-32000,message:'insufficient gas price to replace existing transaction'} and asserts the next bid is higher.

**Reproduction**

Reproduced with the Submission13 deploy-mainnet.mjs behind a scripted JSON-RPC, eth_maxPriorityFeePerGas = 1 gwei. The second step's eth_sendRawTransaction is answered with a -32000 error three times, then taken. With geth's message 'replacement transaction underpriced' the tips at nonce 1 are 1, 1.25, 1.5625, 1.953 gwei (the climb the README describes). With reth's 'insufficient gas price to replace existing transaction', 'already imported' or 'transaction priority fee below minimum required priority fee 1000000000' the tips at nonce 1 are 1, 1, 1, 1 gwei: the same bid is resent unchanged. Expected: a refusal that can only mean a bid is held raises the next bid on every client wording the tool may meet. Actual: on reth the bid never rises, so a held transaction above the market bid is never outbid and with 8 attempts the step ends STEP_STUCK.

### 4. Low: After a dropped entry, the refilled step's checkpoints (and the NONCE_MOVED checkpoint) erase the waiting transaction of the later step from the record, so a resume neither watches nor outbids it

`Submission13/tools/deploy-mainnet.mjs:220`

```
    checkpoint(state(waiting()));
```

New code in section 3: when the top entry is dropped (topLeft) the floor ignores seed.nonce (line 182), so nonce < seed.nonce, the seed stays in memory (line 189) and an earlier step refills the freed nonce. Every checkpoint inside send() for that earlier step drops the seed's pending entry: line 220 writes state(waiting()), whose `pending` is the current step, and lines 230 and 235 write state() with no pending at all. Only the in-memory `seed` still knows the later step's hashes, fee and gasLimit. If the run stops before that later step is reached (STEP_STUCK or TIP_CEILING on the refilled step, RECEIPT_NOT_VISIBLE in settled, a kill, or NONCE_MOVED at line 230 after a stale nonce read sent the later step at the dropped step's nonce), the next resume has no seed: it does not watch the old hashes, does not bid over the old fee, and does not record that transaction when it lands; a market bid at that nonce is refused as underpriced while the old one is held, and for setRevealCommit a late landing after more than 256 blocks reverts and spends a nonce that never reaches failed[]. Merged from audit_permissions and audit_economics. Fix: in send(), write the current step's waiting entry plus the outstanding seed (for example a `queued` field or `pending` as a list) whenever seed.nonce > nonce, and make the state() checkpoints at lines 230 and 235 keep the seed the same way.

**Reproduction**

Reproduced with the Submission13 deploy-mainnet.mjs behind a scripted JSON-RPC. Record from a run stopped before its 4th send: steps [deploy, setRevealURIs, setRevealCommit (nonce 2)], pending {addPaymentToken(USDC, 1600 GOLD), nonce 3, hashes [h4]}, h4 held in the pool. The chain is then set as a reorg left it: no receipt for setRevealCommit's hash, revealCommit() zero, nonce 2 free again; the refill of setRevealCommit is taken by the node but not mined before the run gives up (attempts 2). Expected: every checkpoint still names h4 at nonce 3 so the next resume watches and outbids it. Actual: checkpoint 0 (line 277) has pending addPaymentToken nonce 3 and mentions h4; checkpoints 1 to 3 (line 220 and the final line 242 before 'STEP_STUCK setRevealCommit') have pending setRevealCommit nonce 2 and none of them mentions h4. The partial record on disk no longer references the live transaction at nonce 3.

### 5. Low: TIP_CEILING fires on a 25% raise when a replacement 10% to 25% above the last bid would still fit under the ceiling: the step waits on an unmineable transaction and ends STEP_STUCK instead of bidding

`Submission13/tools/deploy-mainnet.mjs:134`

```
  const up=(v,prev)=>{const bumped=prev==null?0n:prev*125n/100n+1n;return v>bumped?v:bumped;};
  const tip=f.maxPriorityFeePerGas??0n,bid={maxFeePerGas:up(f.maxFeePerGas,last?.maxFeePerGas),maxPriorityFeePerGas:up(tip,last?.maxPriorityFeePerGas)};
  // the tip is paid in full: bids stop at a multiple of today's tip, also across resumes. A waiting transaction above it is only waited for
  const ceiling=tip*TIP_CEILING_TIMES>TIP_CEILING_FLOOR?tip*TIP_CEILING_TIMES:TIP_CEILING_FLOOR;
  if(bid.maxPriorityFeePerGas>ceiling)throw Object.assign(Error('TIP_CEILING the next bid passes '+ceiling+' wei of tip: check the deployer on Etherscan and resume later'),{code:'TIP_CEILING'});
```

Each raise is fixed at prev*125/100+1 (line 134), but a node needs only +10% on both maxPriorityFeePerGas and maxFeePerGas to accept a replacement. When the 25% raise passes the ceiling, fees() throws (line 138) instead of bidding the ceiling, even when the ceiling is at least prev*1.1. The handler at lines 223 to 226 then only waits for the held transaction; if that one is unmineable because the base fee rose above its maxFeePerGas, the step ends STEP_STUCK after attempts x stepTimeoutMs and every resume with the same market fees repeats it. README section 5 accepts this only for a transaction 'at the ceiling'; here the last bid is 18.6% below it (2.44 of 3 gwei), and a replacement at 3 gwei with a fresh maxFeePerGas would have been accepted and mined. Fix: when up(tip,prev) > ceiling but prev*110n/100n+1n <= ceiling, bid maxPriorityFeePerGas = ceiling and maxFeePerGas = max(market, prev.maxFeePerGas*110/100+1); throw TIP_CEILING only when even the 10% raise passes the ceiling.

**Reproduction**

Reproduced with the Submission13 deploy-mainnet.mjs behind a scripted JSON-RPC: eth_maxPriorityFeePerGas = 1 gwei (ceiling max(3 gwei, 2 gwei) = 3 gwei), every eth_sendRawTransaction is taken by the node and held unmined (base fee above each maxFeePerGas). Tips bid for the deploy step: 1, 1.25, 1.5625, 1.953, 2.441 gwei; attempt 6 computes 2.441 x 1.25 = 3.05 gwei > 3 gwei and fees() throws TIP_CEILING; no further bid is sent and the run ends 'STEP_STUCK deploy EmberEvoGenesis: TIP_CEILING the next bid passes 3000000000 wei of tip'. Expected: a bid at the 3 gwei ceiling, which is +22.9% over 2.441 gwei (above the 10% rule), goes out with a fresh maxFeePerGas. Actual: the highest bid ever sent is 2.441 gwei, and the package's own test asserts exactly this (tips.at(-1)*125n/100n+1n > 3 gwei) as the intended stop.

### 6. Low: seedLanded's no-receipt branch drops every lower bound the run already holds on the nonce: one stale eth_getTransactionCount pair sends the next step at the spent seed nonce and stops a valid resume

`Submission13/tools/deploy-mainnet.mjs:195`

```
  else nonce=Math.max(await stableNonce(),planNonce+steps.length+failed.length);   // it landed under another hash, at a nonce only the chain knows
```

seedLanded() runs only after a gate said the waiting step is done. When no receipt for seed.hashes arrives within stepTimeoutMs, line 195 sets nonce = max(stableNonce(), planNonce+steps.length+failed.length) and discards both bounds the run already had: the current `nonce` and seed.nonce+1. stableNonce() accepts two equal 'latest' counts, and a lagging node (the tool's own model) returns two equal old counts. The record cannot cover the gap: a step that landed under a hash the run never saw is in neither steps[] nor failed[], so the count is at most seed.nonce. The result is a nonce at or below seed.nonce, which the gate's own answer shows is spent. Submission 12 used seed.nonce+1, which is right in exactly this case; the README's reason for the change (a step that landed at some other nonce) does not need the existing bound to go away. The send that follows cannot do harm (NONCE_EXPIRED, then NONCE_MOVED if the next read is fresh, or NONCE_EXPIRED after all attempts) but a valid resume stops and a person has to run it again although the chain had already settled the question. Fix: nonce = Math.max(nonce, seed.nonce+1, <read>, planNonce+steps.length+failed.length), reading the count until a value above seed.nonce is seen (a lagging node can only report too low), and a named error if none is seen within visibleTries.

**Reproduction**

Reproduced with the Submission13 deploy-mainnet.mjs behind a scripted JSON-RPC. Record: planNonce 0, steps [deploy@0, setRevealURIs@1], pending {setRevealCommit, nonce 2, hashes [H]}. On the chain setRevealCommit was mined at nonce 2 under another hash H2 (a resend the run never heard back about): getTransactionCount is 3 on fresh nodes, revealCommit() is set, H has no receipt. The first 8 eth_getTransactionCount answers come from a lagging node (2), later ones are fresh (3). Expected: after the gate says done the next step goes out at nonce 3 = seed.nonce+1. Actual: line 185 (2 > 2 false) keeps the seed, nonce = max(2, 2, 2) = 2; the setRevealCommit gate is true, seedLanded finds no receipt for H and sets nonce = max(2, 2) = 2; addPaymentToken(USDC, 1600 GOLD) is signed and sent at nonce 2 twice ('nonce too low'), and the run ends 'NONCE_MOVED addPaymentToken(USDC, 1600 GOLD): the chain is past nonce 2. Run the resume again'. Nonces sent on the resume: [2, 2].

### 7. Info: A step that the chain shows but the record lost (dropped entry, no-receipt seedLanded, lost hash) silently disables the USDC usableAt read-back check and the step's gas accounting

`Submission13/tools/deploy-mainnet.mjs:307`

```
 const usdcUsableAt=addBlock!==undefined?(await retry(async()=>{const b=await provider.getBlock(addBlock);if(!b)throw Error('BLOCK_NOT_VISIBLE '+addBlock);return b;})).timestamp+PAYMENT_TOKEN_DELAY:null;
```

When recordedReceipt returns null for the top entry, when seedLanded takes its no-receipt branch, or when a step landed under a lost hash, the step is never in steps: the gate skips it from the chain, which is right, but stepBlock(name) stays undefined for the rest of the run. For addPaymentToken(USDC, 1600 GOLD) that makes addBlock undefined, usdcUsableAt null, and line 330 (`if(usdcUsableAt!==null&&...)`) skips the only check that paymentTokens(USDC).usableAt is the add block's timestamp plus two days; totalGas and safe-batch's recordBlock also miss the entry. The chain still holds the step and nothing is resent, so this is a loss of verification, not of state, bounded to one entry per resume. Fix: when a gate says done for a step not in steps, record a hash-less entry {name, blockNumber: the block of the gate read} so stepBlock works, or derive usdcUsableAt from the chain (Number(p>>216n) must equal the timestamp of some block in the run's window plus 172,800) instead of skipping the check when addBlock is unknown.

**Reproduction**

Reproduced with the Submission13 deploy-mainnet.mjs behind a scripted JSON-RPC, as the consequence of the two findings above. In the lost-reply scenario the resume finishes with readBackMismatch [] and d.steps without setRevealURIs; in the stale-drop scenario the resume finishes with failed [] and totalGas 126000 while the chain holds a reverted transaction that burned gas. For the USDC case: the partial record from a run stopped after addPaymentToken(USDC, 1600 GOLD) with that entry's hashes replaced by one hash the chain never saw (the package's own test 'a recorded hash the chain no longer shows leaves the record' sets this up). Expected: paymentTokens(USDC).usableAt is still verified against the chain. Actual: addBlock is undefined, usdcUsableAt is null, line 330 never runs, and the run finishes with readBackMismatch [] whatever usableAt holds.

### 8. Info: safe-batch reads storage slot 11 of whatever address the record names without first pinning that code to the reviewed build the slot belongs to

`Submission13/tools/safe-batch.mjs:108`

```
 const extraTokens=BigInt(await steady(()=>provider.getStorage(d.contract,EXTRA_TOKEN_COUNT_SLOT,head))).toString();
```

EXTRA_TOKEN_COUNT_SLOT=11 is correct for the reviewed build: compiling build/standard-input.json's sources with solc 0.8.38 (via-IR, 200 runs, cancun) gives _owner 6, _pendingOwner 7, _nameFallback 8, _versionFallback 9, paymentTokens 10, extraPaymentTokenCount 11 (forge inspect storageLayout, verified during this review). The constant is only meaningful for that layout, but chainState/checkRecord never confirm that the code at d.contract is that build: the record carries codeHash and reviewedBuild.runtimeSha256, and deploy-mainnet.mjs checks runtimeMatches on every resume, while safe-batch.mjs trusts d.contract as given. A build that inserts one state variable before extraPaymentTokenCount moves the count to slot 12 and makes slot 11 the mapping base of paymentTokens (always 0): accept/open then stop with 'CHAIN_DIFFERS_FROM_RECORD extraTokens: the deployer key changed the contract' on a correct contract, blaming the deployer key. Fail-safe today, and an answer to README question 4: the one thing still needed is to tie the slot read to the code. Fix: in chainState read provider.getCode(d.contract,head), compare ethers.keccak256(code) with d.codeHash (and runtimeMatches against the reviewed runtime when the build is available) and throw CODE_NOT_RECORDED_BUILD otherwise; keep the slot constant next to the pinned build hash so a re-compile has to change both.

**Reproduction**

State: a deployment record whose contract field points at an address holding any other contract where slot 11 is not 1 (for example the Sepolia TestUSDC, or a future candidate build with one more variable before the count), with safe and readBackMismatch [] as usual. Run `node contract/tools/safe-batch.mjs accept <record>`. Expected: 'the code at <contract> is not the reviewed build' before any slot is interpreted. Actual: chainState reads slot 11 (line 108) with no code check, checkRecord (line 120) compares it with '1' and stops with 'CHAIN_DIFFERS_FROM_RECORD extraTokens: the deployer key changed the contract, stop and check'. Verified by reading safe-batch.mjs: no getCode or codeHash comparison exists in chainState, checkRecord, checkAccept or checkOpen.

---

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