Skip to content

fix(l2): make batch-commit resends able to replace, and de-flake the shared-bridge test - #7151

Merged
ilitteri merged 9 commits into
mainfrom
fix/shared-bridge-distinct-l1-keys
Aug 20, 2026
Merged

ilitteri merged 9 commits into
mainfrom
fix/shared-bridge-distinct-l1-keys

Conversation

@ilitteri

Copy link
Copy Markdown
Collaborator

Motivation

Integration Test Shared Bridge - Normal fails intermittently with Receiver balance did not increase correctly at test/tests/l2/shared_bridge.rs:409. It hit main on Aug 3 (0cc4572c5) and Aug 13 (f2998963d), and release/v24.0.0 on Aug 12 (8f3d2ca05), always with the same message hash 0x66867250…5be951 — so the test is not wrong about what it expects, the message genuinely never arrives. In the failing runs L2B never commits batch 1, its L2→L2 message never becomes pending on the router, and L2A's watcher logs L2 message not found in pending messages 153 times with 0 verified.

Three separate defects combine to produce that:

1. Both sequencers commit from the same L1 account. crates/l2/docker-compose.yaml gives ethrex_l2 --committer.l1-private-key 0x385c… and the shared-bridge override gave ethrex_l2_b the same key (likewise the proof-coordinator key). They post to one L1 on the same 15 s timer, so their commit loops race for the same nonce and the loser is rejected — first as an underpriced replacement, then as a too-low nonce.

2. The loser cannot recover, which is a bug beyond CI. bump_gas_generic_tx computed the blob fee as 1 + (bump_percentage / 100) * 10 and then divided by ten. For the only bump either call site passes, 30, that evaluates to fee * 1 / 10: every retry lowered max_fee_per_blob_gas by 90%, so a batch commitment could never re-price itself — on CI or on a real L1 during a fee spike. Blob replacements also require the fee to double (DEFAULT_BLOB_PRICE_BUMP_PERCENT), and that threshold applies to the fee cap and the tip as well, so bumping those by 30% could not have replaced a blob transaction either. Separately, the retry loop never noticed when the nonce had become unreachable, so one NonceTooLow burned all ten attempts in ~3.5 s and reported "max retries reached", hiding the cause.

3. The test slept a fixed 180 s and then asserted, which made the result depend on whatever else the job was doing. The first failure in this job's history is the run of #7029, which aligned the cargo test --no-run prebuild's features with the run step so the test binary is no longer rebuilt inside it. Job step timestamps show the window between "Start Sequencer L2B" and the assertion collapsing from 2 m 35 s to 3 s, moving the assertion onto the sequencers' cold start — where batch 1 carries all 192 privileged deposit mints and the two co-nonced committers collide from their first commit. Worth noting the direction: the passing Jul 22 run had more nonce collisions (72 underpriced + 76 too-low) than any failing run, because they happened before the test looked.

Description

  • L2B gets its own committer and proof-coordinator accounts, and its deployment authorizes them. Changing the keys alone is not enough: Timelock.commitBatch is onlyRole(SEQUENCER), and the role is granted from the deployer's --committer.l1-address / --proof-sender.l1-address, which both default to L2A's accounts. Without the matching addresses every L2B commitBatch reverts at gas estimation as an unauthorized caller — the same end symptom, reached a different way. Neither option is forwarded to the deployer container by docker-compose.yaml, so they are set on the override's contract_deployer, leaving L2A's deployment on the defaults.
  • bump_gas_generic_tx raises every fee dimension by a bump that clears the mempool's replacement threshold for the transaction's category — BLOB_FEE_BUMP_PERCENT (100) for blob-carrying transactions, FEE_BUMP_PERCENT (30) otherwise — using saturating arithmetic so an absurd starting fee cannot overflow.
  • The retry loop stops spending attempts that cannot succeed: it abandons the transaction with UnreachableNonce once the chain's nonce has passed it (checked against the chain, not against the rejection text, which differs between clients), and reports the reason when the maximum_allowed_max_fee_per_gas / _blob_gas clamps leave a bid the mempool already rejected.
  • Every shared-bridge assertion now waits for its own condition instead of sharing one fixed sleep, with a timeout more generous than the 180 s it replaces. The L1 deposits accounting is waited on separately rather than assumed settled once the L2B credit lands: the two are updated by different transactions on different chains and nothing orders them. The forced-inclusion test's expiry and verification waits are deliberately left as sleeps — they are waiting for something not to happen.
  • Hardening: a test pins L2B's private keys to the addresses its deployment authorizes, so drift between the two blocks of the override file fails there instead of surfacing at runtime as an ABI-undecodable AccessControlUnauthorizedAccount revert and a chain that silently stops committing. Unit tests cover both replacement thresholds, monotonicity across ten retries, saturation, and — so they cannot silently stop discriminating — that the previous blob formula fails them.

This is deliberately not a revert of #7029. That PR removed an accidental delay which had been masking defects 1 and 2 since the shared-bridge test landed; restoring the delay would hide them again.

Also worth knowing, not changed here: this job is gated github.event_name != 'merge_group', so the merge queue never runs it and a PR can land without it ever executing against the merged result. Combined with the paths filter, it effectively only runs on pushes to main — which is why a ~3% flake sat unnoticed for weeks.

Checklist

  • Updated STORE_SCHEMA_VERSION (crates/storage/lib.rs) if the PR includes breaking changes to the Store requiring a re-sync. — n/a, no Store changes

…oordinator

accounts instead of reusing L2A's. Both sequencers post to the same L1, so the
two committers were racing for the same nonce, and the loser could not recover:
its send was rejected as an underpriced replacement, then as a too-low nonce
until it exhausted its retries, after which it re-wedged on the same batch on
every cycle. Whenever the two commit loops stayed phase-locked, L2B never
committed a batch, its L2->L2 message never became pending on the router, and
test_shared_bridge failed with an unchanged destination balance.

The new keys are rich accounts from fixtures/keys/private_keys_l1.txt, funded in
fixtures/genesis/l1.json. Their only other L1 use is the deployer's rich deposit,
sent from each rich account itself, which completes before either sequencer
starts.
… committer

and proof-coordinator keys is not enough on its own: the Timelock is initialized
with the deployer's committer and proof-sender address options as its authorized
sequencers, and both default to the accounts ethrex_l2 uses. With only the keys
changed, every commitBatch from L2B reverted at gas estimation as an
unauthorized caller, so L2B committed nothing at all -- the same end symptom as
the nonce race, reached a different way.

Neither option is forwarded to the deployer container by docker-compose.yaml, so
exporting them from the workflow would not have reached it; they are set on the
override's contract_deployer instead, leaving L2A's deployment on the defaults.
…d stop

retrying when it cannot.

The blob branch of bump_gas_generic_tx computed `1 + (bump / 100) * 10` and then
divided by ten. For the only bump either call site passed, 30, that evaluates to
`fee * 1 / 10`: every retry *lowered* max_fee_per_blob_gas by 90%. A batch
commitment could therefore never re-price itself, on CI or on a real L1 during a
fee spike. Blob transactions also need a far larger bump than regular ones --
peer clients and ethrex's own DEFAULT_BLOB_PRICE_BUMP_PERCENT require the fee to
double -- and the threshold applies to the fee cap and the tip as well, not only
to the blob fee, so a 30% bump on those dimensions could not have replaced a
blob transaction either.

Two ways the retry budget was being spent on attempts that could not succeed:

- Once the account has moved past the transaction's nonce, no fee gets it
  included; all ten attempts fail identically and the caller is told "max
  retries reached", which hides the cause. The nonce is now checked against the
  chain -- not against the rejection text, which differs between clients -- and
  the attempt is abandoned with UnreachableNonce.
- The maximum_allowed_max_fee_per_gas / _blob_gas clamps can cancel out the
  bump, leaving a bid the mempool already rejected. That is now detected and
  reported instead of resent.

Fees are raised with saturating arithmetic so an absurd starting fee cannot
overflow. Unit tests cover both thresholds, monotonicity across ten retries,
saturation, and -- so the tests cannot silently stop discriminating -- that the
previous formula fails them.
…utes.

Each assertion now polls for the state it is about to check, with a timeout more
generous than the sleep it replaces. A fixed sleep asserts on whatever state the
system happens to be in when the timer fires, so a change in how long the rest
of the job takes flips the result without anything about the chains changing --
which is exactly how this test started failing: a CI change removed a rebuild
that used to sit between starting the sequencers and starting the test, and the
assertion window moved onto the sequencers' cold start.

The L1 deposits accounting is waited on separately rather than assumed to be
settled once the L2B credit lands: the two are updated by different transactions
on different chains and nothing orders them.

Also pins L2B's sequencer keys to the addresses its deployment authorizes as
SEQUENCER. They live in separate blocks of the same override file, and a
mismatch is invisible until runtime, where it surfaces as a commitBatch that
reverts at gas estimation with an ABI-undecodable AccessControlUnauthorizedAccount
and an L2 that silently stops committing.
@ilitteri
ilitteri requested a review from a team as a code owner August 14, 2026 16:10
@github-actions

github-actions Bot commented Aug 14, 2026 •

Copy link
Copy Markdown

⚠️ Known Issues — intentionally skipped tests

Source: docs/known_issues.md

rpc-compat log-bearing cases excluded

Where: KNOWN_EXCLUDED_TESTS in .github/scripts/check-hive-results.sh counts out
eight hive rpc-compat cases — the four eth_getLogs cases, eth_getBlockReceipts/get-block-receipts-latest,
and three eth_getTransactionReceipt cases. They are exactly the cases whose recorded
response contains at least one log object; every case with an empty log array still runs.
Note this leaves eth_getLogs with no rpc-compat coverage at all, since all four of its
cases are in the set.

Why: ethrex populates blockTimestamp on log objects, as geth, besu, nethermind, reth
and erigon all do. hive's rpc-compat compares responses byte-exactly (jsondiff.FullMatch;
the lenient checkJSONStructure path applies only to cases upstream marks speconly), and
the corpus is pinned to execution-apis d08382ae (2025-02-10), whose recordings predate the
field — it entered the schema in execution-apis#639 and the fixtures in #846 (2026-07-22).
So the extra key cannot match, and this is a property of the pin rather than of the response.

The pin cannot move, and this is not temporary. The pin sits one commit before
execution-apis#627, which moved the test chain to a pre-merge genesis: the current corpus has
~36 proof-of-work blocks before its terminal total difficulty. ethrex does not support
pre-merge chains and will not, so importing that chain.rlp fails at block 1 —
validate_block_header has no pre-London base-fee path. Every revision carrying
blockTimestamp in its fixtures also carries that chain, so there is no revision that
satisfies both. Nor can the corpus be patched locally: rpc-compat's Dockerfile clones
ethereum/execution-apis by hard-coded URL, so the branch buildarg cannot point at a fork.

Coverage: the field itself is pinned by
block_timestamp_is_on_the_log_and_not_on_the_receipt in
crates/networking/rpc/types/receipt.rs, which asserts it is present on each log and absent
from the receipt level.

Removal: delete the entries if ethrex ever gains pre-merge chain import, or if upstream
marks these cases speconly so they are type-checked instead of compared byte-for-byte.


The stateless schema id does not identify the encoding

Where: STATELESS_INPUT_SCHEMA_ID in crates/common/types/stateless_ssz.rs.

Upstream keeps the stateless input schema id at 0x1501
(fork_index 0x15 << 8 | revision 0x01) across incompatible body changes. Three
encodings have now shipped under it: tests-zkevm@v0.6.2, then #3248 + #3278,
then #3356, which moved state, codes and public_keys from SszList to
ProgressiveList. ethrex speaks the last one.

The consequence is that the 2-byte prefix cannot be used to detect a stale or
mismatched bundle. A wrong-dialect input is accepted by the id check and then
fails later — in SSZ decode, or on a root that does not match — rather than being
rejected up front for what it is. only_amsterdam_schema_id_decodes therefore
proves less than its name suggests.

Worth raising upstream: a revision field that does not move across a body change
provides no version negotiation at all.


ZisK guest program hash changes with the unsync_cell gate

Where: crates/common/types/block.rs, transaction.rs.

The gate on the single-threaded unsync_cell::OnceCell moved from
all(feature = "eip-8025", target_arch = "riscv64") to
all(feature = "zisk", target_arch = "riscv64") when the eip-8025 feature was removed.

The guest ELFs were previously built --features "<zkvm>-build-elf,ci", which never enabled
eip-8025, so they compiled the atomic once_cell variant. bin/zisk/Cargo.toml does enable
ethrex-common/zisk, so the ZisK guest now compiles the unsafe impl Sync cell instead.
That changes the ELF bytes and therefore the program hash and verification key.

This is intended (the guest is single-threaded, so the unsync cell is sound and cheaper), but it
is a VK change rather than a no-op refactor, and the diffstat presents it as a file rename
(eip8025_cell.rs → unsync_cell.rs). Anyone pinning a ZisK VK across this change must
re-register it. The stateless-validator crate now forwards ethrex-common/zisk from its own
zisk feature so the two ZisK guests do not disagree on the cell type.


Release signing key is an unprotected repository secret

Where: .github/workflows/tag_release.yaml.

MINISIGN_SECRET_KEY is a plain repository secret. There is no environment: on
finalize-release or dry-run-release-assets, and gh api repos/lambdaclass/ethrex/rulesets
shows only branch-targeted rulesets, so the github.ref_type == 'tag' condition is a workflow
check rather than an enforced boundary: anyone who can push a tag can reach the signing key.

This is a repository-settings change, not a code change, so it is recorded here rather than
fixed in the tree. Recommended:

  1. Move MINISIGN_SECRET_KEY / MINISIGN_PASSWORD into a GitHub Environment with required
    reviewers, and add environment: to the two jobs that sign.
  2. Add a ruleset targeting refs/tags/v* restricting who may create release tags.

Until then, the compromise of that key is silent and durable: signatures would still verify
against the committed .github/minisign.pub.

@github-actions github-actions Bot added the L2 Rollup client label Aug 14, 2026
@github-actions

Copy link
Copy Markdown

🤖 Codex Code Review

  1. crates/l2/sdk/src/sdk.rs:923 and crates/l2/sdk/src/sdk.rs:946: the new previous_fee_bid stop condition is not tied to an actual UnderpricedReplacement rejection. Any failed send_generic_transaction attempt increments the loop into the same path, so a transient RPC failure, already known, or other non-mempool error can be turned on the next iteration into a hard "replacement would keep being rejected as underpriced" error if the fee cap clamp restores the same bid. That changes retry behavior for capped sequencer txs and can wedge commit/proof submission after a temporary node issue. This guard should only fire after a structured underpriced-replacement error, not after arbitrary send failures.

  2. crates/l2/sdk/src/sdk.rs:1038 and crates/l2/sdk/src/sdk.rs:1044: saturating_mul(...)/100 does not “raise” extreme fees; it can shrink them dramatically on overflow (u64::MAX -> u64::MAX / 100, U256::MAX -> U256::MAX / 100). That violates the helper’s documented contract and makes retries move farther from the replacement threshold in the only overflow regime they are meant to protect. The test at crates/l2/sdk/src/sdk.rs:1682 currently locks in this shrinking behavior, so fixing the helper later would require changing the test too. If overflow must be handled, clamp to the original value or MAX, not to a smaller bid.

The blob-fee regression fix itself and the shared-bridge test improvements look directionally correct.

I couldn’t run cargo test here because rustup attempted to write under /home/runner/.rustup, which is read-only in this environment.


Automated review by OpenAI Codex · gpt-5.4 · custom prompt

@github-actions

Copy link
Copy Markdown

🤖 Kimi Code Review

This is a high-quality PR addressing real operational issues in the L2 shared bridge. The changes are well-documented, thoroughly tested, and fix critical bugs in transaction fee bumping and test reliability.

Critical Fixes (Correct)

1. Blob Fee Calculation Bug (sdk.rs:1028-1033)
The old formula 1 + (bump_percentage / 100) * 10 with bump_percentage=30 evaluated to 1, causing a 90% fee reduction on every retry. The new bump_u256 correctly implements percentage increases. The regression test the_previous_blob_formula_fails_the_same_checks properly guards against reintroducing this.

2. Nonce Exhaustion Detection (sdk.rs:944-956)
Adding the check for tx_nonce < account_nonce prevents the retry loop from


Automated review by Kimi (Moonshot AI) · kimi-k2.5 · custom prompt

@github-actions

github-actions Bot commented Aug 14, 2026 •

Copy link
Copy Markdown

Lines of code report

Total lines added: 209
Total lines removed: 0
Total lines changed: 209

Detailed view
+---------------------------------+-------+------+
| File                            | Lines | Diff |
+---------------------------------+-------+------+
| ethrex/crates/l2/sdk/src/sdk.rs | 1467  | +209 |
+---------------------------------+-------+------+

@MegaRedHand MegaRedHand left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to update documentation?

Comment thread crates/l2/sdk/src/sdk.rs Outdated
Comment thread crates/l2/sdk/src/sdk.rs Outdated
Comment thread crates/l2/sdk/src/sdk.rs
Comment thread crates/l2/sdk/src/sdk.rs Outdated
Comment thread crates/l2/sdk/src/sdk.rs
client.send_raw_transaction(encoded_tx.as_slice()).await
}

pub async fn send_tx_bump_gas_exponential_backoff(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This logic bumps the fee on any error, even transient ones. Also, the 100% bump with 10 retries can result in 1024x the fee on blob transactions in the worst case, with no user-configurable bump.

We should revisit this in the future.

@MegaRedHand MegaRedHand left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM. The previous comments are improvements that can be added in other PRs

Bump by adding the increment instead of scaling by `100 + bump`: the product
saturates on a fee near the type's maximum, and dividing a saturated product by
100 lands two orders of magnitude below the input, so an absurd starting fee was
bumped *downwards* -- the same shape as the blob-fee defect this branch fixes.
The unit test had asserted that decrease as expected behaviour; it now pins the
invariant that matters, that a bump never returns a lower fee.

Also floor the increment at one wei. A percentage alone rounds to zero below
`100 / bump_percent` wei, so the resend would carry the pooled transaction's
exact fee and be rejected as an underpriced replacement. This goes beyond the
review comment but is the same failure mode at the other end of the range.

Take both bump percentages from the mempool's own `DEFAULT_PRICE_BUMP_PERCENT`
and `DEFAULT_BLOB_PRICE_BUMP_PERCENT` rather than restating them, so the client
and the admission rule cannot drift apart, and read the same constants in the
tests instead of duplicated literals. `ethrex-blockchain` is already in this
crate's dependency graph through `ethrex-rpc`, so this adds no compile units.
Note this lowers the non-blob bump from 30% to 10%, which still clears the
threshold exactly and reduces how far fees compound across ten retries.

Finally, a consumed nonce no longer implies the send failed: if an earlier
attempt of ours was accepted and has since been mined, return that hash instead
of `UnreachableNonce`, so a transaction that is on chain is never reported as an
error.
`tooling` and `tooling/zkevm_bench` both resolve ethrex-sdk (the latter through
ethrex-l2), so declaring ethrex-blockchain on it leaves their lockfiles stale and
`make check-cargo-lock` fails under --locked. Regenerated with
`make update-cargo-lock`; the only change in either file is the new dependency
entry.
@ilitteri
ilitteri added this pull request to the merge queue Aug 19, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to Branch Protection failures Aug 19, 2026
You're not authorized to push to this branch. Visit "About protected branches" for more information.
@ilitteri
ilitteri added this pull request to the merge queue Aug 20, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to Branch Protection failures Aug 20, 2026
You're not authorized to push to this branch. Visit "About protected branches" for more information.
@ilitteri
ilitteri added this pull request to the merge queue Aug 20, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to Branch Protection failures Aug 20, 2026
You're not authorized to push to this branch. Visit "About protected branches" for more information.
@ilitteri
ilitteri added this pull request to the merge queue Aug 20, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to Branch Protection failures Aug 20, 2026
You're not authorized to push to this branch. Visit "About protected branches" for more information.
@ilitteri
ilitteri merged commit bf94de7 into main Aug 20, 2026
59 checks passed
@ilitteri
ilitteri deleted the fix/shared-bridge-distinct-l1-keys branch August 20, 2026 17:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

L2 Rollup client

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

2 participants