Skip to content

[runtime/node] L2 defense in depth#913

Merged
Wizdave97 merged 15 commits into
mainfrom
l2-defense
May 28, 2026
Merged

[runtime/node] L2 defense in depth#913
Wizdave97 merged 15 commits into
mainfrom
l2-defense

Conversation

@Wizdave97
Copy link
Copy Markdown
Member

@Wizdave97 Wizdave97 commented May 25, 2026

PR Summary: L2 Defense — Fisherman blacklist for opstack & arbitrum

Branch: l2-defensehttps://github.com/polytope-labs/hyperbridge/pull/new/l2-defense

What 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-optimism and ismp-arbitrum verifiers 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)

  • New BlacklistedDisputeGames (keyed by (StateMachineId, H160 proxy)) and BlacklistedArbitrumClaims (keyed by (StateMachineId, H256 claim_hash)). Value type is the AccountId of the submitting fisherman.
  • New extrinsics blacklist_dispute_game and blacklist_arbitrum_claim, Pays::No, gated by IsCollator. Permanent — no unblacklist.
  • Single-collator finalization throughout: veto_state_commitment and both blacklist extrinsics finalize on the first call from any active collator. PendingVetoes / Pending*Blacklist storage maps and the quorum bookkeeping removed.
  • New consumer-facing FishermanBlacklist trait read by the verifier pallets.
  • PrioritizeVeto transaction extension boosts all three calls to TransactionPriority::MAX.

Verifier crates

  • arbitrum-verifier: get_state_hash, compute_assertion_hash, AssertionState::hash made pub; new orbit_claim_hash(state_hash, node_num) so on-chain and off-chain derive the unified arbitrum claim key identically.
  • New error variants op_verifier::Error::DisputeGameBlacklisted(H160) and arbitrum_verifier::Error::ClaimBlacklisted(H256).

Verifier pallet gating (ismp-optimism, ismp-arbitrum)

  • New type FishermanBlacklist: FishermanBlacklist on each Config.
  • verify_consensus consults the blacklist before running the heavy proof verification.
  • nexus-runtime and gargantua-runtime wire type FishermanBlacklist = Fishermen.

Tesseract

  • New FishermanClaim trait in tesseract-primitives (parallel to HyperbridgeClaim / HandleGetResponse), implemented only on SubstrateClient in tesseract/messaging/substrate/src/calls.rs. Kept off IsmpProvider since blacklists only make sense against hyperbridge.
  • New tesseract-fisherman modules:
    • quorum.rs — shared 2/3·N+1 fan-out (fetch_block_by_number, fetch_block_by_hash, decide).
    • opstack.rsfish_opstack watches DisputeGameCreated, reads the proxy's l2SequenceNumber, fetches the L2 block + message-parser account via eth_getAccount (not eth_getProof), recomputes the output root, blacklists on disagreement.
    • arbitrum.rsfish_arbitrum watches AssertionCreated, recomputes assertion_hash via the shared verifier helpers, blacklists on mismatch.
  • Reuses canonical ABI bindings from op_host::abi and arb_host::abi (both now pub mod abi) — avoids a buggy local redefinition of IRollupBold::AssertionCreated.
  • L1 polling runs against the latest block (no finality lag) for fastest reaction.

Spawn integration (parachain/node/fisherman)

  • Wraps the hyperbridge substrate client once and hands out two trait-object views: Arc<dyn IsmpProvider> for the pair fisherman, Arc<dyn FishermanClaim + Send + Sync> for the rollup watchers.
  • Groups configured L2s by their L1 and spawns one fish_opstack and one fish_arbitrum (BoLD only) task per L1.
  • Reads the existing [<chain>.consensus] op-host / arb-host blocks — no new config schema.

Validation (parachain/node/fisherman/src/config.rs)

  • Requires every supported EVM chain in the registry to be configured:
    • Mainnet L2s: Arbitrum, Base, Optimism, Soneium (Unichain removed).
    • Testnet L2s: Arbitrum Sepolia, Base Sepolia.
    • Mainnet non-L2: Ethereum, BNB, Gnosis, Polygon.
    • Testnet non-L2: Sepolia.
  • Minimum 3 distinct-host RPC URLs per chain (raised from 2 so the 2/3·N+1 +1 is meaningful).
  • L2 chains must additionally carry a [<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.
  • Coverage check is all-or-nothing per mainnet/testnet cohort.

Tests

  • Pallet (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.
  • Fisherman config (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.
  • Simnet (parachain/simtests::pallet_fishermen, #[ignore]d, run with PORT=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.
  • Workspace cargo check is green throughout.

Versioning

hyperbridge node crate bumped from 1.7.01.8.0.

Commits (chronological)

  1. 0741aafa — Initial implementation: storage, extrinsics, verifier gating, off-chain watchers, spawn wiring.
  2. bb6cd6c9 — Record both finalizing fishermen on blacklist entries (later superseded by single-collator).
  3. 99c4fa00 — Scan L1 to the latest block (no finality lag).
  4. b7751df2 — Spawn only the arbitrum BoLD watcher per L1.
  5. f4d0981b — Reuse op-host / arb-host ABI bindings instead of redefining (fixed wrong BoLD event signature).
  6. 5343702a — Split blacklist submission into its own FishermanClaim trait.
  7. e0481531 — Bump hyperbridge to 1.8.0.
  8. b7791f93 — Drop quorum: single-collator finalization for veto + blacklist.
  9. 55db11ad — Require non-L2 chains in collator config.
  10. e1a6374b — Non-L2 chains don't need a consensus block on the collator.
  11. 5fa9c4df — Narrow testnet support to Sepolia, Base Sepolia, Arbitrum Sepolia.
  12. a20163f2 — Use eth_getAccount for opstack message-parser storage root.
  13. f0a68b5a — Drop Unichain from supported L2s.
  14. d1f4d2b0 — Update simnet veto suite for single-collator finalization.
  15. 831f6d5b — Raise minimum rpc_urls per chain to 3.

Wizdave97 added 6 commits May 25, 2026 13:58
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.
@Wizdave97 Wizdave97 marked this pull request as draft May 25, 2026 15:40
Wizdave97 added 7 commits May 25, 2026 15:55
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.
@Wizdave97 Wizdave97 marked this pull request as ready for review May 25, 2026 17:51
Wizdave97 added 2 commits May 25, 2026 18:49
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.
@Wizdave97 Wizdave97 requested a review from seunlanlege May 28, 2026 07:10
@Wizdave97 Wizdave97 merged commit fd4e0d7 into main May 28, 2026
10 checks passed
@Wizdave97 Wizdave97 deleted the l2-defense branch May 28, 2026 10:48
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