# Audit report

> Review Submission12/ only (read Submission12/AUDIT_README.md first; Submission11 to 5 AUDIT_README are the reference for everything unchanged). Not deployed. This is the remediation of audit 358f7f40 (1M 3L 4I, all in the deployment tools and the signer) on the build intended for Ethereum mainnet. The contract did not change: runtime, creation code and ABI are identical to Submission11, metadata included. Changed: tools/deploy-mainnet.ps1, deploy-mainnet.mjs, safe-batch.mjs, offchain/signer/paid-approval.mjs; the diff is in build/tools-and-tests.Submission11-to-12.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. Resume order: can floor be wrong and leave a run waiting on a nonce gap? Can dropping seed lose a needed outbid? Can a step enter steps[] when it should not?
> 2. REPLACEMENT_GAS: can the fallback send something that should have stopped? Is 1,000,000 too low for any step?
> 3. New stops: can NONCE_MOVED, RESUME_OUT_OF_ORDER or HEAD_BEHIND_RECORD block a valid run for good?
> 4. A recorded step is not resent: does that hide anything the read-back does not catch, for example after a reorg?
> 5. Fees: a refused bid raises last, and only an accepted fee reaches the record. Can a run overpay badly or fall short?
> 6. safe-batch on a real RPC: can pinned reads, the head rule or log chunks cause false refusals? Can a stale state still pass three consecutive reads?
> 7. Secrets: the key now lives in a PowerShell variable between the prompts and the deploy call. Is the scoping right on every exit path?
> 8. Anything in audit 358f7f40 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 | `b612610307800ec3bad2aa6cff279f39a6b65adf` |
| Job | `161d71af-879f-487f-b3b8-c5ac1bb71d9c` |
| Judged | 2026-11-03 06:40 UTC |
| Findings | 1 medium · 5 low · 3 info |

Four agents audited the code as it is at `b612610`, 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: safe-batch: on the default READ RPC (publicnode) every `open`, and any `accept` run more than ~10,000 blocks (~33 h) after the deploy, ends in LOGS_UNAVAILABLE, which makes the --logs-checked-on-ether

`Submission12/tools/safe-batch.mjs:107`

```
 const from=d.startBlock??d.deployBlock,extraTokens=from==null?null:await tokenLogs(g,Number(from),head).catch(()=>null);
```

tokenLogs always starts its first chunk at the record's startBlock (line 97 / 107). The default RPC (PUBLIC_RPC, line 37) does not cap the width of a log range: it refuses any eth_getLogs whose fromBlock is more than about 10,000 blocks behind its head, with -32602 'Archive requests require a personal token'. Chunking cannot help, because the very first chunk is the old one. steady() retries it 5 times, the .catch turns it into extraTokens=null, checkRecord throws LOGS_UNAVAILABLE, and holds() rethrows it at once (line 154). The owner's plan runs `open` days or weeks after the deploy: the metadata host, Worker and final list are not built yet, and USDC only becomes usable after two days. So on the default RPC `open` cannot pass without --logs-checked-on-etherscan, and that flag skips the extraTokens check entirely. The check that the deployer key, which holds every owner power until the Safe accepts, added no second payment token is therefore skipped in the normal case. AUDIT_README section 5 says only an RPC 'whose log limit is under 10,000 blocks' hits this and that the default RPC 'was not tried'. That is the wrong mechanism: the limit is on age, not range. `status` (line 199, holds(state,x=>x)) also prints extraTokens:null with no warning. The same RPC refuses eth_call pinned 127 or more blocks back. The current code re-reads the head before each pass, so it is not hit by that, but it rules out pinning reads to the record's block on this RPC. Fix: Read the count without history. For example, read extraPaymentTokenCount with eth_getStorageAt at the pinned head, using its slot from the build's storage layout, and compare it to 1 in checkRecord, keeping the log count as an optional cross-check. Alternatively require a READ_RPC_URL that serves logs from the deploy block for accept/open. Also have `status` warn when extraTokens is null, and correct AUDIT_README section 5.

**Reproduction**

Measured 2026-11-03 against https://ethereum-rpc.publicnode.com at head 0x191cd68 (26,332,520). eth_getLogs {address:Safe, fromBlock: head-9999, toBlock: +10} returned {"result":[]}. The same request with fromBlock head-10500, head-12000 or head-50000 returned {"error":{"code":-32602,"message":"Archive requests require a personal token. Get one at: https://www.allnodes.com/publicnode"}}. eth_call {to:Safe,data:0xe75235b8} at head-100 returned ...02, and at head-127 the same -32602 error. Tool trace: record with startBlock S, run `node safe-batch.mjs open <record> <wallets> <taken> <start> --sample-look=...` (or `accept`) with no READ_RPC_URL when head > S+10,000. Expected: extraTokens='1', checkOpen passes. Actual: queryFilter('PaymentTokenAdded',S,S+9999) is refused 5 times, extraTokens=null, LOGS_UNAVAILABLE is rethrown by holds on the first pass, and no batch is written. Re-running with --logs-checked-on-etherscan writes the batch with the token count never checked.

### 2. Low: Resume is blocked for good when the last recorded step's hash is no longer canonical: settled() throws RECEIPT_NOT_VISIBLE before any gate or read-back runs

`Submission12/tools/deploy-mainnet.mjs:150`

```
  if(steps.length)await settled(steps.at(-1));
```

A recorded step holds one hash, the one whose receipt firstReceipt saw first (line 197). The tool itself keeps up to 8 competing transactions of one step at one nonce (25% higher bids after stepTimeoutMs). Suppose h1 is mined and settled (3 reads about 6 s apart, inside one slot) and pushed into steps[], then a 1-block reorg drops its block and the next builder includes the step's replacement h2. The chain state is right, but eth_getTransactionReceipt(h1) is null for good. Every later --resume runs settled(steps.at(-1)) first: it polls visibleTries (90 x 2 s) and throws RECEIPT_NOT_VISIBLE, before codeAbsent, the gates, NONCE_MOVED or the read-back. AUDIT_README section 5 says a reorg-removed recorded step leads to a read-back mismatch that a person checks. On this path the read-back is never reached, and nothing says what to edit. deploy-mainnet.ps1 line 112 tells the owner to run RESUME again, which loops on the same stop. A step pushed from seed (line 158) becomes steps.at(-1) on the next resume and has the same exposure. This is a liveness fault only: nothing is sent. (Merges the audit_math and audit_permissions findings.) Fix: Record every hash of a step (hashes is known at line 197) and accept a receipt for any of them in settled. If none appears, drop that last entry from steps[] (or skip the wait) and let codeAbsent/notDone and the read-back decide from the chain. At minimum, name the manual edit in the error and in the README.

**Reproduction**

Reproduced by importing an unmodified copy of Submission12/tools/deploy-mainnet.mjs with ethers 6.15.0 into a stub harness: a JS chain model behind provider and deployer objects, a stub runtime.mjs, artifacts and signer imports, cfg.reviewed matched to the stub build, timing {pollMs:1,stepTimeoutMs:50,visibleReads:3,visibleTries:20}. sendTransaction below the chain nonce throws code NONCE_EXPIRED, as ethers maps it. First run until steps=[deploy@0,setRevealURIs@1,setRevealCommit@2] is checkpointed with no pending, then killed. Then delete the receipt of steps[2].hash from the chain model, which leaves the state and nonce 3 unchanged: the effect of a reorg that re-included the step's replacement. deployMainnet({...,resume:record}). Expected: the gates see revealCommit set, and addPaymentToken goes out at nonce 3. Actual: 'ERR RECEIPT_NOT_VISIBLE 0x183a7d36...', 0 new sends, identical on every rerun.

### 3. Low: A reverted transaction's nonce is not in the record (TX_FAILED, and setRevealCommit revertRetries), so the resume floor is one below the chain and a stale nonce pair resends at a spent nonce, the depl

`Submission12/tools/deploy-mainnet.mjs:151`

```
  let floor=planNonce+steps.length;
```

AUDIT_README 4.1 says 'A reverted waiting transaction is not pushed, and its nonce is counted'. That holds only on the seed path (line 157). In send(), a reverted receipt does nonce++ (line 192) and then either retries (revertRetries, line 194) or writes checkpoint(state()) with no pending and throws TX_FAILED (line 195). Neither path leaves anything in the record about the spent nonce. On resume, floor=planNonce+steps.length is one below the chain, so the bound that answers audit 358f7f40 finding 1 is missing, and nonce comes only from stableNonce(): two reads that a lagging node can answer stale. Case 1, a reverted deploy: the owner should get PLANNED_NONCE_USED at once. Instead the tool broadcasts a second deploy at the spent planNonce. A fresh node refuses it, so no second contract is created. The run then stops with NONCE_MOVED. If the node is still behind, it can accept the transaction into its pool and the loop waits 8 x stepTimeoutMs. Case 2, a reverted setRevealCommit attempt followed by a retry that lands, then a kill before the next send: the resume sends addPaymentToken at the spent nonce and stops with NONCE_MOVED. The result is extra stops and time, with no loss. (Merges the audit_math TX_FAILED finding and the audit_flow floor finding.) Fix: Carry the run's next nonce in every checkpoint (state=>({...,nextNonce:nonce})) and set floor=Math.max(planNonce+steps.length, resume.nextNonce??0). Or record reverted attempts (status 0) in a failed[] list that the floor counts, which also makes totalGas include the burned gas.

**Reproduction**

Reproduced by importing an unmodified copy of Submission12/tools/deploy-mainnet.mjs with ethers 6.15.0 into a stub harness: a JS chain model behind provider and deployer objects, a stub runtime.mjs, artifacts and signer imports, cfg.reviewed matched to the stub build, timing {pollMs:1,stepTimeoutMs:50,visibleReads:3,visibleTries:20}. sendTransaction below the chain nonce throws code NONCE_EXPIRED, as ethers maps it. (1) The deploy at nonce 0 is mined with status 0. The first run ends with TX_FAILED, record steps=[], no pending, chain nonce 1. Resume with the first two eth_getTransactionCount answers stale (0). Expected: floor=1 and PLANNED_NONCE_USED. Actual: the tool resends DEPLOY@0, then 'NONCE_MOVED deploy EmberEvoGenesis: the chain is past nonce 0'. (2) setRevealCommit attempt 1 at nonce 2 reverts and the retry lands at 3. The run is killed after the checkpoint that has steps.length=3 and no pending, with chain nonce 4. Resume with the first two count reads at 3. Expected: addPaymentToken at 4. Actual: it is sent at nonce 3, then 'NONCE_MOVED addPaymentToken(USDC, 1600 GOLD): the chain is past nonce 3'.

### 4. Low: A waiting transaction that lands during the resume's gate reads is never recorded, so the next step stops with a misleading RESUME_OUT_OF_ORDER

`Submission12/tools/deploy-mainnet.mjs:171`

```
  if(seed?.hashes.length&&seed.nonce===nonce&&seed.name!==name)throw Error('RESUME_OUT_OF_ORDER '+name+' at the nonce of '+seed.name+': run the resume again');
```

The prologue gives the waiting transaction firstReceipt(seed.hashes,pollMs*5), about 10 s. If its nonce is not yet spent, seed stays and nonce=seed.nonce. The seed step's gate runs later, after codeAbsent, codeVisible and the RESUME_CODE/RESUME_NOT_OWNER reads. If the transaction is mined in that window, notDone(seed.name) says done. Nothing then advances nonce, clears seed or records the receipt: nonce++ only happens inside send. The next step's send hits line 171 and throws RESUME_OUT_OF_ORDER, whose text points at an out-of-order record when the real event is that the waiting transaction landed. A second resume finds the receipt and continues. The same mechanism makes the stop repeat under a lag pattern where receipt and nonce reads come from a node that is behind and the gate read from a fresh one. The cost is an extra run and a wrong diagnosis, with no loss. (Merges the audit_math finding and the audit_economics info finding.) Fix: In notDone, when the gate says done for seed.name while seed is set, wait for firstReceipt(seed.hashes), push it to steps if status 1, then set nonce=Math.max(nonce,seed.nonce+1) and seed=null. Include the seed hashes in the RESUME_OUT_OF_ORDER text.

**Reproduction**

Reproduced by importing an unmodified copy of Submission12/tools/deploy-mainnet.mjs with ethers 6.15.0 into a stub harness: a JS chain model behind provider and deployer objects, a stub runtime.mjs, artifacts and signer imports, cfg.reviewed matched to the stub build, timing {pollMs:1,stepTimeoutMs:50,visibleReads:3,visibleTries:20}. sendTransaction below the chain nonce throws code NONCE_EXPIRED, as ethers maps it. The first run sends addPaymentToken at nonce 3 into the pool, never mined. The record ends with steps=[deploy,setRevealURIs,setRevealCommit] and pending {name:'addPaymentToken(USDC, 1600 GOLD)',nonce:3,hashes:[...]}. On resume, the pooled transaction is mined on the first eth_call to paymentTokens(address), which is the gate read. Expected: the step is recorded and approvePaidPrice goes out at 4. Actual: 'RESUME_OUT_OF_ORDER approvePaidPrice at the nonce of addPaymentToken(USDC, 1600 GOLD): run the resume again', 0 sends. A second resume gets past it.

### 5. Low: Every send error, including a transport error with nothing broadcast, raises the next bid 25%: seven 502s make the deploy bid 4.77x the node's suggested tip and maxFee

`Submission12/tools/deploy-mainnet.mjs:180`

```
    last=fee;   // a refused bid counts too: the next attempt must beat it, or it would be refused the same way
```

last is set before sendTransaction, so any error raises the next fee: a gateway 502, SERVER_ERROR, a rate limit or INSUFFICIENT_FUNDS. When nothing is sent, the loop waits only pollMs*3 (line 190), so 8 attempts fit in well under a minute. There is no ceiling, and a resume restarts the chain of bumps from the recorded fee, so two stuck runs compound. On mainnet the tip is paid in full, so an RPC fault alone, with no competing bid anywhere, overpays the 5.9M-gas deploy by about 7.5 gwei/gas, or ~0.044 ETH at a 2 gwei market tip. A timeout after broadcast does need an outbid. The defect is that the bump is not limited to errors that can mean a bid exists, and is not capped. (Merges the audit_flow low and the audit_economics info findings.) Fix: Bump only after a successful send, or on errors that can mean a bid is held: REPLACEMENT_UNDERPRICED, 'already known', or a timeout after broadcast. Add a ceiling on maxPriorityFeePerGas, for example 3x the first attempt, after which the run stops with STEP_STUCK.

**Reproduction**

Reproduced by importing an unmodified copy of Submission12/tools/deploy-mainnet.mjs with ethers 6.15.0 into a stub harness: a JS chain model behind provider and deployer objects, a stub runtime.mjs, artifacts and signer imports, cfg.reviewed matched to the stub build, timing {pollMs:1,stepTimeoutMs:50,visibleReads:3,visibleTries:20}. sendTransaction below the chain nonce throws code NONCE_EXPIRED, as ethers maps it. getFeeData returns tip 2 gwei and maxFee 30 gwei. The first 7 deploy sendTransaction calls throw code SERVER_ERROR ('502 Bad Gateway') before broadcast, and the 8th is accepted. The 8 attempts bid tips of 2, 2.5, 3.125, 3.906, 4.883, 6.104, 7.629 and 9.537 gwei, with maxFee rising from 30 to 143.05 gwei. Expected: about 2 gwei and 30 gwei, since no earlier bid existed. Actual: the accepted deploy carries a 9.537 gwei tip and 143.05 gwei maxFee.

### 6. Low: safe-batch: a transient eth_getLogs failure ends accept/open on the first pass; holds() rethrows LOGS_UNAVAILABLE instead of retrying it like other refusals

`Submission12/tools/safe-batch.mjs:154`

```
   if(String(e.message).startsWith('LOGS_UNAVAILABLE'))throw e;   // already asked several times
```

chainState maps every tokenLogs error to extraTokens=null (line 107). That includes transient ones: a timeout, a 429 after the 20 parallel eth_call reads, or a node behind the pinned head refusing toBlock. One chunk failing for about 5 s (steady: 5 tries x 1 s) is enough. checkRecord then throws LOGS_UNAVAILABLE, and holds rethrows it at once. A CHAIN_DIFFERS_FROM_RECORD or HEAD_BEHIND_RECORD from the same lag is retried for up to 10 passes. The resulting message recommends --logs-checked-on-etherscan, which turns the check off. This is separate from the structural refusal by the default RPC, which is reported as the medium finding. (Merges the audit_flow and audit_economics findings.) Fix: Treat LOGS_UNAVAILABLE as an ordinary refused pass, and throw it only if it is still present when tries run out. In tokenLogs, map only the RPC's explicit range/limit errors to null and let other errors propagate.

**Reproduction**

With an unmodified copy of safe-batch.mjs: holds(read, check, {passMs:1,refusedMs:1}). read returns {extraTokens:null} on pass 1 and {extraTokens:'1'} afterwards. check throws Error('LOGS_UNAVAILABLE x') on null, as checkRecord does. Result: 'holds threw after pass 1 : LOGS_UNAVAILABLE x'. The same holds() with a check that throws 'CHAIN_DIFFERS_FROM_RECORD x' on pass 1 only passes after 4 reads. Expected: a log refusal on one pass is retried too.

### 7. Info: safe-batch: stableHead runs before the chain-id check, so a wrong-chain READ_RPC_URL with a lower head reports HEAD_BEHIND_RECORD 'try again' for ~5 minutes instead of the real cause

`Submission12/tools/safe-batch.mjs:196`

```
  const state=async()=>{high=await stableHead(provider,high);return chainState(provider,d,high);};
```

The provider is built with staticNetwork:true and the record's chainId (line 193), so ethers never checks the RPC's chain. eth_chainId is only compared inside chainState, after stableHead has returned. If the RPC's head is below recordBlock(d), stableHead throws HEAD_BEHIND_RECORD after 30 s. holds treats that as a refusal and retries it 10 times, and the run then exits telling the owner to try again, which can never succeed. Nothing is signed, so only the diagnosis is affected. Fix: Read eth_chainId once in the CLI block before the first stableHead and refuse there.

**Reproduction**

stableHead({getBlockNumber:async()=>1500000}, 9500000, {tries:3,ms:1}) throws 'HEAD_BEHIND_RECORD the read RPC does not show block 9500000 yet: try again'. In the CLI the eth_chainId check that would say 'the read RPC is on another chain' is never reached. Example: a Sepolia record at startBlock 9,500,000 with READ_RPC_URL pointing at a chain at height 1,500,000.

### 8. Info: deploy-mainnet.ps1: the RPC URL and deployer key are read before the try, so a stop between the prompts leaves them set and the finally never clears them

`Submission12/tools/deploy-mainnet.ps1:66`

```
$deployerKey = Read-Secret 'Deployer wallet private key'
```

$rpcUrl (line 65) and $deployerKey (line 66) are assigned before the try at line 67. Every exit inside the try reaches the finally, which nulls both and removes the env entries. That covers the key check, pre-check failure, cancel, deploy exit 1/2 and Ctrl+C inside the try. A Ctrl+C at the key prompt, or an exception from Read-Secret, exits with $rpcUrl set and never cleared. When the script is run with -File as documented, the process ends and nothing persists. When it is dot-sourced into an interactive session, which the header does not forbid, the URL with its API key stays readable in the session. Fix: Move lines 63-66 inside the try, or open the try before the first Read-Secret.

**Reproduction**

By reading the code (PowerShell was not run): `. .\deploy-mainnet.ps1`, answer the address and RPC prompts, press Ctrl+C at 'Deployer wallet private key', then `$rpcUrl` prints the URL. Ctrl+C anywhere after line 67 leaves it empty, because the finally runs.

### 9. Info: REPLACEMENT_GAS is a fixed 1,000,000 instead of the limit the step's first, estimated send already used

`Submission12/tools/deploy-mainnet.mjs:24`

```
const LOW128=(1n<<128n)-1n,PAYMENT_TOKEN_DELAY=2*86400,REPLACEMENT_GAS=1_000_000n;
```

The fallback (line 179) runs only when the step already has a hash and is not the deploy. The replacement shares the nonce, so it cannot double-execute, and with fixed calldata it reverts only if the waiting transaction would also revert. Under today's pricing the limit is enough: the largest step is 169,104 gas, 5.9x headroom. It is not tied to anything, though. Scaling the project's own preflight allowance (~6.3M to ~40M 'if EIP-8037 is live', 6.3x) to setRevealURIs gives ~1.07M, above the constant. A replacement mined on that limit would go out of gas, burn 1M gas and end the run with TX_FAILED. That would also hit the uncounted-nonce finding above. The first send of every step used a successful estimate, so a known-good limit exists. Fix: Keep the last successful limit for the step, and store it in the pending checkpoint as gasLimit so a resume can reuse it. Fall back to that, using REPLACEMENT_GAS only when no limit is known.

**Reproduction**

Arithmetic only, not executed: 169,104 x (40M/6.3M) = 1,073,642 > 1,000,000. Path: setRevealURIs with hashes=[h1] sent on an estimated limit, a node that estimates on top of h1 so the estimate reverts, and line 179 returns REPLACEMENT_GAS. No failure under today's pricing.

---

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