Skip to content

Support splitting Linera block into multiple EVM transactions.#6295

Open
deuszx wants to merge 29 commits into
testnet_conwayfrom
multi-tx-burn-processing
Open

Support splitting Linera block into multiple EVM transactions.#6295
deuszx wants to merge 29 commits into
testnet_conwayfrom
multi-tx-burn-processing

Conversation

@deuszx
Copy link
Copy Markdown
Contributor

@deuszx deuszx commented May 13, 2026

Motivation

A Linera block can carry enough burns to exceed any EVM chain's block gas limit (~30M on Ethereum, ~240M on Base), in which case FungibleBridge.addBlock(cert) does not fit in one EVM transaction. Today the
relayer cannot make progress on such a block: Microchain.verifiedBlocks blocks a retry, and there is no per-burn entry point. This PR makes the bridge multi-tx-friendly so the relayer can split such a block
into several smaller EVM transactions.

Proposal

  • Microchain.verifiedBlocks mapping dropped; addBlock is now idempotent on the subclass's per-burn dedup (processedBurns keyed on (height, Event.index)). Documented requirement that subclasses MUST be idempotent under repeated calls.
  • New FungibleBridge.processBurns(bytes cert, uint32 txIndex, uint32[] eventPositionsInTx) entry point: verifies the cert once and releases the subset of burns at the requested positions within a single transaction. Direct body.events[txIndex][pos] access — no nested-loop scan.
  • processBurns is idempotent like _onBlock: positions already in processedBurns are skipped silently rather than reverted, so overlap with a prior addBlock (or a racing/retrying processBurns) doesn't poison the whole chunk.
  • Shared helpers _burnKey, _isMatchingBurn, _releaseBurn extracted from _onBlock + processBurns to keep the dedup-key invariant in one place and tighten the matching logic. _releaseBurn applies checks-effects-interactions (sets the dedup flag BEFORE token.transfer) for both entry points — a reentrancy defense in depth.
  • Off-chain PendingBurn gains tx_index and event_pos_in_tx fields alongside the existing event_index (still the on-chain dedup key). The scanner's find_burn_events widens its return tuple; SQLite pending_burns / finished_burns add two columns.
  • process_pending_burns does per-height dispatch (cert fetch + persist + decide addBlock vs chunked) with branches factored into helpers: submit_addblock or submit_chunked.
  • Per-burn retry accounting: cert-fetch and gas-estimate failures are infrastructure-level and no longer consume any burn's retry budget. addBlock success leaves retry counts alone (completion is observed asynchronously by check_burn_completion); addBlock submit failure bumps retry for every burn at the height. Chunked submit failure bumps retry only for that chunk's burns; remaining chunks still attempt submission.
  • Permanently-oversized burns (single position still doesn't fit) are marked failed and drop out of subsequent snapshots, instead of poisoning their tx group on every retry pass.

Test Plan

  • New Forge tests on linera-bridge/src/solidity/:

    • MicrochainIdempotencyTest::test_addBlock_can_be_called_repeatedly_for_same_cert — re-entry on the same cert is now allowed.
    • Eight FungibleBridgeProcessBurnsTest cases — single_position_marks_processed, multi_position_marks_both_processed, already_processed_skips (idempotent no-op + no double-release), tx_index_out_of_range_reverts, event_pos_out_of_range_reverts, non_burn_event_reverts, empty_positions_reverts, partial_overlap_releases_remaining (settle pos 1 then [0,1] → pos 0 settles, pos 1 silently skipped).
  • Rust unit tests under cargo test -p linera-bridge --features relay:

    • relay::settlement::tests::estimate_fits_* — 8 cases covering Ok, gas required exceeds, exceeds block gas limit, anvil's Out of gas: gas required exceeds allowance: N (empirically verified against
      anvil 1.6 for both calldata-too-large and infinite-loop constructors), real contract revert with non-empty data (bubbles up), bare out of gas (bubbles up — not a fits signal), unrelated RPC error, and
      transport-layer error.
  • Existing evm::microchain::tests::test_microchain_* flipped to assert the new idempotent behaviour.

  • New e2e test linera-bridge/tests/e2e/tests/multi_tx_burn_chunking.rs:

    • Anvil's block gas limit is dialled down via evm_setBlockGasLimit after the bridge deploys.
    • Chain B submits a single Linera block carrying 8 burns to 8 distinct recipients.
    • The relayer's addBlock(cert) estimate exceeds the limit and falls back to chunked processBurns per tx; all 8 burns settle, balances match 1 * 10^18, linera_bridge_burns_completed = 8.
  • Existing multiple_burns_same_block and multiple_burns_same_recipient_across_blocks e2e tests still cover the per-burn isBurnProcessed semantics from Fix the bug in relayer logic responsible for marking Linera PendingBurn txn as finished on EVM #6276 and remain unchanged.

Release Plan

  • These changes should be backported to the main branch.

Links

@deuszx deuszx force-pushed the multi-tx-burn-processing branch from 74de1cd to 63fefe2 Compare May 14, 2026 10:11
deuszx added 14 commits May 16, 2026 15:29
Empty eventPositionsInTx silently paid for cert verification with no
work to do. Reject eagerly so caller bugs surface early.
Mirrors _onBlock's idempotent semantics. Overlap with a prior addBlock
or a racing/retrying processBurns no longer reverts the whole chunk —
already-released burns are skipped silently and the rest still settle.
Removes the all-or-nothing fragility flagged in PR review.
A bare "out of gas" message can fire for reasons unrelated to the block
gas limit — e.g. too-low tx gas cap, or contract state consuming more
than estimated. Matching it as "doesn't fit" sent the relayer down the
chunking path and masked real misconfigurations behind retry churn.
Keep the node-specific signals ("gas required exceeds",
"exceeds block gas limit", anvil's "execution reverted" + data: "0x").
Returns (height, event_indices, by_tx) per height under a single
monitor read so process_pending_burns drives chunking and retry/cert
persistence off one consistent snapshot. Previously the second view
came from another read with a different (no max_retries) filter,
risking TOCTOU drift between the two views.
event_index_for_pos maps a (height, tx_index, pos_in_tx) triple back to
the event_index that mark_burn_retried/mark_burn_failed need — letting
the chunked processBurns path bump retry per-chunk and mark oversized
burns failed without a second snapshot read.

pending_burns_by_height_and_tx now filters burns whose failed flag is
set, so a once-marked-failed burn (next commit's oversized path) drops
out of subsequent snapshots instead of poisoning the chunking loop on
every pass.
Retry budget previously bumped unconditionally for all burns at a
height once per pass — a handful of transient RPC blips (cert fetch
timeout, gas-estimate failure) could push burns past max_retries and
silently strand them. Now:

- Cert fetch / gas-estimate failures: skip the height, bump nothing.
  These are infrastructure-level, not burn-level.
- addBlock submit Ok: no retry bump (completion is async).
- addBlock submit Err: bump retry for every burn at the height.
- Chunked processBurns submit Err: bump retry for that chunk's burns
  only; remaining chunks still attempt (each is an independent tx).

A single-position chunk that still doesn't fit (oversized burn) used
to break out of the per-tx loop, discarding sibling chunks already
known to fit and burning every retry pass re-rediscovering it. Now:

- Continue draining the stack so other slices still find chunks.
- Mark the oversized burn  so the next pending_burns_by_height
  snapshot excludes it — no more poisoning its tx group every pass.
- Still submit the chunks that did fit before/around it.
Tuple form (BlockHeight, Vec<u32>, Vec<(u32, Vec<u32>)>) was easy to
misuse positionally. Named fields document each role. Ord keyed on
height makes BTreeSet height-sorted structurally and rejects a second
entry for the same height (which never happens in practice).
process_pending_burns's per-height body collapsed to: cert fetch +
persist + dispatch. Each branch of the dispatch now lives in its own
helper:

- submit_addblock: forward_cert + per-burn retry bump on failure
- submit_chunked: per-tx loop, calls split + mark_oversized + submit
- split_to_fit: pure binary-search chunking, returns (chunks, oversized)
- mark_oversized_failed: looks up event_indices, marks each failed
- submit_chunks_with_retry: per-chunk submit + per-chunk retry bump
@deuszx deuszx force-pushed the multi-tx-burn-processing branch from bcc059b to ec28611 Compare May 18, 2026 18:52
@deuszx deuszx marked this pull request as ready for review May 18, 2026 18:52
Copy link
Copy Markdown
Contributor

@afck afck left a comment

Choose a reason for hiding this comment

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

(No blockers so far, but I might need a walkthrough in a meeting to approve. 😅)

if oversized.is_empty() {
return;
}
let to_fail: Vec<u32> = {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

(Type annotation should go on collect below.)

tracing::error!(
?height,
tx_index,
event_index = ei,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

(Why not for event_index in to_fail?)

?error,
"processBurns submission failed"
);
let to_bump: Vec<u32> = {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

(Type annotation)

alloy::primitives::Address,
U256,
> = std::collections::BTreeMap::new();
let mut expected_per_recipient: std::collections::BTreeMap<alloy::primitives::Address, U256> =
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

(Type annotation could move to next line; avoids BTreeMap duplication.)

let mut result = Vec::new();
for tx_events in events {
for event in tx_events {
for (i, tx_events) in events.iter().enumerate() {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

(0u32..).zip(&events) would avoid the type cast below.


impl PartialOrd for PendingBurnsAtHeight {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

(That doesn't really satisfy the PartialOrd properties documented in the standard library: Eq should match whether this returns Some(Equal): https://doc.rust-lang.org/std/cmp/trait.PartialOrd.html)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants