Skip to content

apollo_gateway: validate replayed txs against their source block's gas prices - #15010

Open
ron-starkware wants to merge 1 commit into
mainfrom
ron/echonet/gateway-source-block-gas-price
Open

apollo_gateway: validate replayed txs against their source block's gas prices#15010
ron-starkware wants to merge 1 commit into
mainfrom
ron/echonet/gateway-source-block-gas-price

Conversation

@ron-starkware

Copy link
Copy Markdown
Contributor

Problem

validate_tx_l2_gas_price_within_threshold compares a tx's l2 max_price_per_unit against min_gas_price_percentage of the committed tip's L2 gas price. Under replay the tip trails the block the tx came from, and mainnet's L2 price moves up to ~2% per block, so a tx mainnet accepted gets rejected here for having less headroom than the price fell in between.

That is a deadlock, not a delay. A tx in block N clears the threshold only once the tip's price drops to its cap, which in a falling market first happens at tip = N — and block N is closed by then, so the tx can never enter its own block. It is deterministic, so the retry after a resync reproduces it exactly and Echonet ends up permanently skipping a mainnet block.

Block 11926844 hit it twice this weekend:

tx      0x1549342b9b52c7d2046e604d920fd788a52afb54240ef0f904667186dbb9b3f
l2 cap  32,000,000,000        (a round bot cap)
block   11926844, price 31,507,218,957   -> only +1.56% headroom

Mainnet's price was in a five-block slide into that block (34.85e9 → 31.51e9), and 57 GAS_PRICE_TOO_LOW rejections walked the threshold from 32,093,567,243 (block 11926843) up to 34,854,264,801 (11926839) without ever dropping under the cap. Echonet built 11926844 with 1 of its 4 txs, produced the byte-identical wrong hash on the retry, and skipped the block. Block 11802770 four days earlier was the same mechanism.

Change

In BehaviorMode::Echonet, threshold against the L2 gas price of the block the tx actually came from, resolved from the recorder.

A tx mainnet included in block N necessarily paid price(N), so price(N) <= cap always holds and the tx clears on its first forward, regardless of where the tip is. The check keeps its teeth: anything that could not have paid its own block's price is still rejected. Outside Echonet mode nothing changes.

Why not the alternatives:

  • validate_resource_bounds: false removes the check entirely.
  • Lowering min_gas_price_percentage weakens it for everything, and no fixed percentage is safe — the required discount depends on how far the price falls.
  • Thresholding on next_l2_gas_price (which is what the TODO(Arni) at that line wants, and is a genuine latent improvement) is not enough on its own: it moves the pass condition from tip = N to tip = N-1, which is only reached moments before block N is built, leaving a ~1 s race against the gateway's state-sync lag. Worth doing separately, but not as the fix for this.

Notes

  • Both recorder endpoints already exist and are already relied on by the mempool in this mode (apollo_mempool/src/communication.rs), so no recorder-side change is neededechonet/get_block_metadata already returns l2_gas_price_fri.
  • Any lookup failure — endpoint down, unparsable body, zero price — logs and falls back to the committed tip's price rather than failing the tx.
  • Costs two recorder round trips per tx on the validation path, both in-cluster. At Echonet's ~3 tx/s sustained that is negligible; the 2 s timeout matches the mempool's.

Testing

crates/apollo_gateway/src/source_block_gas_price_test.rs, 5 cases against a mockito recorder: resolves the real block-11926844 price from the exact payload the Python handler emits, and falls back on missing tx metadata, missing block metadata, an unparsable body, and a zero price.

cargo test -p apollo_gateway — 170 passed, 0 failed. cargo clippy -p apollo_gateway --all-targets -- -D warnings clean. scripts/rust_fmt.sh clean.

🤖 Generated with Claude Code

@cursor

cursor Bot commented Aug 24, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
Changes stateful fee validation and injects recorder HTTP lookups on the add-tx path in Echonet mode. Failures fall back to committed-tip prices, so a recorder outage can still reject replayed txs rather than admit them.

Overview
Fixes an Echonet deadlock where GAS_PRICE_TOO_LOW compared a replayed tx's L2 cap to the committed tip instead of the block it came from. In a falling market the tip only drops far enough after that block is already built, so the tx can never enter and the block is skipped forever.

In BehaviorMode::Echonet, the gateway now looks up the tx's source block via the recorder (get_tx_block_metadata / get_block_metadata) and substitutes that STRK gas-price vector into both the explicit L2 threshold check and blockifier fee bounds. Lookup failures log and fall back to the committed tip. Other modes are unchanged.

Also saturates the min-price threshold on overflow instead of wrapping via num-rational.

Reviewed by Cursor Bugbot for commit af23ca4. Bugbot is set up for automated code reviews on this repo. Configure here.

@reviewable-StarkWare

Copy link
Copy Markdown

This change is Reviewable

@github-actions

github-actions Bot commented Aug 24, 2026

Copy link
Copy Markdown

@ron-starkware ron-starkware changed the title apollo_gateway: threshold replayed txs against their source block's l2 gas price apollo_gateway: validate replayed txs against their source block's gas prices Aug 24, 2026
@ron-starkware

Copy link
Copy Markdown
Contributor Author

Reworked after a code review found the first attempt did not achieve its goal. Pushed as a second commit rather than a force-push so the review's line anchors survive; the net diff against main is what matters and no longer contains the old module.

What was wrong: overriding only the explicit GAS_PRICE_TOO_LOW threshold left blockifier's check_fee_bounds (via run_validate_entry_point) still comparing the bid against the committed block's prices from the same get_block_info(). Its bound is the stricter of the two — max_price_per_unit >= actual_gas_price, no percentage — so the deadlocked tx was still rejected, just as a more confusing ValidateFailure. And it covers all three resources, so an l2-only override was too narrow regardless: seven of the nine Resource bounds were not satisfied rejections echonet logged over four days were l1_data_gas.

What changed: the source block's whole STRK price vector is now resolved once per tx and applied in a single block_info() seam that both the explicit check and blockifier's BlockContext are built from. The threshold helper and the per-call client plumbing are gone.

Also addressed from the review: resolution moved ahead of the state-snapshot pin; retry with the mempool's policy; a last-block memo so the block lookup is ~1 per block; a u64 sanity bound on recorder-supplied prices; Url::join base normalization; the stale previous_block_l2_gas_price parameter name; and a validator-level test on the replay branch, which is now trivial because the validator holds a plain Option<GasPriceVector> instead of an HTTP client.

Two review items deliberately left as follow-ups, noted in the description: no metric on the recorder dependency (needs a define_metrics! entry plus a dashboard regen), and this being a third hand-rolled copy of the recorder client (a cross-crate extraction).

One partial disagreement on the fixture finding: the previous test suite did cover tx-metadata-OK plus block-metadata-absent. It's right that 500 and unparsable on the second leg were missing and that the rstest parameter was dead — both fixed, along with the hex/decimal duplication and the hardcoded block number.

@ron-starkware
ron-starkware force-pushed the ron/echonet/gateway-source-block-gas-price branch 2 times, most recently from ca70dcf to 9dac5e5 Compare August 24, 2026 12:33

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 9dac5e5. Configure here.

Comment thread crates/apollo_gateway/Cargo.toml
@ron-starkware
ron-starkware force-pushed the ron/echonet/gateway-source-block-gas-price branch from 9dac5e5 to eb9871a Compare August 24, 2026 12:56
…s prices

Resource bounds are validated against the committed block's gas prices in two
independent places: the explicit l2 threshold check, and blockifier's check_fee_bounds
reached through run_validate_entry_point, whose BlockContext is built from the same
get_block_info(). Blockifier's is the stricter of the two -- max_price_per_unit >=
actual_gas_price, no percentage -- and it covers all three resources.

Under replay the committed block trails the block a transaction came from, and mainnet
prices move up to ~2% per block, so a transaction mainnet accepted is rejected here for
having less headroom than the price fell in between. That is a deadlock rather than a
delay: the bound is satisfied only once prices drop to the transaction's cap, which in a
falling market first happens when the committed block IS its own block -- already built
by then, so it can never be included. Being deterministic, the retry after a resync
reproduces it and the block is skipped for good.

Block 11926844 hit this twice in one weekend. Tx 0x1549342b caps l2 at 32000000000
against its own block's 31507218957, +1.56% headroom at the bottom of a five-block
slide; 57 rejections walked the threshold from 32093567243 up to 34854264801 without
ever falling under the cap. Echonet built the block with 1 of its 4 transactions and
produced the same wrong hash on the retry. Nor is it only l2 or only the explicit check:
of nine `Resource bounds were not satisfied` rejections over four days, seven were
l1_data_gas, which only blockifier's bound looks at.

Resolve the source block's whole STRK price vector once per transaction and apply it in
a single block_info() seam that both consumers are built from. A transaction mainnet
included in block N necessarily paid price(N), so it clears on the first forward
wherever the committed block happens to be, while the check keeps its teeth: anything
that could not have paid its own block's price is still rejected.

Only strk_gas_prices is replaced -- the ETH vector is not replayed, and v3 transactions
select the STRK vector by fee type. block_number and block_timestamp are deliberately
left alone, since moving them would shift the stored-block-hash window and
get_block_hash against a state that only holds up to the committed block.

Resolution happens in instantiate_validator before the state snapshot is pinned, so the
recorder round-trips do not age the state validation then runs against. Transient
failures are retried with the policy the mempool's recorder client uses, since a silent
fallback would reinstate the very rejection this removes. The last block's prices are
memoized -- they are immutable and consecutive replayed transactions share a block -- so
the block lookup drops to roughly one per block, leaving only the per-transaction hash
lookup the mempool already makes anyway. The recorder base URL is normalized to end in a
slash, because Url::join otherwise replaces the last path segment of a URL configured
with a path.

The threshold multiplication is now saturating rather than a Ratio product, so a price
too large to scale rejects instead of wrapping. That also hardens the pre-existing path,
where the price comes from state, and leaves num-rational unused by this crate, so it is
dropped from its dependencies.

Both recorder endpoints already exist and are already used by the mempool in this mode,
so no recorder-side change is needed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ron-starkware
ron-starkware force-pushed the ron/echonet/gateway-source-block-gas-price branch from eb9871a to af23ca4 Compare August 24, 2026 13:06
@ron-starkware ron-starkware self-assigned this Aug 24, 2026
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