[runtime/node] L2 defense in depth#913
Merged
Merged
Conversation
Adds an L1 rollup-claim fisherman: collators watch Ethereum L1 for new opstack DisputeGameCreated and arbitrum NodeCreated / AssertionCreated events, verify the claimed L2 state against a 2/3·N+1 L2 RPC quorum, and submit a blacklist extrinsic for any claim that doesn't agree. The on-chain ismp-optimism and ismp-arbitrum consensus verifiers refuse to process any proof referencing a blacklisted dispute-game proxy or arbitrum claim hash. - pallet-fishermen: two new 2-collator extrinsics (blacklist_dispute_game, blacklist_arbitrum_claim), permanent blacklist storage, FishermanBlacklist trait read by the optimism/arbitrum verifier pallets, PrioritizeVeto extension boosted for the new calls. - arbitrum-verifier: pub helpers (get_state_hash, compute_assertion_hash, new orbit_claim_hash) so on-chain and off-chain derive the unified H256 claim key identically. - ismp-optimism / ismp-arbitrum: gate verify_consensus on the blacklist before the heavy proof verification. - nexus + gargantua runtimes wire FishermanBlacklist = Fishermen. - IsmpProvider gains blacklist_* methods (default = unimplemented, real impl on the substrate provider). - tesseract-fisherman gains opstack.rs, arbitrum.rs, quorum.rs modules that reuse the existing 2/3·N+1 fan-out pattern from tesseract-evm::byzantine. - parachain/node/fisherman: spawn one fish_opstack / fish_arbitrum task per L1, sourcing rollup-core / dispute-game factory addresses from the operator's existing [<chain>.consensus] op-host / arb-host blocks. - Validation now requires every supported L2 to carry a matching [<chain>.consensus] sub-table (arbitrum_orbit for Arbitrum chains, op_stack otherwise), so the fisherman fails fast at startup if the rollup-core / factory address is missing.
BlacklistedDisputeGames and BlacklistedArbitrumClaims now store the (first_collator, second_collator) tuple of the two distinct fishermen whose calls finalized the quorum, in submission order. The matching *Blacklisted events carry the same addresses. Keeps auditability and gives downstream reputation accounting the data it needs. Tests assert both addresses are stored for opstack dispute-game and arbitrum claim blacklists.
Drops the `l1_finality_lag` config and the 32-block constant in the parachain spawn wiring. The opstack / arbitrum watchers now scan up to `get_block_number()` directly. Trades reorg-safety for timeliness — a blacklist landed against a reorged event will stick — but reacts to fraudulent claims as soon as they appear on L1.
Pre-BoLD Orbit chains have been migrated; the Orbit `NodeCreated` watcher would just emit empty-event noise while still costing one L1 `eth_getLogs` per poll. Drop the duplicate Orbit target — the BoLD `AssertionCreated` watcher is the only one we spawn per arbitrum chain. The `ArbitrumKind::Orbit` variant and verifier code are kept intact for future use; only the spawn integration changes.
The locally-redefined `IRollupBold::AssertionCreated` parameter list
didn't match the real ABI (extra `inboxAcc`/`challengePeriodBlocks`,
wrong order), so event decoding would have silently failed against
mainnet rollups. Drop the local `sol!` declarations and import the
canonical bindings:
- `op_host::abi::{DisputeGameFactory, FaultDisputeGame}` for the
opstack watcher.
- `arb_host::abi::{IRollup, IRollupBold}` for the arbitrum watcher.
Both `mod abi` modules are now `pub mod abi` so downstream crates can
share the generated types. The arbitrum scan loop builds its
`AfterState` inline from the event fields (no helper that depended on
the now-deleted local sol type). Read the proxy's L2 block number via
the single `l2SequenceNumber()` selector the op-host binding actually
exposes (older `l2BlockNumber()` isn't in the pinned ABI).
…rait `blacklist_dispute_game` / `blacklist_arbitrum_claim` only ever make sense against the hyperbridge substrate client — every EVM provider implementation was just an unimplemented stub. Moving them off `IsmpProvider` keeps the trait limited to operations every chain client actually supports, mirroring how `HyperbridgeClaim` and `HandleGetResponse` are factored. - New `tesseract_primitives::FishermanClaim` trait with both methods, no default impls. - `impl FishermanClaim for SubstrateClient<C>` lives in `tesseract/messaging/substrate/src/calls.rs` next to the existing `HyperbridgeClaim` and `HandleGetResponse` impls. - The stub `blacklist_*` overrides on the substrate `IsmpProvider` impl are gone. - `OpstackConfig.hyperbridge` and `ArbitrumConfig.hyperbridge` now take `Arc<dyn FishermanClaim + Send + Sync>` instead of `Arc<dyn IsmpProvider>`. - Parachain spawn wraps the substrate client once and hands out two trait views: `Arc<dyn IsmpProvider>` for the pair-based fisherman and `Arc<dyn FishermanClaim + Send + Sync>` for the rollup watchers.
Minor bump for the L1 rollup-claim fisherman + pallet-fishermen blacklist extrinsics shipping on this branch.
…cklist A single fisherman call now finalizes a veto or a blacklist. Removes the two-collator quorum and the half-finalized "pending" half of every flow, plus the `InvalidVeto`/`InvalidBlacklist` "same collator twice" errors that the quorum bookkeeping needed. Storage shape: - `PendingVetoes`, `PendingDisputeGameBlacklist`, `PendingArbitrumClaimBlacklist` — removed. - `BlacklistedDisputeGames` / `BlacklistedArbitrumClaims` values change from `(AccountId, AccountId)` to `AccountId` (the submitting fisherman), so a single storage read still surfaces who finalized. Behaviour: - `veto_state_commitment` deletes the commitment on the first call. - `blacklist_*` writes the entry on the first call. Subsequent calls for the same (chain, key) are silently Ok and do not overwrite the recorded fisherman. - Events lose the `*Noted` / two-fisherman variants. `*Vetoed` and `*Blacklisted` each carry the single `fisherman: AccountId`. Tests in `pallet-ismp-testsuite::pallet_fishermen` updated for the single-collator flow and assert the submitting account is recorded.
…n collator config
The fisherman validation only enforced L2 coverage; an operator could
omit the L1 chains Hyperbridge settles against and still pass preflight.
Extend the rule so every supported non-L2 EVM chain — Ethereum / BNB /
Gnosis / Polygon and their testnets — must also be configured with the
matching consensus block.
Registry:
- New `SUPPORTED_NON_L2_CHAIN_IDS_{MAINNET,TESTNET}` constants.
- `is_supported_non_l2` / `is_supported_chain` helpers.
- `expected_consensus_kind` covers non-L2 chains too: 1 → ethereum,
11155111 → sepolia, 56 → bsc, 97 → bsc_testnet, 100 → gnosis,
10200 → chiado, 137/80002 → polygon.
Validation:
- Both `preflight` and `validate` now iterate every supported chain
(L2 or non-L2), require its consensus block, and check kind.
- `require_complete_set` is invoked against the union mainnet and
union testnet sets, so a partial config fails fast.
Tests:
- Fixture extended to include all four testnet non-L2 chains with
proper consensus blocks (Sepolia, BSC Chapel, Chiado, Amoy).
- Three new tests assert: missing non-L2 chain rejected, missing
non-L2 consensus rejected, wrong consensus kind on Polygon rejected.
Only L2 chains need [<chain>.consensus] — the on-chain rollup-claim
fisherman uses those entries to read rollup-core / dispute-game-factory
addresses. Non-L2 EVM chains (Ethereum, BNB, Gnosis, Polygon and their
testnets) are still required to be present in the config (rpc_urls +
state_machine, with the usual distinct-host quorum rule), but their
consensus block is the relayer's concern, not the collator's.
- `expected_consensus_kind` returns `None` for non-L2 chains again, so
the `ensure_consensus_section{,_raw}` helpers silently skip them.
- Coverage check via `require_complete_set` still uses the union
mainnet / testnet sets, so a missing non-L2 still fails preflight.
- Test fixture leaves `consensus = None` on every non-L2 entry.
- Dropped the per-non-L2-kind tests; new `validate_accepts_non_l2_without_consensus`
pins the rule. `validate_rejects_missing_non_l2_chain` still covers
the presence requirement.
- Dropped `tesseract-bsc` / `tesseract-polygon` / `tesseract-sync-committee`
dev-deps that were only used to construct the now-removed non-L2
consensus fixtures.
…m Sepolia The collator-side fisherman only covers a subset of Hyperbridge's testnet deployments. Trim the testnet registry sets so preflight no longer demands chains the fisherman doesn't actually monitor: - `SUPPORTED_L2_CHAIN_IDS_TESTNET` = [Arbitrum Sepolia, Base Sepolia]. Optimism Sepolia removed. - `SUPPORTED_NON_L2_CHAIN_IDS_TESTNET` = [Sepolia]. BSC Chapel, Gnosis Chiado, Polygon Amoy removed. Test fixture shrinks accordingly; the non-L2-without-consensus loop now iterates only over Sepolia.
…e root The opstack quorum check only needs the message-parser's `storage_root` to recompute the L2 output root — not the full merkle proof. Switch from `provider.get_proof(parser_addr, vec![])` to `get_account`, which goes via `eth_getAccount` and returns a single `TrieAccount` instead of an `EIP1186AccountProofResponse` with proof bytes. Lighter per-call payload at the same correctness guarantees.
`SUPPORTED_L2_CHAIN_IDS_MAINNET` no longer contains 130. The collator-side coverage check no longer requires an unichain entry. `consensus_state_id_for_chain_id` / `ismp_host_for_chain_id` keep their Unichain mappings since those are general lookups beyond the fisherman's enforcement, but `is_opstack_l2` doc and the test fixture's match arm are updated for consistency.
The pallet's two-collator quorum is gone, so the simnode tests that walked the "first collator records pending, second collator finalizes" flow no longer reflect the on-chain behaviour. Rewrite the suite: - Drop `pending_vetoes_key` / `fetch_pending_veto` helpers — the `PendingVetoes` storage map was removed. - Replace `first_collator_records_pending_veto` and `two_distinct_collators_finalize_veto` with a single `single_collator_veto_deletes_commitment` that asserts the first Alice call deletes `pallet_ismp::StateCommitments[height]`. - `outsider_cannot_veto` now also asserts the commitment is still present after the rejected dispatch. - `fisherman_task_detects_disagreement_and_submits_veto` watches for the commitment being deleted instead of polling for a PendingVetoes entry that no longer exists. - `veto_lands_above_normal_extrinsic_in_block` unchanged — still exercises the priority extension. All tests remain `#[ignore]`d; run against a simnode on PORT=9990.
The byzantine handler's 2/3·N+1 quorum needs at least three providers to make the +1 meaningful — with two, a single disagreement leaves the threshold unmet and the handler abstains. Bump `MIN_RPC_URLS_PER_L2` from 2 to 3 so preflight fails fast on under-provisioned configs. Test fixture grows a third distinct host (Ankr) per chain; the duplicate-host test now uses three same-host URLs so the distinct-providers branch is what fires.
seunlanlege
approved these changes
May 28, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
PR Summary: L2 Defense — Fisherman blacklist for opstack & arbitrum
Branch:
l2-defense— https://github.com/polytope-labs/hyperbridge/pull/new/l2-defenseWhat it does
Adds an L1 rollup-claim fisherman to Hyperbridge. Collators watch Ethereum L1 for new opstack dispute games and arbitrum BoLD assertions, verify each claim against a 2/3·N+1 L2 RPC quorum, and submit a blacklist extrinsic on hyperbridge for any claim that disagrees. The on-chain
ismp-optimismandismp-arbitrumverifiers refuse to process any consensus proof referencing a blacklisted dispute-game proxy or arbitrum claim hash.The existing veto path is also simplified: any single collator can veto a fraudulent state commitment without quorum.
Layers
Pallet (
pallet-fishermen)BlacklistedDisputeGames(keyed by(StateMachineId, H160 proxy)) andBlacklistedArbitrumClaims(keyed by(StateMachineId, H256 claim_hash)). Value type is theAccountIdof the submitting fisherman.blacklist_dispute_gameandblacklist_arbitrum_claim,Pays::No, gated byIsCollator. Permanent — nounblacklist.veto_state_commitmentand both blacklist extrinsics finalize on the first call from any active collator.PendingVetoes/Pending*Blackliststorage maps and the quorum bookkeeping removed.FishermanBlacklisttrait read by the verifier pallets.PrioritizeVetotransaction extension boosts all three calls toTransactionPriority::MAX.Verifier crates
arbitrum-verifier:get_state_hash,compute_assertion_hash,AssertionState::hashmadepub; neworbit_claim_hash(state_hash, node_num)so on-chain and off-chain derive the unified arbitrum claim key identically.op_verifier::Error::DisputeGameBlacklisted(H160)andarbitrum_verifier::Error::ClaimBlacklisted(H256).Verifier pallet gating (
ismp-optimism,ismp-arbitrum)type FishermanBlacklist: FishermanBlackliston eachConfig.verify_consensusconsults the blacklist before running the heavy proof verification.nexus-runtimeandgargantua-runtimewiretype FishermanBlacklist = Fishermen.Tesseract
FishermanClaimtrait intesseract-primitives(parallel toHyperbridgeClaim/HandleGetResponse), implemented only onSubstrateClientintesseract/messaging/substrate/src/calls.rs. Kept offIsmpProvidersince blacklists only make sense against hyperbridge.tesseract-fishermanmodules:quorum.rs— shared 2/3·N+1 fan-out (fetch_block_by_number,fetch_block_by_hash,decide).opstack.rs—fish_opstackwatchesDisputeGameCreated, reads the proxy'sl2SequenceNumber, fetches the L2 block + message-parser account viaeth_getAccount(noteth_getProof), recomputes the output root, blacklists on disagreement.arbitrum.rs—fish_arbitrumwatchesAssertionCreated, recomputesassertion_hashvia the shared verifier helpers, blacklists on mismatch.op_host::abiandarb_host::abi(both nowpub mod abi) — avoids a buggy local redefinition ofIRollupBold::AssertionCreated.Spawn integration (
parachain/node/fisherman)Arc<dyn IsmpProvider>for the pair fisherman,Arc<dyn FishermanClaim + Send + Sync>for the rollup watchers.fish_opstackand onefish_arbitrum(BoLD only) task per L1.[<chain>.consensus]op-host / arb-host blocks — no new config schema.Validation (
parachain/node/fisherman/src/config.rs)[<chain>.consensus]block of the matching kind (arbitrum_orbit/op_stack); non-L2 chains need to be present but don't require a consensus block.Tests
pallet-ismp-testsuite::pallet_fishermen): outsider rejected, single-collator veto deletes commitment, single-collator blacklist writes entry + records fisherman, idempotency on second call. 3/3 pass.hyperbridge-fisherman): 23 unit tests cover signer presence, consensus enforcement, kind matching, RPC quorum minimum, distinct-host rule, mainnet/testnet coverage completeness, non-L2 presence requirement. All pass.parachain/simtests::pallet_fishermen,#[ignore]d, run withPORT=9990): outsider rejected, single-collator veto deletes commitment, priority extension keeps veto ahead of normal extrinsics, fisherman task spawns against mocked L2 RPCs, end-to-end byzantine detection submits a veto.cargo checkis green throughout.Versioning
hyperbridgenode crate bumped from1.7.0→1.8.0.Commits (chronological)
0741aafa— Initial implementation: storage, extrinsics, verifier gating, off-chain watchers, spawn wiring.bb6cd6c9— Record both finalizing fishermen on blacklist entries (later superseded by single-collator).99c4fa00— Scan L1 to the latest block (no finality lag).b7751df2— Spawn only the arbitrum BoLD watcher per L1.f4d0981b— Reuse op-host / arb-host ABI bindings instead of redefining (fixed wrong BoLD event signature).5343702a— Split blacklist submission into its ownFishermanClaimtrait.e0481531— Bumphyperbridgeto 1.8.0.b7791f93— Drop quorum: single-collator finalization for veto + blacklist.55db11ad— Require non-L2 chains in collator config.e1a6374b— Non-L2 chains don't need a consensus block on the collator.5fa9c4df— Narrow testnet support to Sepolia, Base Sepolia, Arbitrum Sepolia.a20163f2— Useeth_getAccountfor opstack message-parser storage root.f0a68b5a— Drop Unichain from supported L2s.d1f4d2b0— Update simnet veto suite for single-collator finalization.831f6d5b— Raise minimum rpc_urls per chain to 3.