# 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 | `14066658-72c7-46f8-a91c-8a569722919a` |
| Judged | 2026-11-04 01:04 UTC |
| Findings | 6 low · 2 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' answer about a fresh transaction a node already delivered removes the only hash of a mined step; the run then burns all attempts (STEP_STUCK) or stops NONCE_MOVED with the st

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

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

refusedSend (line 30) counts NONCE_EXPIRED as 'the node holds no such thing', and line 269 deletes the hash this attempt added. That is wrong for the transaction that spent the nonce. ethers 6.17.0 maps any eth_sendRawTransaction error whose message matches /nonce/ and /too low/ to NONCE_EXPIRED (verified with provider.getRpcError). A gateway that retries internally (README section 4 item 1) can deliver the signed bytes and, once they are mined, answer 'nonce too low' about them. The author's test for finding 2 covers only a resend of the same bytes (fresh=false). For a fresh bid Y (a replacement after the first bid timed out, or any setRevealCommit resend, whose request changes every attempt) the hash is removed and nothing reads getTransactionReceipt(own) first. Two outcomes. (a) hashes empty: line 277 throws NONCE_MOVED; the resume finds the step by its gate (steps) or resends it (a revert), but the receipt, gas and any `failed` entry of Y are gone from the record for good and plan+steps+failed undercounts. (b) An earlier bid X is still in hashes: NONCE_MOVED cannot fire, `last` is not raised (NONCE_EXPIRED is not mayHoldBid), every later attempt signs another bid at the spent nonce, is refused and removed, firstReceipt([X]) never answers because X was replaced, and after 8 attempts the run stops STEP_STUCK with pending.hashes=[X]. The next resume waits stepTimeoutMs (5 min) on X at line 207, then seedLanded waits another stepTimeoutMs, before moving on; the step appears only in notInRecord. It fails safe (no second contract, no double send) but the record is permanently incomplete and the run stops and waits for nothing. Fix: on NONCE_EXPIRED for a fresh hash, read getTransactionReceipt(own) (and getTransaction(own)) over visibleReads reads before removing it; if a receipt exists treat it as this step's receipt (settled, record, nonce++). bidTooLow and INSUFFICIENT_FUNDS can keep the removal.

**Reproduction**

Mainnet timing (pollMs 2000, stepTimeoutMs 300000, attempts 8). Step setRevealCommit at nonce n. Attempt 1 broadcasts X at fee F, accepted (sent=true, last=placed=F). firstReceipt times out. Attempt 2 signs Y (fresh block, fresh acceptance) at 1.25F+1; broadcastTransaction is forwarded by the gateway, the node takes Y, Y is mined at n, the gateway's retried forward returns {code:-32000,message:'nonce too low'} and ethers throws NONCE_EXPIRED. Expected: Y stays in hashes, firstReceipt returns Y's receipt, Y is recorded in steps (or in failed with its gas). Actual: line 269 removes Y; hashes=[X] so line 277 does not fire; firstReceipt([X],6 s) is null; attempts 3..8 each sign a new acceptance at 1.25F+1 at nonce n, get NONCE_EXPIRED, are removed; the run throws 'STEP_STUCK setRevealCommit: NONCE_EXPIRED' with pending={nonce:n,hashes:[X]}. With attempt 1 as the delivered one (hashes empty after removal and stableNonce()=n+1) it throws NONCE_MOVED instead and the checkpoint holds no hash for nonce n.

### 2. Low: meetSeed: one eth_getTransactionByHash answer per hash decides whether another step's waiting transaction is held, so a single null from a lagging/load-balanced backend bypasses RESUME_OUT_OF_ORDER an

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

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

Everywhere else the tool treats an absent answer as unproved until it repeats (codeAbsent 10 reads, notDone visibleReads, recordedReceipt visibleReads consecutive passes; line 100 says nothing trusts one read). meetSeed decides 'no node holds the waiting transaction of another step at this nonce' from one getTransaction per hash, and a null means this step goes out at market at that nonce. Mempools are per node: on a load-balanced RPC a backend that never received H, or a lagging one, answers null while H is pending elsewhere. That is exactly the case the guard exists for (line 239: 'normally this step's read was stale'). The step goes out; if its market bid is at least 10% over H it replaces H, and if the step was in fact done (the stale-gate case the README's reorg exception creates) it lands as a revert (addPaymentToken and setRevealCommit refuse a repeat; revertRetries is 0 for addPaymentToken so the run stops TX_FAILED) or as a second transaction. The opposite direction is also one read: `.catch(()=>true)` turns a 429/502 into 'held', which on the first call throws RESUME_OUT_OF_ORDER for a transaction no node holds, and on the revert-retry path (line 284, where failed always contains this step's name) raises last to the seed's fee so the retry bids at least 25% over a fee nobody holds; if that passes the ceiling fees() throws TIP_CEILING, hashes is empty, sent=true, and 8 attempts later the step throws. Line 272 (TIP_CEILING held check) uses the same single read: one null sets last=null, the next bid goes out at market below the held bid, is refused as underpriced, and climbs back to the ceiling through the remaining attempts. Fix: decide 'not held' only after visibleReads consecutive reads, spaced pollMs apart, in which every hash answers null; retry a failed read with the file's retry() helper and decide only on answers; apply the same rule at line 272.

**Reproduction**

Record: steps through setRevealCommit; addPaymentToken landed at nonce N-1 but its entry left the record (README section 5, accepted reorg case); queued=[{name:'approvePaidPrice',nonce:N,hashes:[H],fee:{maxFeePerGas:'20000000000',maxPriorityFeePerGas:'1000000000'},gasLimit:null}] with H pending in the pool. Resume: stableNonce()=N. The addPaymentToken gate (paymentTokens(USDC)!=0) is read 3 times from a lagging backend and says 0. send('addPaymentToken') calls meetSeed: seed.nonce===N, name differs; the single getTransaction(H) is routed to a backend whose pool lacks H and returns null; some(Boolean) is false; meetSeed returns. Expected: RESUME_OUT_OF_ORDER, nothing sent (what the same resume does when the read reaches a backend that has H). Actual: addPaymentToken is signed at nonce N with fees(null) = market (e.g. 30 gwei/2 gwei, above 1.1x H), replaces approvePaidPrice in the pool, and reverts on chain because USDC is already added: a `failed` entry, burned gas, TX_FAILED stop, and H must be resent later. Error direction: after a reverted setRevealCommit at nonce n with revertRetries left, nonce=n+1, queued seed of another step at n+1 whose hashes no node holds; getTransaction returns HTTP 429, catch gives true, failed contains 'setRevealCommit', so last=feeOf(seed.fee) and the retry bids max(market,1.25*seed.fee+1) instead of market.

### 3. Low: A reorg that moves the deploy or addPaymentToken transaction to a later block leaves a stale blockNumber in the record; the read-back then mismatches startBlock or usableAt on every resume and safe-ba

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

```
 const deployBlock=stepBlock('deploy EmberEvoGenesis')??Number(await retry(()=>genesis.startBlock()));
```

record() (line 142) stores rc.blockNumber from the receipt firstReceipt returned. settled() (lines 164-171) only checks that some receipt for rc.hash exists and the head is at or above rc.blockNumber; it never checks the receipt is still in that block, and record() keeps the old rc. The run then goes on for up to five more steps. The only later re-check is on --resume, and it re-reads only the single highest entry `top` (line 193). So when a 1-block reorg drops the block holding the deploy or addPaymentToken and the same transaction is re-included at the next height, the entry keeps the old height and nothing corrects it. The read-back builds its expectations from that height: expect.startBlock=String(deployBlock) (line 364) against the immutable startBlock=block.number the constructor wrote at the real height (contract line 246), and usdcUsableAt=blockTime(addBlock)+PAYMENT_TOKEN_DELAY (line 356) against block.timestamp+2 days at the real block (contract line 423). Both mismatch on a valid deployment, in all 10 passes, since expect is computed once. A resume does not fix it: top is the transferOwnership entry, so the stale entry is never re-read and every resume returns the same readBackMismatch. The partial file stays (line 475), the CLI exits 2, and safe-batch.mjs accept/open throw RECORD_HAS_MISMATCH (safe-batch.mjs line 199). The Safe cannot take ownership through the tool until the record is edited by hand, while the deployer key keeps every owner power. This answers README section 4 item 5 ('can a real reorg now stick a run'): yes, and section 5 lists only an entry that leaves the record, not one that stays with a wrong height. Fix: in the read-back take deployBlock and addBlock from fresh getTransactionReceipt reads of the recorded hashes (or compare against genesis.startBlock() and the PaymentTokenAdded event block), or on resume re-check every steps/failed entry with recordedReceipt and rewrite blockNumber/blockHash from the receipt, not only the top one.

**Reproduction**

State: the deploy lands in block B; firstReceipt returns rc with blockNumber=B. During settled() or any of the next five steps a 1-block reorg replaces B with B' (without the deploy) and the deploy is mined again at B+1 under the same hash, so getTransactionReceipt(hash) keeps answering and settled() passes. The run finishes all six steps. Read-back: stepBlock('deploy EmberEvoGenesis')=B, expect.startBlock='B', genesis.startBlock()=B+1 (constructor stores block.number). Expected: no mismatch (the record follows the chain, deployBlock=B+1). Actual: readBackMismatch=['startBlock'] in all 10 passes, exit code 2, partial file kept; `--resume` re-verifies only the transferOwnership entry and gives the same mismatch; `safe-batch.mjs accept <record>` throws 'RECORD_HAS_MISMATCH startBlock'. Same with addPaymentToken moved from height A to A+1: blockTime(A)+172800 differs from the stored usableAt by the 12 s slot difference, so paymentTokens(USDC).usableAt mismatches.

### 4. Low: send(): a node answering 'already known' (it holds this very transaction) is treated as not sent: the step waits only pollMs*3 (6 s on mainnet) instead of stepTimeoutMs and then replaces its own held

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

```
    }else if(fee&&mayHoldBid(e))last=fee;   // the next attempt must beat it, or it would be refused the same way
```

sent=true is set only when broadcastTransaction resolves (line 265). An error matched by mayHoldBid's held words ('already known', 'known transaction', 'already imported', 'same hash') means the node holds exactly this transaction (verified: ethers 6.17.0 gives UNKNOWN_ERROR for 'already known' and 'Known transaction', so refusedSend is false and the hash is kept, which is right). This is the README section 4 item 1 case of a gateway that delivered the transaction, retried internally and got the duplicate answer, and also the attempt after any broadcast that errored with a non-refusal (502, 429) although the node took it: that attempt leaves `last` unchanged, so the next one signs the same bytes and gets 'already known'. On that error the code sets last=fee but sent stays false, so line 279 waits firstReceipt(hashes,pollMs*3) instead of stepTimeoutMs. The next attempt beats `last` by 25% (fees(), line 150), so a transaction a node holds and that may be in the next block is replaced about 6 s after it went out. The tool's rule is to wait stepTimeoutMs before raising a bid; this path skips it. Cost: a tip 25% higher than needed per such cycle, up to TIP_CEILING (3x today's tip), and 2 of the 8 attempts per cycle, each bringing STEP_STUCK nearer. Fix: when the error is one of mayHoldBid's held words (not a bidTooLow refusal), set sent=true so the receipt wait is stepTimeoutMs, the same as for a taken bid; keep last=fee.

**Reproduction**

Mainnet timing (pollMs 2000, stepTimeoutMs 300000). Attempt 1 of approvePaidPrice: fees(null) gives tip T. The node takes the transaction, the gateway retries the same eth_sendRawTransaction and returns {code:-32000,message:'already known'}. In send(): refusedSend false (hash kept), mayHoldBid true (last=fee), sent=false, firstReceipt waits 6 s and returns null, continue. Attempt 2: fees(last) gives tip max(T,1.25T+1), a fresh hash at the same nonce, taken as a replacement. Expected: wait up to 5 min for the held transaction at tip T before replacing it. Actual: replaced about 6 s later at 1.25T; if the first one was not yet mined the step pays 25% more tip. Same sequence when attempt 1 gets HTTP 502 after delivery: attempt 2 re-signs identical bytes (fresh=false), gets 'already known', waits 6 s, and attempt 3 replaces at 1.25x.

### 5. Low: Audit 8b5de2de finding 3 is not fixed for Nethermind releases before 1.39.0 (July 2026): their refusals 'ReplacementNotAllowed', 'FeeTooLow', 'AlreadyKnown' and 'InsufficientFunds' match none of bidTo

`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));
```

Line 25 says the list covers Nethermind; README section 7 admits its constants were not read. Checked against the sources: in Nethermind 1.38.1 (AcceptTxResult.cs at that tag) the results are AlreadyKnown=new(1,nameof(AlreadyKnown)), FeeTooLow=new(3,nameof(FeeTooLow)), InsufficientFunds=new(6,nameof(InsufficientFunds)), ReplacementNotAllowed=new(11,nameof(ReplacementNotAllowed)), with ToString() => Message is null ? Code : $"{Code}, {Message}"; EthRpcModule.SendTx returns ResultWrapper.Fail(acceptTxResult.ToString(), ErrorCodes.TransactionRejected); TxPool.AddCore returns the bare ReplacementNotAllowed, FeeTooLowFilter the bare FeeTooLow, BalanceTooLowFilter the bare InsufficientFunds for remote transactions. (OldNonce is already 'nonce too low' at 1.38.1, so NONCE_MOVED does work there; the flow specialist's OldNonce claim does not hold.) Commit 693ab2b9 of 2026-06-30 ('align AcceptTxResult rejection messages with Geth canonical phrases'), first shipped in 1.39.0 on 2026-07-08, changed them to 'replacement transaction underpriced', 'transaction underpriced', 'already known', which the list matches; every earlier release, which an RPC provider may still run four months later, answers with the CamelCase words. Verified with ethers 6.17.0 getRpcError and the three predicates copied from lines 26/28/30: 'ReplacementNotAllowed', 'FeeTooLow, FeePerGas needs to be higher than 5', 'AlreadyKnown', 'InsufficientFunds' all give UNKNOWN_ERROR with refusedSend=bidTooLow=mayHoldBid=false. In send() that means: a refused replacement leaves `last` where it was, so the next attempt signs the same bid and the bid never climbs (the failure finding 3 describes); the refused hash is kept (line 269) as a never-held hash in the record, which the next resume waits stepTimeoutMs on at line 207; 'AlreadyKnown' does not raise `last`, so a resend of the same bytes loops 8 times at 6 s each. Besu (RpcErrorType.java at main) has a smaller gap: 'Upfront gas cost exceeds account balance' (-32004) is not mapped to INSUFFICIENT_FUNDS by ethers and matches nothing, and 'Gas price below current base fee' matches nothing, while its 'Nonce too low', 'Replacement transaction underpriced', 'Known transaction' and 'Gas price below configured minimum gas price' are matched. Fix: add /ReplacementNotAllowed|FeeTooLow/ to bidTooLow, /AlreadyKnown/ to mayHoldBid, /InsufficientFunds|upfront gas cost exceeds/ to refusedSend (case-insensitive, no spaces assumed), and add CLI tests whose stand-in RPC answers with these exact strings.

**Reproduction**

Predicate check (ethers 6.17.0): provider.getRpcError({method:'eth_sendRawTransaction',params:['0x02f8']},{error:{code:-32010,message:M}}) for M in 'ReplacementNotAllowed', 'FeeTooLow, FeePerGas needs to be higher than 5', 'AlreadyKnown', 'InsufficientFunds' gives code UNKNOWN_ERROR and refusedSend=false, bidTooLow=false, mayHoldBid=false; the geth strings and Nethermind 1.39+ strings classify correctly. Run scenario (README section 5's accepted case): RPC_URL backed by Nethermind 1.38.x. At step setRevealURIs the run signs T1 (tip 1 gwei, maxFee F), checkpoints the hash with fee null, the node takes T1, the process dies before the second checkpoint; the base fee rises above F. Resume: seed.name===name, last=placed=null, attempt 1 bids fees(null) = market with the same 1 gwei tip; Nethermind answers 'ReplacementNotAllowed'. Expected (what geth's words give): bidTooLow, the fresh hash removed, last raised, attempt 2 bids 25% more and replaces T1. Actual: the hash is kept, last stays null, attempts 2..8 re-sign the same market bid and get the same refusal, the step ends 'STEP_STUCK setRevealURIs (not locked): UNKNOWN_ERROR (ReplacementNotAllowed)' with the never-held hashes in pending, and every resume repeats it (waiting 5 min on them first) while T1 stays unmineable.

### 6. Low: setRevealCommit's revert retries re-send without re-reading the gate: a stale 'not done' read costs three reverted transactions, and the retry's meetSeed (failed always holds this step's name there) o

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

```
    if(revertRetries-->0){hashes=[];last=placed=null;await meetSeed();continue;}
```

send() retries a reverted setRevealCommit up to twice (revertRetries:2, line 344) and never re-checks isDone. The contract refuses any second setRevealCommit with the same commit: _heldBefore(commit) is true once revealCommit==commit (EmberEvoGenesisCandidate.sol lines 312 and 339-340, checked before the acceptance), so every retry reverts REVEAL_COMMIT_INVALID whatever `previous` the retry reads. The first send happens on a stale gate once the record lost the step (README section 5: a mined entry can leave the record after a reorg while receipts lag, and notDone then decides from 3 reads of a possibly lagging node), so the first revert is a cost the README accepts; the two retries are pure waste. Submission 14 adds meetSeed to the retry path right after record() pushed this step into `failed`, so failed.some(f=>f.name===name) (line 245) is always true there: RESUME_OUT_OF_ORDER can never fire on a retry, and when another step's transaction waits at the new nonce the retry takes last=feeOf(seed.fee) and replaces that live transaction with one that is certain to revert. Fails safe (the contract refuses the repeat, the replaced step is resent later) but burns gas three times and removes a valid waiting transaction. Fix: before each revert retry run the step's done check again (same isDone, visibleReads reads) and return if it now says done; or retry only when the revert reason is REVEAL_ACCEPTANCE_EXPIRED or REVEAL_ACCEPTANCE_INVALID (eth_call at the failing block). Key meetSeed's override on a failed entry that existed before this send, not on the one this attempt just wrote.

**Reproduction**

Resume record: steps [deploy, setRevealURIs], queued [{name:'addPaymentToken(USDC, 1600 GOLD)',nonce:N+1,hashes:[H],fee:{maxFeePerGas:'30000000000',maxPriorityFeePerGas:'2000000000'},gasLimit:null}]. On chain setRevealCommit landed at N but the record lost it; H is held in the pool. notDone('setRevealCommit') reads revealCommit()==0 three times from a lagging backend. Expected: nothing sent, or a stop once the commit is seen set. Actual: send('setRevealCommit') goes out at N and reverts REVEAL_COMMIT_INVALID (failed entry, nonce N+1). Retry 1: meetSeed finds seed H at N+1, getTransaction(H) non-null, failed has 'setRevealCommit', last=H's fee; the retry bids 1.25x over H, replaces addPaymentToken at N+1 and reverts again. Retry 2 reverts at N+2. Then TX_FAILED setRevealCommit: three reverted transactions' gas, addPaymentToken's waiting transaction gone and resent later.

### 7. Info: Resume re-checks only the highest record entry: an entry below it that a 2-block reorg also removed keeps plan+steps+failed one too high, so the next step is signed at a gap nonce and the run spends a

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

```
  const top=[...steps,...failed].reduce((a,b)=>!a||b.blockNumber>=a.blockNumber?b:a,null);
```

plan+steps+failed is a valid lower bound only while every recorded entry is still mined. recordedReceipt checks just `top`. A reorg of two or more blocks that removes the top entry and the one below it (neither re-mined) drops only the top; the lower entry stays and the count is one too high. With floor 0 and a correct stableNonce, Math.max at line 211 picks the too-high value, the lower step's gate is skipped (stepBlock finds the stale entry, line 306), and the next step is signed at a nonce with a gap below it. Nodes queue such a transaction and never mine it, so every attempt's firstReceipt times out: 8 attempts x stepTimeoutMs (40 min) with the bid climbing, then STEP_STUCK. The specialist's claim that every resume repeats this is wrong: on the second resume the stale entry is now `top`, recordedReceipt drops it, nonce falls back to the real count, the lower step is resent and the queued transaction is handled as the seed. The cost is one wasted cycle and bids raised toward the ceiling for nothing; no gas is spent. Mainnet reorgs of 2+ blocks are rare. Fix: on resume run recordedReceipt over every steps/failed entry within a reorg window of the head, or use plan+steps+failed only after the receipt of the entry at the highest nonce is confirmed visible.

**Reproduction**

Record: steps=[deploy@n0 (block B), setRevealURIs@n0+1 (B+1), setRevealCommit@n0+2 (B+2, top)]. A 2-block reorg replaces B+1 and B+2 and neither transaction is re-mined. Resume: recordedReceipt(top) sees another block at B+2 without its hash on 3 reads, drops setRevealCommit; setRevealURIs is never checked. stableNonce()=n0+1, planNonce+steps.length+failed.length=n0+2, nonce=n0+2. Expected: setRevealURIs (gate not done) resent at n0+1, then setRevealCommit at n0+2. Actual: the setRevealURIs gate is skipped because stepBlock finds the stale entry; setRevealCommit is signed at n0+2 behind an empty n0+1 and never mines; 8 attempts time out and the run ends STEP_STUCK. The second resume drops the stale setRevealURIs entry and recovers.

### 8. Info: pending.fee lags the bid a node holds: the pre-broadcast checkpoint and a non-refusal error record the previous bid, so a resume re-bids exactly the held fee and is refused once

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

```
  const waiting=()=>({pending:{name,nonce,hashes,fee:feeText(placed),gasLimit:knownLimit===null?null:knownLimit.toString()}});
```

waiting() writes fee:feeText(placed). At the first checkpoint (line 263) the new hash is in `hashes` but `placed` is still the previous bid. After a broadcast the node took but whose answer was a non-refusal error (TIMEOUT, 502), the hash stays (right) but `placed` is never raised (line 265 is skipped), so the STEP_STUCK checkpoint at line 289 records a fee lower than the held bid. On resume line 249 sets last=placed=that older fee and fees() bids max(market, old*1.25+1), which is exactly the held bid whenever the market has not moved. For setRevealCommit, whose request changes on every resend, the new hash with the same fee is refused 'replacement transaction underpriced' and one attempt is lost; for other steps the same bytes get 'already known' and lose an attempt the same way. README section 5 accepts only the fee:null case. Within one run the same thing self-corrects after one wasted attempt. Fix: record the highest signed fee of any kept hash in pending.fee (or treat a kept hash after a non-refusal error as possibly held: placed=fee for record purposes).

**Reproduction**

Attempt 1 goes out with placed P={maxFee 20 gwei, tip 1 gwei}. Attempt 2 bids F={25000000001,1250000001}, is delivered, but the RPC answers SERVER_ERROR 502: hash kept, last and placed stay P. Attempts 3..8 fail the same way (RPC flapping), STEP_STUCK writes pending.fee=P. Resume: last=P, fees(P) with the market unchanged returns F. setRevealCommit signs a new acceptance, so the hash is new and the fee equals the held F. Expected: the bid clears the held F by 10%. Actual: refused as underpriced (bidTooLow), last=F, and only the next attempt bids high enough.

---

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