# Audit report

> Review Submission14/ only (read Submission14/AUDIT_README.md first; Submission13 to 5 AUDIT_README are the reference for everything unchanged). Not deployed. This is the remediation of audit 8b5de2de (6L 2I, 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 Submission13, metadata included. Changed: tools/deploy-mainnet.mjs and safe-batch.mjs; the diff is in build/tools-and-tests.Submission13-to-14.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. Signing first in send(): can a recorded hash no node ever held change a decision wrongly? Can a refusal remove a held hash?
> 2. refusedSend and the words list: can an error count as a refusal when the node took the transaction? Can a refusal in a client's words be missed?
> 3. queued and meetSeed: can a live transaction leave the record, can a resume repeat forever, can a step land twice?
> 4. floor starts at 0: what stops a run at a spent nonce on a stale read? Is plan + steps + failed right for every record the tool can write?
> 5. recordedReceipt: can a real reorg stick a run? Can a mined entry still be dropped?
> 6. Fees at the ceiling: is the last bid always acceptable to a node? Can the ceiling branch repeat a bid?
> 7. chainState's code check: can it stop a valid accept or open?
> 8. Anything in audit 8b5de2de 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 | `aa70ae3425990a6532bccd7ee8705b19b23a1e8c` |
| Job | `56694dc1-7e9a-422c-92b2-0cec74a3e938` |
| Judged | 2026-11-04 10:12 UTC |
| Findings | 5 low · 1 info |

Four agents audited the code as it is at `aa70ae3`, 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: send(): a 'nonce too low' (or 'insufficient funds') answer about bytes that were delivered removes the landed hash, so the step loses its hash and gas, a revert loses its failed entry, and a replaceme

`Submission14/tools/deploy-mainnet.mjs:269`

```
if(fresh&&refusedSend(e))hashes.splice(hashes.indexOf(own),1);
```

Merged from audit_permissions #1, audit_math #1 and audit_flow #1 (same line, same root cause). refusedSend (line 30) counts e.code==='NONCE_EXPIRED' and 'INSUFFICIENT_FUNDS' as proof that no node holds the transaction, and line 269 then splices the hash this attempt signed. AUDIT_README section 4 item 1 names the case where that proof fails: a gateway that retries internally delivers the raw transaction, loses the reply, retries, and gets 'nonce too low' because the first copy is already mined. ethers maps any 'nonce ... too low' text to NONCE_EXPIRED. Nothing calls getTransaction or getTransactionReceipt on own before splicing it, so nothing recovers. (a) First attempt of a step: hashes becomes [], so at line 277 stableNonce()>nonce is true and the run throws NONCE_MOVED, and checkpoint(state()) writes no pending entry and no hash. The resume skips the step by its gate. The step then shows up in notInRecord with its gas left out of totalGas, and the deploy step loses its creation hash. If the delivered transaction reverted (setRevealCommit), record() never adds a failed entry, so planNonce+steps+failed comes out one short on every later resume. The meetSeed exception at line 245 is then also unavailable to that step. (b) Replacement attempt (a>=1): the earlier hash h1, which was replaced and will never mine, stays in hashes. Line 277 therefore never fires. Attempts a+1..7 each sign at the spent nonce, get NONCE_EXPIRED, splice their own hash and wait pollMs*3. The step ends in STEP_STUCK with pending.hashes=[h1]. On the resume, line 207 sees stableNonce()>s.nonce and waits the full stepTimeoutMs (5 min) on h1 before dropping it, and the step again ends in notInRecord. INSUFFICIENT_FUNDS from a lagging node behind a fan-out gateway has the same effect: the held hash is dropped, and it is only re-added if the fee is unchanged and the same bytes are signed again. Nothing is sent twice, because the nonce is spent, so the cost is an incomplete record, lost gas accounting and time. This is a non-reorg way for a mined hash to leave the record, which section 5 does not list.

**Reproduction**

Traced by hand against the code. Bash would not start in this sandbox, so no harness was run. Use the harness of tests/contract/deploy-mainnet-cli.test.mjs. Wrap provider.broadcastTransaction so that for the setRevealURIs raw transaction it calls the real broadcast, mines it (automine), then throws Object.assign(Error('nonce too low'),{code:'NONCE_EXPIRED'}). Expected, per README section 2 finding 2 ('anything else keeps it, and it is watched') and the TIMEOUT/SERVER_ERROR test of finding 2: the step is recorded with the pre-computed hash, d.steps.length===6, d.notInRecord deepEquals []. Actual: line 269 splices own, hashes.length===0, line 277 throws 'NONCE_MOVED setRevealURIs (not locked)', and the checkpoint has no pending entry. --resume finishes with notInRecord ['setRevealURIs (not locked)'] and totalGas short by that step's gas. Variant (b): automine off, so attempt 1's h1 is pooled and not mined. Attempt 2's h2 is broadcast for real, mined, then answered NONCE_EXPIRED, and eth_getTransactionReceipt(h1)=null. Actual: 'STEP_STUCK setRevealURIs (not locked): NONCE_EXPIRED' with pending.hashes=[h1], and the resume spends stepTimeoutMs at line 207. With lowGasOnce on h2 (a status-0 receipt), d.failed never lists it.

### 2. Low: send(): NONCE_MOVED needs hashes to be empty, so a hash no node holds (a send that never left, or a replaced one) suppresses the stop on a spent nonce: all attempts are used up, then STEP_STUCK, and t

`Submission14/tools/deploy-mainnet.mjs:277`

```
if(e.code==='NONCE_EXPIRED'&&!hashes.length&&await stableNonce()>nonce){checkpoint(state());throw Error('NONCE_MOVED '+name+': the chain is past nonce '+nonce+'. Run the resume again');}
```

From audit_economics #1. Since hashes are pushed at line 261 before broadcastTransaction, hashes may hold a hash that no node ever received: a 502, a reset, or ethers' internal getBlockNumber failing inside broadcastTransaction is not a refusal, so the hash is kept. README section 3 item 5 says a stale low read 'sends at a spent nonce, which a node refuses' (NONCE_MOVED). Line 277 checks only !hashes.length, though. One phantom hash keeps the stop from firing: every later attempt signs at the spent nonce, gets NONCE_EXPIRED (only its own fresh hash is spliced), and waits pollMs*3. The run ends with 'STEP_STUCK <step>: NONCE_EXPIRED' and pending.hashes=[phantom]. The next resume's line 207 sees stableNonce()>s.nonce and waits the full stepTimeoutMs on a hash that cannot have a receipt. The TIP_CEILING branch at line 272 already asks getTransaction for each hash; line 277 does not. No gas is spent and the run ends correct. The cost is about 6 minutes and a misleading error, in exactly the stale-read case the floor-at-0 change relies on this stop for.

**Reproduction**

Traced by hand (no harness run; Bash unavailable). Start from the test 'audit 8b5de2de finding 2: a send a node refuses leaves no hash behind'. Its record is one short of the real count, and eth_getTransactionCount answers n-1 until the first send. Change the first eth_sendRawTransaction of the stale step to throw Object.assign(Error('server response 502'),{code:'SERVER_ERROR'}) without delivering. Every later send gets the node's real 'nonce too low'. Expected: NONCE_MOVED and wrote.pending===undefined, as in the existing test. Actual: H1 is kept (not a refusal). Attempts 2..8 get NONCE_EXPIRED, hashes stays [H1], line 277 is skipped, the error is 'STEP_STUCK <step>: NONCE_EXPIRED (nonce too low)' and the checkpoint has pending.hashes=[H1]. The resume on that record waits stepTimeoutMs at line 207.

### 3. Low: meetSeed takes one null getTransaction answer per hash as 'no node holds it' and sends a stale-gate step over another step's live transaction

`Submission14/tools/deploy-mainnet.mjs:244`

```
if(!(await Promise.all(seed.hashes.map(h=>provider.getTransaction(h).catch(()=>true)))).some(Boolean))return;
```

Merged from audit_permissions #2 and audit_flow #2. RESUME_OUT_OF_ORDER (line 245) is the guard against a stale gate sending a done step at the nonce of another step's waiting transaction. Line 244 bypasses it on a single null answer per hash. Every other 'absent' decision in this file needs repeated answers (notDone uses visibleReads, codeAbsent uses 10 reads, recordedReceipt uses visibleReads block answers), because one request reaches one node. A pending transaction is answered from the pool of whichever node is asked, and a node that has not received it by gossip answers null. The step then goes out at market (last stays null). If that bid is >=110% of the waiting one, or after the refusals have raised last by 25% per attempt, it replaces the later step's transaction. A done step then runs again on chain: setRevealCommit and addPaymentToken revert, burning gas and adding a spurious failed entry. The displaced step has to be resent at the next nonce. Preconditions: a record one short (e.g. after the line-269 finding), a stale gate over visibleReads reads, and a null from a node that lacks the pooled transaction.

**Reproduction**

Traced by hand (no harness run). Take the second half of the existing test 'audit 8b5de2de review: a step whose tries reverted goes out at the nonce of a later waiting transaction': a record with queued [{name:'addPaymentToken(USDC, 1600 GOLD)', nonce:n, hashes:[fake], fee:FAKE_FEE}] and no failed setRevealCommit entry. That test expects RESUME_OUT_OF_ORDER when provider.getTransaction(fake) returns {}. Make it return {} on odd calls and null on even calls, so that meetSeed's call gets null. Expected: RESUME_OUT_OF_ORDER setRevealCommit. Actual: line 244 returns, and setRevealCommit is signed and broadcast at nonce n (srv.state.sent[0].nonce===n, market tip), racing the queued addPaymentToken. With a real pending addPaymentToken at n and a stale revealCommit() read, the repeated setRevealCommit can mine and revert (previous commit mismatch), and addPaymentToken is resent at n+1.

### 4. Low: meetSeed's revert exception matches any failed entry with the step's name, including one followed by a success the record lost, so a stale gate outbids the next step's live transaction

`Submission14/tools/deploy-mainnet.mjs:245`

```
if(!failed.some(f=>f.name===name))throw Error('RESUME_OUT_OF_ORDER '+name+' at the nonce of '+seed.name+' ('+seed.hashes.join(',')+'): run the resume again');
```

Merged from audit_permissions #3 and audit_flow #3. The exception is meant for 'this step's own last try having reverted' (comment at line 240). It tests only whether some failed entry carries the step's name. record() (line 142) stores no nonce, and failed entries are never removed except by the reorg drop. setRevealCommit runs with revertRetries:2, so a revert at n followed by a success at n+1 is a normal path. If that success is not in steps (its hash was lost as in the line-269 finding, or seedLanded found no receipt) and the gate reads stale over visibleReads, the next step's held transaction at n+2 meets the exception. last=feeOf(seed.fee) is set, the guard is skipped, and the stale step bids 125% over and replaces the live transaction. Mined, the repeat reverts (REVEAL_COMMIT_INVALID: the previous commit no longer matches), burning gas and adding a second failed entry. The displaced step is resent.

**Reproduction**

Traced by hand (no harness run). Record: planNonce p, with steps deploy@p and setRevealURIs@p+1, failed [setRevealCommit (status 0) @p+2]. setRevealCommit succeeded at p+3 under a hash not in the record. queued [{name:'addPaymentToken(USDC, 1600 GOLD)', nonce:p+4, hashes:[h], fee:{maxPriorityFeePerGas:'1200000000', maxFeePerGas:'500000000000'}}], with h pending in the pool. eth_getTransactionCount answers p+4, and revealCommit() answers 0x0 for the first visibleReads reads (lagging node). Expected: RESUME_OUT_OF_ORDER setRevealCommit. Actual: notDone returns true, meetSeed sees h held, line 245 finds the old failed entry, setRevealCommit is signed at nonce p+4 with tip >=1.5e9+1 and fee cap 625e9+1 and replaces h, and once mined it reverts.

### 5. Low: seedLanded(): when the count jumps past a queued later transaction, that transaction is dropped from the record without its receipt being read, although its hash is known

`Submission14/tools/deploy-mainnet.mjs:226`

```
nonce=Math.max(n,planNonce+steps.length+failed.length);
```

From audit_economics #2. In the no-receipt branch, nonce is raised to the highest count read. If the count passed seed.nonce by more than one, every later entry below the new nonce disappears. nextSeed() promotes it, the state() filter at line 139 (s.nonce>=nonce) leaves it out of the checkpoint at line 229, and trimSeed() in its own notDone discards it. Its isDone then returns true and, since seed?.name no longer matches, seedLanded is not called. Its receipt was only looked for once in the resume block (line 206, pollMs*5), before the stepTimeoutMs wait on the seed at line 216. A later transaction that landed during that wait is therefore never recorded. It is missing from steps (or from failed, if it reverted, which makes planNonce+steps+failed one short) and only shows up in notInRecord. Section 5 accepts a live hash leaving the record only after a reorg. This path needs none. The gates prevent a resend, so the run ends correct.

**Reproduction**

Traced by hand (no harness run). Resume record: steps at p..p+2, pending {name:'setRevealCommit', nonce:p+3, hashes:[hX]} with hX never mined (the commit landed at p+3 under a lost hash), and queued [{name:'addPaymentToken(USDC, 1600 GOLD)', nonce:p+4, hashes:[hY]}] with hY pooled. eth_getTransactionCount answers p+3 during the resume block, then p+5. hY mines after the 10-second look at line 206. Expected: addPaymentToken recorded in steps with hY's receipt and gas. Actual: in seedLanded, firstReceipt(hX) waits stepTimeoutMs and returns null. n becomes p+5 and nonce=p+5, nextSeed() makes Y the seed, checkpoint(state()) omits it, notDone('addPaymentToken...') trims it and returns false. The final record has addPaymentToken in notInRecord, addBlock is undefined, and totalGas is short.

### 6. Info: Refusal words do not match Nethermind 1.x's bare AcceptTxResult names (FeeTooLow, ReplacementNotAllowed, AlreadyKnown, OldNonce, InsufficientFunds)

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

```
const bidTooLow=e=>e.code==='REPLACEMENT_UNDERPRICED'||/underpriced|insufficient gas price to replace|below .*minimum|fee too low|could not replace/i.test(String(e.message));
```

From audit_math #2. README section 2 finding 3 says only Nethermind's result names were read. Those names are written without spaces, and the regexes need spaces: /fee too low/ does not match 'FeeTooLow', there is no pattern for 'ReplacementNotAllowed', and /already known/ on line 28 does not match 'AlreadyKnown'. Nethermind 1.x returns the result name as the JSON-RPC error message (later releases use geth-style text). ethers maps NONCE_EXPIRED and INSUFFICIENT_FUNDS only from the spaced phrases, so 'OldNonce' and 'InsufficientFunds' are not refusals either. Effects on such a node: a bid refused as too low does not raise last, so the same market bid repeats; a held bid is not recognised; a refused hash is kept; and line 277's NONCE_MOVED never fires. The step ends in STEP_STUCK. Nothing is sent twice and no gas is spent. The code-level mismatch is certain. That Nethermind 1.x returns these exact strings is the specialist's reading and was not re-checked here.

**Reproduction**

Evaluate the predicates from lines 26, 28 and 30 directly. bidTooLow({message:'FeeTooLow'}) is false, bidTooLow({message:'ReplacementNotAllowed'}) is false, and mayHoldBid({message:'AlreadyKnown'}) is false. Expected for a bid refusal: true, so that the next bid is raised. In the finding 3 test shape (resume with pending {name:'setRevealCommit', nonce:n, hashes:[fake], fee:null}, getTransaction(fake)={}), make eth_sendRawTransaction throw Error('ReplacementNotAllowed'). tips[i]===tips[i-1] on every attempt, then STEP_STUCK.

---

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