diff --git a/control/preimage/src/commands.rs b/control/preimage/src/commands.rs index dd22dded8..6786759da 100644 --- a/control/preimage/src/commands.rs +++ b/control/preimage/src/commands.rs @@ -24,10 +24,16 @@ use crate::bridge_hub_runtime::runtime_types::{ }, snowbridge_outbound_queue_primitives::{v1::message::Initializer, OperatingMode}, snowbridge_pallet_ethereum_client, snowbridge_pallet_inbound_queue, - snowbridge_pallet_outbound_queue, snowbridge_pallet_system, + snowbridge_pallet_inbound_queue_v2, snowbridge_pallet_outbound_queue, + snowbridge_pallet_system, snowbridge_pallet_system_v2, }; use crate::bridge_hub_runtime::RuntimeCall as BridgeHubRuntimeCall; +use crate::asset_hub_runtime::runtime_types::{ + snowbridge_core::operating_mode::BasicOperatingMode as AssetHubBasicOperatingMode, + snowbridge_pallet_system_frontend, +}; + #[cfg(feature = "polkadot")] pub mod asset_hub_polkadot_types { pub use crate::asset_hub_runtime::runtime_types::staging_xcm::v5::{ @@ -150,6 +156,51 @@ pub fn outbound_queue_operating_mode(param: &OperatingModeEnum) -> BridgeHubRunt ) } +// V2 variant: halts the inbound-queue-v2 pallet's `submit` extrinsic, blocking +// processing of V2 Ethereum -> Polkadot messages on BridgeHub. +pub fn inbound_queue_v2_operating_mode(param: &OperatingModeEnum) -> BridgeHubRuntimeCall { + let mode = match param { + OperatingModeEnum::Normal => BasicOperatingMode::Normal, + OperatingModeEnum::Halted => BasicOperatingMode::Halted, + }; + BridgeHubRuntimeCall::EthereumInboundQueueV2( + snowbridge_pallet_inbound_queue_v2::pallet::Call::set_operating_mode { mode }, + ) +} + +// V2 variant: sends `Command::SetOperatingMode` to the Gateway via the V2 outbound +// queue. Sets the same Gateway `$.mode` storage as the V1 variant; both are kept +// so governance can halt via whichever outbound path is live. +pub fn gateway_operating_mode_v2( + operating_mode: &GatewayOperatingModeEnum, +) -> BridgeHubRuntimeCall { + let mode = match operating_mode { + GatewayOperatingModeEnum::Normal => OperatingMode::Normal, + GatewayOperatingModeEnum::RejectingOutboundMessages => { + OperatingMode::RejectingOutboundMessages + } + }; + BridgeHubRuntimeCall::EthereumSystemV2( + snowbridge_pallet_system_v2::pallet::Call::set_operating_mode { mode }, + ) +} + +// AssetHub-side: halts the system-frontend pallet. The `PausableExporter` wrapping +// the AssetHub->Ethereum XcmRouter consults `SnowbridgeSystemFrontend::is_paused()` +// and returns `SendError::NotApplicable` when halted, short-circuiting every +// AssetHub->Ethereum export (V1 and V2 share the same wrapper). This is the +// primary outbound halt lever for V2 because `outbound-queue-v2` has no local +// operating-mode storage. +pub fn system_frontend_operating_mode(param: &OperatingModeEnum) -> AssetHubRuntimeCall { + let mode = match param { + OperatingModeEnum::Normal => AssetHubBasicOperatingMode::Normal, + OperatingModeEnum::Halted => AssetHubBasicOperatingMode::Halted, + }; + AssetHubRuntimeCall::SnowbridgeSystemFrontend( + snowbridge_pallet_system_frontend::pallet::Call::set_operating_mode { mode }, + ) +} + pub fn upgrade(params: &UpgradeArgs) -> BridgeHubRuntimeCall { BridgeHubRuntimeCall::EthereumSystem(snowbridge_pallet_system::pallet::Call::upgrade { impl_address: params.logic_address.into_array().into(), diff --git a/control/preimage/src/helpers.rs b/control/preimage/src/helpers.rs index 361b063ec..2a039f089 100644 --- a/control/preimage/src/helpers.rs +++ b/control/preimage/src/helpers.rs @@ -134,12 +134,35 @@ pub async fn query_weight_asset_hub( Ok((call_info.weight.ref_time, call_info.weight.proof_size)) } -pub fn utility_force_batch(calls: Vec) -> AssetHubRuntimeCall { +/// Emits `pallet_utility::Call::batch_all`, which rolls back every call if any one +/// fails. The default for governance batches that must commit atomically. +pub fn utility_batch_all(calls: Vec) -> AssetHubRuntimeCall { AssetHubRuntimeCall::Utility( crate::asset_hub_runtime::runtime_types::pallet_utility::pallet::Call::batch_all { calls }, ) } +/// Emits `pallet_utility::Call::batch`, which short-circuits on the first failing call +/// but commits all preceding calls. +#[allow(dead_code)] +pub fn utility_batch(calls: Vec) -> AssetHubRuntimeCall { + AssetHubRuntimeCall::Utility( + crate::asset_hub_runtime::runtime_types::pallet_utility::pallet::Call::batch { calls }, + ) +} + +/// Emits `pallet_utility::Call::force_batch`, which attempts every call regardless of +/// failures and reports per-call results via `ItemFailed` events. Use when each call +/// must fire independently — e.g. the halt-bridge preimage, where one stuck lever +/// (e.g. HRMP transport failure for the BH XCM) should not skip the rest. +pub fn utility_force_batch(calls: Vec) -> AssetHubRuntimeCall { + AssetHubRuntimeCall::Utility( + crate::asset_hub_runtime::runtime_types::pallet_utility::pallet::Call::force_batch { + calls, + }, + ) +} + #[cfg(any(feature = "westend", feature = "paseo"))] pub fn sudo(call: Box) -> AssetHubRuntimeCall { return AssetHubRuntimeCall::Sudo( diff --git a/control/preimage/src/main.rs b/control/preimage/src/main.rs index a1c0e7bce..9a1b90298 100644 --- a/control/preimage/src/main.rs +++ b/control/preimage/src/main.rs @@ -11,7 +11,10 @@ use alloy_primitives::{address, utils::parse_units, Address, Bytes, FixedBytes, use clap::{Args, Parser, Subcommand, ValueEnum}; use codec::Encode; use constants::{ASSET_HUB_API, BRIDGE_HUB_API, POLKADOT_DECIMALS, POLKADOT_SYMBOL, RELAY_API}; -use helpers::{force_xcm_version, send_xcm_asset_hub, send_xcm_bridge_hub, utility_force_batch}; +use helpers::{ + force_xcm_version, send_xcm_asset_hub, send_xcm_bridge_hub, utility_batch_all, + utility_force_batch, +}; use snowbridge_preimage_chopsticks::generate_chopsticks_script; use sp_crypto_hashing::blake2_256; use std::{io::Write, path::PathBuf}; @@ -204,24 +207,63 @@ pub struct PricingParametersArgs { #[derive(Debug, Args)] pub struct HaltBridgeArgs { - /// Halt the Ethereum gateway, blocking message from Ethereum to Polkadot in the Ethereum - /// contract. + /// Halt the Ethereum Gateway contract (both V1 and V2 paths). Sends + /// `Command::SetOperatingMode(Halted)` via both V1 and V2 system pallets so the halt + /// is delivered via whichever outbound queue is live. Once processed on Ethereum, + /// this blocks `v2_sendMessage`, `v2_registerToken`, and V1 `sendToken`/`sendMessage` + /// on the Gateway. Delivery is relayer-dependent. #[arg(long, value_name = "HALT_GATEWAY")] gateway: bool, - /// Halt the Ethereum Inbound Queue, blocking messages from BH to AH. + /// V2-only Gateway halt: sends `Command::SetOperatingMode(Halted)` only via the V2 + /// system pallet. Blocks `v2_sendMessage` and `v2_registerToken` on the Gateway once + /// the message is delivered to Ethereum. Leaves V1 `sendToken`/`sendMessage` working. + #[arg(long, value_name = "HALT_GATEWAY_V2")] + gateway_v2: bool, + /// Halt both V1 and V2 inbound-queue pallets on BridgeHub, blocking processing of + /// Ethereum -> Polkadot messages. For surgical halts of a single version, use + /// `--inbound-queue-v1` or `--inbound-queue-v2`. #[arg(long, value_name = "HALT_INBOUND_QUEUE")] inbound_queue: bool, - /// Halt the Ethereum Outbound Queue, blocking message from AH to BH. + /// Halt only the V1 inbound-queue pallet on BridgeHub. + #[arg(long, value_name = "HALT_INBOUND_QUEUE_V1")] + inbound_queue_v1: bool, + /// Halt only the V2 inbound-queue pallet on BridgeHub. + #[arg(long, value_name = "HALT_INBOUND_QUEUE_V2")] + inbound_queue_v2: bool, + /// Halt AssetHub -> Ethereum outbound traffic. Halts the V1 outbound-queue pallet + /// on BridgeHub AND the system-frontend pallet on AssetHub; the latter short-circuits + /// the AssetHub->Ethereum `PausableExporter` for both V1 and V2 at the XcmRouter + /// layer (V2's `outbound-queue-v2` has no local halt, so the frontend halt is the + /// primary V2 outbound lever). #[arg(long, value_name = "HALT_OUTBOUND_QUEUE")] outbound_queue: bool, - /// Halt the Ethereum client, blocking consensus updates to the light client. + /// Router-layer P->E halt: halts only the AssetHub system-frontend pallet. Blocks + /// BOTH V1 and V2 P->E at the `PausableExporter`, returning `SendError::NotApplicable` + /// to the XcmRouter. The V1 BridgeHub outbound-queue is left untouched so it keeps + /// draining in-flight V1 messages already enqueued there. There is no V2-only + /// operating-mode halt for P->E; for a V2-only deterrent use `--assethub-max-fee-v2`. + #[arg(long, value_name = "HALT_SYSTEM_FRONTEND")] + system_frontend: bool, + /// Halt the Ethereum beacon light client, blocking new beacon-header ingestion. + /// Note: this does NOT propagate into the `Verifier::verify` trait impl that + /// downstream consumers (`inbound-queue(-v2)::submit`, + /// `outbound-queue-v2::submit_delivery_receipt`) call — those verify against + /// already-stored finalised state. Halt those consumers individually to block + /// proof-consuming flows during a suspected beacon compromise. #[arg(long, value_name = "HALT_ETHEREUM_CLIENT")] ethereum_client: bool, - /// Set the AH to Ethereum fee to a high amount, effectively blocking messages from AH -> - /// Ethereum. + /// Set the AssetHub -> Ethereum outbound fee to `u128::MAX` for both V1 + /// (`BridgeHubEthereumBaseFee`) and V2 (`BridgeHubEthereumBaseFeeV2`) storage + /// items, effectively deterring user sends via fee pricing. Complementary to the + /// system-frontend halt; does not block at the router layer. #[arg(long, value_name = "ASSETHUB_MAX_FEE")] assethub_max_fee: bool, - /// Halt all parts of the bridge + /// V2-only variant of `--assethub-max-fee`: writes only the + /// `BridgeHubEthereumBaseFeeV2` storage item, leaving V1 fee unchanged. + /// Use this to deter V2 P->E sends while V1 traffic continues unimpeded. + #[arg(long, value_name = "ASSETHUB_MAX_FEE_V2")] + assethub_max_fee_v2: bool, + /// Halt all parts of the bridge (equivalent to passing every other flag). #[arg(long, value_name = "HALT_SNOWBRIDGE")] all: bool, } @@ -384,7 +426,7 @@ async fn run() -> Result<(), Box> { ], ) .await?; - utility_force_batch(vec![bridge_hub_call, asset_hub_call]) + utility_batch_all(vec![bridge_hub_call, asset_hub_call]) } Command::UpdateAsset(params) => { send_xcm_asset_hub( @@ -410,7 +452,7 @@ async fn run() -> Result<(), Box> { let bridge_hub_call = send_xcm_bridge_hub(&context, vec![set_pricing_parameters]).await?; let asset_hub_call = send_xcm_asset_hub(&context, vec![set_ethereum_fee]).await?; - utility_force_batch(vec![bridge_hub_call, asset_hub_call]) + utility_batch_all(vec![bridge_hub_call, asset_hub_call]) } Command::HaltBridge(params) => { let mut bh_calls = vec![]; @@ -418,27 +460,64 @@ async fn run() -> Result<(), Box> { let mut halt_all = params.all; // if no individual option specified, assume halt the whole bridge. if !params.gateway + && !params.gateway_v2 && !params.inbound_queue + && !params.inbound_queue_v1 + && !params.inbound_queue_v2 && !params.outbound_queue + && !params.system_frontend && !params.ethereum_client && !params.assethub_max_fee + && !params.assethub_max_fee_v2 { halt_all = true; } + // Gateway halt commands must be enqueued BEFORE any local outbound-queue + // halt takes effect, otherwise the SetOperatingMode command cannot be + // committed for delivery to Ethereum. Push both V1 and V2 variants so the + // halt is delivered via whichever outbound queue is operational. if params.gateway || halt_all { bh_calls.push(commands::gateway_operating_mode( &GatewayOperatingModeEnum::RejectingOutboundMessages, )); + bh_calls.push(commands::gateway_operating_mode_v2( + &GatewayOperatingModeEnum::RejectingOutboundMessages, + )); + } else if params.gateway_v2 { + // V2-only: leave V1 SetOperatingMode unsent. + bh_calls.push(commands::gateway_operating_mode_v2( + &GatewayOperatingModeEnum::RejectingOutboundMessages, + )); } - if params.inbound_queue || halt_all { + if params.inbound_queue || params.inbound_queue_v1 || halt_all { bh_calls.push(commands::inbound_queue_operating_mode( &OperatingModeEnum::Halted, )); } + if params.inbound_queue || params.inbound_queue_v2 || halt_all { + bh_calls.push(commands::inbound_queue_v2_operating_mode( + &OperatingModeEnum::Halted, + )); + } if params.outbound_queue || halt_all { + // V1 local halt on BridgeHub. V2's outbound-queue-v2 has no local halt; + // the system-frontend halt below is the effective V2 outbound lever. bh_calls.push(commands::outbound_queue_operating_mode( &OperatingModeEnum::Halted, )); + // system-frontend halt on AssetHub: short-circuits the PausableExporter + // wrapping the AH->Ethereum router, blocking both V1 and V2 exports at + // the source regardless of user or parachain origin. + ah_calls.push(commands::system_frontend_operating_mode( + &OperatingModeEnum::Halted, + )); + } else if params.system_frontend { + // Router-layer halt: AH frontend, blocks both V1 and V2 P->E at the + // PausableExporter. V1 BH outbound-queue left running so in-flight V1 + // messages continue to drain. + ah_calls.push(commands::system_frontend_operating_mode( + &OperatingModeEnum::Halted, + )); } if params.ethereum_client || halt_all { bh_calls.push(commands::ethereum_client_operating_mode( @@ -446,16 +525,33 @@ async fn run() -> Result<(), Box> { )); } if params.assethub_max_fee || halt_all { + // Set both V1 and V2 AssetHub outbound fee storage items to u128::MAX. ah_calls.push(commands::set_assethub_fee(u128::MAX)); + ah_calls.push(commands::set_assethub_fee_v2(u128::MAX)); + } else if params.assethub_max_fee_v2 { + // V2-only: leave V1 fee untouched. + ah_calls.push(commands::set_assethub_fee_v2(u128::MAX)); } - if bh_calls.len() > 0 && ah_calls.len() == 0 { + // Use `force_batch` (not `batch_all` or `batch`) so every lever fires + // independently — a single failure (e.g. HRMP transport hiccup blocking + // pallet_xcm::send to BridgeHub) must not skip the AH-side halts, and + // vice versa. Per-call failures are reported via `ItemFailed` events. + let ah_call = if ah_calls.len() == 1 { + Some(ah_calls.into_iter().next().unwrap()) + } else if ah_calls.len() > 1 { + Some(utility_force_batch(ah_calls)) + } else { + None + }; + if bh_calls.len() > 0 && ah_call.is_none() { send_xcm_bridge_hub(&context, bh_calls).await? - } else if ah_calls.len() > 0 && bh_calls.len() == 0 { - send_xcm_asset_hub(&context, ah_calls).await? + } else if ah_call.is_some() && bh_calls.len() == 0 { + ah_call.unwrap() } else { - let call1 = send_xcm_bridge_hub(&context, bh_calls).await?; - let call2 = send_xcm_asset_hub(&context, ah_calls).await?; - utility_force_batch(vec![call1, call2]) + let bh_xcm_send = send_xcm_bridge_hub(&context, bh_calls).await?; + // BH XCM-send is the first call so the V2 Gateway halt (the first + // Transact inside the XCM) is processed before any AH-side halt. + utility_force_batch(vec![bh_xcm_send, ah_call.unwrap()]) } } Command::RegisterEther(params) => { @@ -481,7 +577,7 @@ async fn run() -> Result<(), Box> { send_xcm_asset_hub(&context, vec![register_ether_call, set_ether_metadata_call]) .await?; - utility_force_batch(vec![ + utility_batch_all(vec![ bh_set_pricing_call, ah_set_pricing_call, ah_register_ether_call, @@ -506,7 +602,7 @@ async fn run() -> Result<(), Box> { { let metadata_calls = commands::register_erc20_token_metadata(); let reg_call = commands::frequency_token_registrations(); - utility_force_batch(vec![ + utility_batch_all(vec![ send_xcm_asset_hub(&context, metadata_calls).await?, send_xcm_bridge_hub(&context, reg_call).await?, ]) @@ -533,7 +629,7 @@ async fn run() -> Result<(), Box> { let outbound_fee_call = commands::set_assethub_fee_v2(1_000_000_000); let ah_xcm_call = send_xcm_asset_hub(&context, vec![outbound_fee_call]).await?; - utility_force_batch(vec![bh_xcm_call, ah_xcm_call]) + utility_batch_all(vec![bh_xcm_call, ah_xcm_call]) } } Command::ReplaySep2025 => { diff --git a/control/preimage/src/treasury_commands.rs b/control/preimage/src/treasury_commands.rs index 5329fcc22..c649d03cb 100644 --- a/control/preimage/src/treasury_commands.rs +++ b/control/preimage/src/treasury_commands.rs @@ -9,7 +9,7 @@ use crate::asset_hub_runtime::runtime_types::{ }, }; use crate::asset_hub_runtime::RuntimeCall as AssetHubRuntimeCall; -use crate::helpers::utility_force_batch; +use crate::helpers::utility_batch_all; use polkadot_runtime_constants::currency::UNITS; use polkadot_runtime_constants::time::DAYS; @@ -189,7 +189,7 @@ pub fn treasury_proposal(params: &TreasuryProposal2024Args) -> AssetHubRuntimeCa println!("Spend: {}, {}({})", spend.name, asset_id, asset_amount); } - utility_force_batch(calls) + utility_batch_all(calls) } fn make_treasury_spend( diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index e6fa98ec1..ef16dbbf3 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -41,6 +41,7 @@ * [Processes for keeping track of dependency changes](resources/processes-for-keeping-track-of-dependency-changes.md) * [Contributing to Snowbridge](resources/updating-snowbridge-pallets-bridgehub-and-assethub-runtimes.md) * [Governance and Operational Processes](resources/governance-and-operational-processes.md) +* [Emergency Procedures](resources/emergency-procedures.md) * [General Governance Updates](resources/governance-updates.md) * [Test Runtime Upgrades](resources/test-runtime-upgrades.md) * [Run Relayers](resources/run-relayers.md) diff --git a/docs/resources/emergency-procedures.md b/docs/resources/emergency-procedures.md new file mode 100644 index 000000000..4f6c2cd9e --- /dev/null +++ b/docs/resources/emergency-procedures.md @@ -0,0 +1,152 @@ +# Emergency Procedures + +On-call runbook. Halt mechanics are at the top so they're easy to reach under pressure. For routine governance, see [Governance and Operational Processes](governance-and-operational-processes.md). + +Halt is technically reversible but the halt referendum on Polkassembly is public. See [Decision authority](#decision-authority) for when solo action vs team confirmation applies. + +## Producing the preimage and submission links + +The governance page at [app.snowbridge.network/governance](https://app.snowbridge.network/governance) is the single source of truth during an incident. Select the halt scope (see [Halt scopes reference](#halt-scopes-reference)) and the page emits both the preimage **and** the two ready-to-submit papi.how links. Take the links straight to [Submitting](#submitting). + +**Fallback (UI down only)**: call `buildHaltBridgePreimage` then `buildHaltBridgeSubmissionUrls` from `@snowbridge/api`. Produces the same preimage + URLs the UI shows. + +## Halt scopes reference + +Pick the narrowest scope that covers the failure mode. Governance page form fields: + +* **All** Every component. Default if nothing else selected. +* **Ethereum client** Halts `EthereumBeaconClient::submit` and short-circuits `Verifier::verify` for all BridgeHub consumers. Stops V1 + V2 inbound `submit` and `outbound-queue-v2::submit_delivery_receipt`. Use for beacon-light-client or sync-committee compromise. `force_checkpoint` stays available (root-only) for recovery. +* **Inbound queue** Both V1 + V2 inbound pallets on BridgeHub. +* **Inbound queue V1** V1 inbound only. +* **Inbound queue V2** V2 inbound only. +* **Outbound queue** V1 outbound on BridgeHub **and** AssetHub system-frontend (short-circuits `PausableExporter` for V1 + V2 at XcmRouter). V2 has no local outbound halt, so system-frontend is the primary V2 outbound lever. +* **System frontend** AssetHub system-frontend only. Blocks V1 + V2 P→E at `PausableExporter` (`SendError::NotApplicable`). V1 BridgeHub outbound keeps draining in-flight messages. +* **Gateway** Sends `Command::SetOperatingMode(Halted)` to the Ethereum Gateway via both V1 + V2 system pallets. Delivery is relayer-dependent, so schedule **before** local outbound halts. +* **Gateway V2** V2-only Gateway halt. Blocks `v2_sendMessage` and `v2_registerToken` once delivered. Pair with **Inbound queue V2** + **AssetHub max fee V2** for a V2-only pause. +* **AssetHub max fee** Sets `BridgeHubEthereumBaseFee` + `BridgeHubEthereumBaseFeeV2` to `u128::MAX`. Fee deterrent, not a router halt. +* **AssetHub max fee V2** V2-only variant. Writes only `BridgeHubEthereumBaseFeeV2`. Only V2-isolated P→E lever. + +| Failure mode | Scopes | +| --- | --- | +| Beacon light client / sync committee compromise | **Ethereum client** | +| Ethereum Gateway compromise | **Gateway** + **AssetHub max fee** | +| Inbound-queue bug (one version) | **Inbound queue V1** or **Inbound queue V2** | +| Outbound-queue / system-frontend bug | **Outbound queue** | +| V2 P→E only (V1 keeps flowing) | **AssetHub max fee V2** (fee deterrent only) | +| Full V2 pause | **Gateway V2** + **Inbound queue V2** + **AssetHub max fee V2** | +| Full P→E halt (V1 + V2) | **Outbound queue** or **System frontend** | +| Uncertain | **All** | + +When uncertain: **All**. To block both directions immediately: **Gateway** + **AssetHub max fee**. + +## Submitting + +Submission goes through OpenGov's **Whitelisted Caller** track, which requires the Polkadot Fellowship to whitelist the call first. From the governance page's result panel, two papi.how links handle this end-to-end: + +1. **Asset Hub batch** Click **Open**. Notes the preimage and opens the public Whitelisted Caller referendum on Asset Hub. Anyone on the team can submit. Sign in papi.how. +2. **Fellowship whitelist** Click **Copy** and share the link in the Parity Element channel (see [Comms](#comms-during-an-incident)). Must be submitted by a Fellow of rank 3 or higher. Bottleneck of the flow. + +Enactment defaults to `After(10)` blocks (matches opengov-cli's default). + +**Wall-clock: hours, not minutes.** Run Parity escalation in parallel with the Asset Hub submission. + +### Fallback: opengov-cli + +If the governance page is unreachable, construct the same submission locally with [opengov-cli](https://github.com/joepetrowski/opengov-cli) and the preimage bytes (which the SDK fallback in [Producing](#producing-the-preimage-and-submission-links) can still generate offline): + +{% code overflow="wrap" %} +``` +opengov-cli submit-referendum \ + --proposal 0x \ + --network polkadot \ + --track whitelistedcaller \ + --output AppsUiLink +``` +{% endcode %} + +Emits the same two papi.how URLs the UI shows. Use only when the UI is down; the UI is the single source of truth the team drives from during an incident. + +## Verifying the halt + +After the call executes, query each affected chain's `OperatingMode` storage (expected: `Halted`). + +| Halt scope | Chain | Storage | +| --- | --- | --- | +| **Ethereum client** | BridgeHub | `ethereumBeaconClient.operatingMode` | +| **Inbound queue V1** | BridgeHub | `ethereumInboundQueue.operatingMode` | +| **Inbound queue V2** | BridgeHub | `ethereumInboundQueueV2.operatingMode` | +| **Outbound queue** (BridgeHub) | BridgeHub | `ethereumOutboundQueue.operatingMode` | +| **Outbound queue** / **System frontend** (AssetHub) | AssetHub | `systemFrontend.operatingMode` | +| **Gateway** (BridgeHub side) | BridgeHub | `ethereumSystem.operatingMode`, `ethereumSystemV2.operatingMode` | +| **Gateway** (Ethereum contract) | Ethereum | `Gateway.operatingMode() == Halted`. **Relayer-dependent**: watch for `SetOperatingMode` event before confirming. | +| **AssetHub max fee** | AssetHub | `bridgeHubEthereumBaseFee` + `bridgeHubEthereumBaseFeeV2` == `u128::MAX` (`340282366920938463463374607431768211455`) | +| **AssetHub max fee V2** | AssetHub | `bridgeHubEthereumBaseFeeV2` == `u128::MAX` | + +Polkadot-side halt is the firm guarantee. If Gateway isn't halted yet (no relayer delivery), `sendToken`/`sendMessage` on Ethereum still accept calls but nothing downstream processes them. + +## Detection + +Triggers for the incident flow: + +* **Funds drained or unexpectedly moved**. Highest priority. Halt first, investigate after. +* **Bug bounty report** (HackenProof or direct), verified by a team member as a valid exploit with working PoC. + +When in doubt: post in Slack, treat as incident until ruled out. + +## Decision authority + +| Action | Threshold | +| --- | --- | +| Solo halt | 1 member. **Only for visible exploit / funds being drained.** | +| Confirmed halt | 2 members agree. Default for bug bounty, anomalies, "I don't understand what I'm seeing." | +| Escalate to Parity | 2 members agree. Same conversation as confirmed halt in practice. | +| Public comms | Full team. **Only after fix is deployed and bridge is resuming.** | +| Emergency upgrade | Coordinated with Parity. Code is exploit-sensitive. | + +A halt referendum on Polkassembly is public, so solo authority is reserved for cases where the incident is already public (funds moving). Otherwise discuss in Slack first. + +## Comms during an incident + +Each step assumes the previous one has happened. + +1. **Slack** `#snowbridge-security` Post the signal (link to explorer, alert, bounty report). Non-visible signals: wait for at least one teammate to confirm before halting. Visible exploit: skip ahead. +2. **Halt** See [Producing](#producing-the-preimage-and-submission-links) + [Submitting](#submitting). For visible exploits, run in parallel with steps 3 and 4. +3. **Internal confirmation** 2+ members agree it's a real incident. Retroactive for solo-halt cases. +4. **Element with Parity** New room, invite Adrian, Bastian, Oliver. Fellowship coordination happens here. +5. **Integrators** Telegram. Hydration first, then others. Tell them what's halted + expected resume timing. +6. **No public comms** (Twitter/X, forum, blog, public Discord) until fix is deployed and resume is in flight. + +## Resuming the bridge + +Same flow as halting: the governance page emits the resume preimage and the two submission links. Select scopes matching what was halted, then proceed via [Submitting](#submitting). + +Fallback: `buildResumeBridgePreimage` + `buildResumeBridgeSubmissionUrls` in `@snowbridge/api`. + +Before submitting, confirm: + +* Fix is deployed and verified in production. +* Full team has signed off. +* Monitoring is back to baseline, no fresh anomalies. +* AssetHub fee values being restored to pre-incident values. Resume writes `BridgeHubEthereumBaseFee` and `BridgeHubEthereumBaseFeeV2` back to known good defaults (currently `14_929_540_998` for V1, `1_000_000_000` for V2). Double-check these match what was live before. + +Public comms can begin once resume executes and the bridge is processing again. + +## Emergency Upgrade + +For cases a halt alone can't contain (e.g. critical pallet logic bug): + +* **Halt first anyway.** Buys time to develop the upgrade without pressure. Skip only if halting is itself harmful. +* **Restrict the code.** Upgrade code for an unpatched vulnerability is itself exploit material. Private branch, limited reviewers (team + necessary Parity contacts). Don't publicise until executed on-chain. +* **Coordinate the pathway with Parity.** Whitelisted Caller for runtime, multi-sig for contracts. Use the Element channel. +* **Resume** only after the upgrade is verified live ([Resuming the bridge](#resuming-the-bridge)). + +## Post-mortem + +Within 48h of resume: + +* **Owner** Whoever drove the incident (defaults to whoever halted first). +* **Format** Google Doc, shared with team + Parity contacts from the Element channel. +* **Contents** Timeline (timestamps), root cause, halt scope + reason, what worked, what didn't, action items with owners and dates. +* **Action items** Track in the team issue tracker, not the doc itself. + +Also write one for false-positive halts. Tuning detection signals to reduce false positives is itself useful output. diff --git a/docs/resources/governance-and-operational-processes.md b/docs/resources/governance-and-operational-processes.md index f3a74e229..b2e2e05e1 100644 --- a/docs/resources/governance-and-operational-processes.md +++ b/docs/resources/governance-and-operational-processes.md @@ -28,51 +28,7 @@ We expect to need non-emergency governance calls once every few months as we imp ## Emergency Situations -For emergency situations, there are two possible scenarios: - -### 1. Emergency Pause - -In case of an emergency, a call to halt the bridge needs to be executed as soon as possible. Deploying the fix and resuming the bridge can happen afterwards with less time pressure and sensitivity, similar to a normal upgrade. - -The processes to do so is: - -{% code overflow="wrap" %} -``` -git clone https://github.com/Snowfork/snowbridge.git -cd snowbridge/control - -cargo run --bin snowbridge-preimage -- \ - --bridge-hub-api wss://polkadot-bridge-hub-rpc.polkadot.io \ - --asset-hub-api wss://polkadot-asset-hub-rpc.polkadot.io \ - halt-bridge \ - --all -``` -{% endcode %} - -The command will product a preimage hash, to be submitted to the **Whitelisted Caller Track**: - -``` -Preimage Hash: 0xc2569b432fba3b01df7da3c90bb546158480067064d4d5bc88c351fcba4355dd -Preimage Size: 129 -0x1a0408630003000100a90f03242f00000602b28d0b9859460c530101200006028217b42.. -``` - -The halt-bridge command has the following flags: - -* `--all` Halt all the bridge components at once. -* `--ethereum-client` Halt the Ethereum client, blocking consensus updates. -* `--inbound-queue` Halt the Inbound Queue, blocking messages from BridgeHub to AssetHub. -* `--outbound-queue` Halt the Outbound Queue, blocking messages from AssetHub to BridgeHub. -* `--gateway` Halt messages from Ethereum to AssetHub. -* `--assethub-max-fee` Set the AssetHub to Ethereum to the maximum fee, so that users effectively cannot send messages from AssetHub to Ethereum. - -Based on the nature of the emergency, the bridge might need to be halted in its entirety, or partially. - -In case of emergency where there is uncertainty of the cause of a problem, it is best to block the bridge in its entirety using `halt-bridge --all`. To block both transfer directions at the earliest point possible, use `halt-bridge --gateway --assethub-max-fee`. - -### 2. Emergency Upgrade - -Although unlikely, there may be scenarios where the emergency can only be resolved through an upgrade rather than just a pause. In this case, the code for the upgrade may be sensitive, and we would want to avoid overly publicising it until the fix has been executed. +Emergency response (halt-bridge and emergency-upgrade procedures) is documented in [Emergency Procedures](emergency-procedures.md). On-call operators should read that page directly. ## Fallback governance diff --git a/web/packages/api/src/governance/index.ts b/web/packages/api/src/governance/index.ts new file mode 100644 index 000000000..cdd6717bf --- /dev/null +++ b/web/packages/api/src/governance/index.ts @@ -0,0 +1,2 @@ +export * from "./halt_bridge" +export * from "./opengov_submission" diff --git a/web/packages/api/src/governance/opengov_submission.ts b/web/packages/api/src/governance/opengov_submission.ts new file mode 100644 index 000000000..056397aac --- /dev/null +++ b/web/packages/api/src/governance/opengov_submission.ts @@ -0,0 +1,293 @@ +import { ApiPromise } from "@polkadot/api" +import { SubmittableExtrinsic } from "@polkadot/api/types" +import { blake2AsHex } from "@polkadot/util-crypto" +import { hexToU8a, u8aToHex } from "@polkadot/util" + +import { HaltBridgePreimage } from "./halt_bridge" + +// Default WS endpoints embedded in the emitted papi.how URLs. These match what +// joepetrowski/opengov-cli prints, so the UI links open against the same +// metadata source opengov-cli would have used. +const DEFAULT_AH_WS = "wss://asset-hub-polkadot-rpc.dwellir.com" +const DEFAULT_COLLECTIVES_WS = "wss://polkadot-collectives-rpc.polkadot.io" + +// papi.how host. opengov-cli emits dev.papi.how URLs, match for parity. +const DEFAULT_PAPI_HOW_BASE = "https://dev.papi.how" + +// Asset Hub para ID, used as the XCM dest from Collectives. AH is where +// pallet_whitelist and pallet_referenda live post-AHM. +const ASSET_HUB_POLKADOT_ID = 1000 + +// opengov-cli hardcodes After(10) for the Fellowship-side enactment. +const FELLOWSHIP_ENACTMENT_AFTER_BLOCKS = 10 + +// papi.how networkId values, matching opengov-cli's output. +const PAPI_HOW_NETWORK_ASSET_HUB = "polkadot_asset_hub" +const PAPI_HOW_NETWORK_COLLECTIVES = "polkadot_collectives" + +export interface SubmissionUrls { + /** + * Asset Hub batch URL: `utility.forceBatch([preimage.notePreimage(wrapped), + * referenda.submit(Origins(WhitelistedCaller), Lookup{hash, len}, After(n))])`. + * Anyone on the operator team can submit this. + */ + assetHubBatchUrl: string + /** + * Hex bytes of the Asset Hub batch call (the `data=` payload of {@link assetHubBatchUrl}), + * exposed for parity testing against opengov-cli. + */ + assetHubBatchCallData: string + /** + * Collectives Chain URL: `utility.forceBatch([fellowshipReferenda.submit( + * Origins(FellowshipOrigins::Fellows), Inline(xcm_send), After(10))])` where + * `xcm_send` is a `polkadotXcm.send` to Asset Hub carrying a Transact of + * `whitelist.whitelistCall(preimageHash)`. **Must be submitted by a Fellow + * of rank 3 or higher.** + */ + fellowshipWhitelistUrl: string + /** + * Hex bytes of the Collectives call (the `data=` payload of {@link fellowshipWhitelistUrl}), + * exposed for parity testing against opengov-cli. + */ + fellowshipWhitelistCallData: string + /** + * Hash of the user-supplied preimage call (the input to this function). + * This is the hash that goes inside `whitelist.whitelistCall` on the + * Fellowship side. **Not** the hash referenced by the AH public referendum + * (that one hashes the wrapped `dispatchWhitelistedCallWithPreimage` call). + */ + preimageHash: string + /** + * Hash of the wrapped preimage (`whitelist.dispatchWhitelistedCallWithPreimage(call)`). + * This is what `preimage.notePreimage` stores on AH and what the AH public + * referendum's `Lookup` references. + */ + wrappedPreimageHash: string + /** + * Length in bytes of the wrapped preimage, the `len` field of the AH + * referendum's `Lookup`. + */ + wrappedPreimageLen: number +} + +export interface SubmissionOptions { + /** + * Enactment delay (in blocks) for the AH public WhitelistedCaller + * referendum. Defaults to 10, matching opengov-cli's default. The + * Fellowship-side enactment is always hardcoded to `After(10)` to match + * opengov-cli. + */ + enactmentAfterBlocks?: number + /** Override the papi.how host. Defaults to dev.papi.how (opengov-cli parity). */ + papiHowBase?: string + /** Override the AH WS URL embedded in the papi.how URL. */ + assetHubWsUrl?: string + /** Override the Collectives WS URL embedded in the papi.how URL. */ + collectivesWsUrl?: string +} + +/** + * Build the two submission URLs a Snowbridge halt preimage needs to land on + * the Polkadot WhitelistedCaller track: + * + * 1. An Asset Hub URL the operator submits to note the (wrapped) preimage + * and open the public Whitelisted Caller referendum. + * 2. A Collectives Chain URL that a rank-3+ Fellow submits to open the + * Fellowship whitelist referendum, which XCM-Transacts into AH to call + * `whitelist.whitelistCall(preimageHash)`. + * + * Mirrors joepetrowski/opengov-cli's `polkadot_fellowship_referenda` flow + * (src/submit_referendum.rs ~L628-L811). The encoded call bytes are byte-for- + * byte identical to what opengov-cli emits for the same preimage (verified by + * the `opengov_submission_check` script in @snowbridge/operations). + * + * @param assetHub Connected ApiPromise for Asset Hub Polkadot (provides + * metadata for `whitelist`, `preimage`, `referenda`, + * `utility`, `Origins`). + * @param collectives Connected ApiPromise for Polkadot Collectives Chain + * (provides metadata for `fellowshipReferenda`, `polkadotXcm`, + * `utility`, `FellowshipOrigins`). + * @param preimage The halt-bridge preimage to wrap, either a {@link HaltBridgePreimage} + * (use its `callData` field) or a 0x-prefixed hex string of + * the raw preimage call bytes. + * @param opts Optional URL/enactment overrides. + */ +export async function buildHaltBridgeSubmissionUrls( + assetHub: ApiPromise, + collectives: ApiPromise, + preimage: HaltBridgePreimage | string, + opts: SubmissionOptions = {}, +): Promise { + const userCallHex = + typeof preimage === "string" ? preimage : preimage.callData + + const assetHubWsUrl = opts.assetHubWsUrl ?? DEFAULT_AH_WS + const collectivesWsUrl = opts.collectivesWsUrl ?? DEFAULT_COLLECTIVES_WS + const papiHowBase = opts.papiHowBase ?? DEFAULT_PAPI_HOW_BASE + const ahEnactmentAfter = + opts.enactmentAfterBlocks ?? FELLOWSHIP_ENACTMENT_AFTER_BLOCKS + + const userCallBytes = hexToU8a(userCallHex) + const preimageHash = blake2AsHex(userCallBytes, 256) + + // Wrap the user call in whitelist.dispatchWhitelistedCallWithPreimage. + // The wrapped bytes are what gets noted on AH and referenced by the + // public referendum; the wrapped hash is distinct from the raw preimage + // hash that the Fellowship whitelist call hashes. + const userCallDecoded = assetHub.createType("Call", userCallBytes) + const wrappedCall = assetHub.tx.whitelist.dispatchWhitelistedCallWithPreimage( + userCallDecoded, + ) + const wrappedCallBytes = wrappedCall.method.toU8a() + const wrappedPreimageHash = blake2AsHex(wrappedCallBytes, 256) + const wrappedPreimageLen = wrappedCallBytes.length + + const assetHubBatch = buildAssetHubBatch( + assetHub, + wrappedCallBytes, + wrappedPreimageHash, + wrappedPreimageLen, + ahEnactmentAfter, + ) + const assetHubBatchCallData = u8aToHex(assetHubBatch.method.toU8a()) + + const collectivesBatch = buildCollectivesBatch( + assetHub, + collectives, + preimageHash, + ) + const fellowshipWhitelistCallData = u8aToHex(collectivesBatch.method.toU8a()) + + return { + assetHubBatchUrl: buildPapiHowUrl( + papiHowBase, + PAPI_HOW_NETWORK_ASSET_HUB, + assetHubWsUrl, + assetHubBatchCallData, + ), + assetHubBatchCallData, + fellowshipWhitelistUrl: buildPapiHowUrl( + papiHowBase, + PAPI_HOW_NETWORK_COLLECTIVES, + collectivesWsUrl, + fellowshipWhitelistCallData, + ), + fellowshipWhitelistCallData, + preimageHash, + wrappedPreimageHash, + wrappedPreimageLen, + } +} + +/** + * Convenience wrapper for the resume case. The wire format is identical + * (resume preimages go through the same WhitelistedCaller track), so this + * just delegates to {@link buildHaltBridgeSubmissionUrls}. + */ +export async function buildResumeBridgeSubmissionUrls( + assetHub: ApiPromise, + collectives: ApiPromise, + preimage: HaltBridgePreimage | string, + opts: SubmissionOptions = {}, +): Promise { + return buildHaltBridgeSubmissionUrls(assetHub, collectives, preimage, opts) +} + +function buildAssetHubBatch( + assetHub: ApiPromise, + wrappedCallBytes: Uint8Array, + wrappedPreimageHash: string, + wrappedPreimageLen: number, + enactmentAfterBlocks: number, +): SubmittableExtrinsic<"promise"> { + const notePreimage = assetHub.tx.preimage.notePreimage( + u8aToHex(wrappedCallBytes), + ) + + // Plain-object args; @polkadot/api resolves the runtime-specific + // OriginCaller and Bounded enums from referenda.submit's metadata. + const submit = assetHub.tx.referenda.submit( + { Origins: "WhitelistedCaller" } as any, + { Lookup: { hash: wrappedPreimageHash, len: wrappedPreimageLen } } as any, + { After: enactmentAfterBlocks } as any, + ) + + return assetHub.tx.utility.forceBatch([notePreimage, submit]) +} + +function buildCollectivesBatch( + assetHub: ApiPromise, + collectives: ApiPromise, + rawPreimageHash: string, +): SubmittableExtrinsic<"promise"> { + // Inner AH call that the Fellowship XCM Transacts into: + // whitelist.whitelistCall(preimageHash) + const whitelistCall = assetHub.tx.whitelist.whitelistCall(rawPreimageHash) + const whitelistCallBytes = whitelistCall.method.toU8a() + + // XCM message: UnpaidExecution + Transact(whitelistCall). No fee asset + // because AH grants free execution to sibling Collectives via the + // UnpaidExecution barrier. + const message = { + V5: [ + { + UnpaidExecution: { + weightLimit: "Unlimited", + checkOrigin: null, + }, + }, + { + Transact: { + originKind: "Xcm", + fallbackMaxWeight: null, + call: { encoded: u8aToHex(whitelistCallBytes) }, + }, + }, + ], + } + + // Dest from Collectives to AH: { parents: 1, interior: X1([Parachain(1000)]) }. + const dest = { + V5: { + parents: 1, + interior: { X1: [{ Parachain: ASSET_HUB_POLKADOT_ID }] }, + }, + } + + const xcmSend = collectives.tx.polkadotXcm.send(dest, message) + const xcmSendBytes = xcmSend.method.toU8a() + + // fellowshipReferenda.submit with FellowshipOrigins::Fellows origin and + // Inline(xcm_send) proposal. opengov-cli only uses Inline when bytes + // fit in the 128-byte BoundedVec; the whitelist-call wrapper is small + // enough that this always holds. + if (xcmSendBytes.length > 128) { + throw new Error( + `buildCollectivesBatch: XCM send bytes (${xcmSendBytes.length}) exceed 128-byte Inline cap; ` + + "would need a separate Preimage::notePreimage on Collectives. Not implemented.", + ) + } + + const submit = collectives.tx.fellowshipReferenda.submit( + { FellowshipOrigins: "Fellows" } as any, + { Inline: u8aToHex(xcmSendBytes) } as any, + { After: FELLOWSHIP_ENACTMENT_AFTER_BLOCKS } as any, + ) + + // Even for a single call, opengov-cli wraps in force_batch for output + // consistency; match it byte-for-byte. + return collectives.tx.utility.forceBatch([submit]) +} + +function buildPapiHowUrl( + base: string, + networkId: string, + wsUrl: string, + callData: string, +): string { + return ( + `${base}/extrinsics#data=${callData}` + + `&networkId=${networkId}` + + `&endpoint=${encodeURIComponent(wsUrl)}` + ) +} diff --git a/web/packages/api/src/index.ts b/web/packages/api/src/index.ts index 17016658f..6ca999b41 100644 --- a/web/packages/api/src/index.ts +++ b/web/packages/api/src/index.ts @@ -79,7 +79,7 @@ export * as history from "./history" export * as historyV2 from "./history_v2" export { TransferStatus } from "./history_v2" export * as subsquidV2 from "./subsquid_v2" -export * as governance from "./governance/halt_bridge" +export * as governance from "./governance" export class Context { readonly environment: Environment diff --git a/web/packages/operations/src/opengov_submission_check.ts b/web/packages/operations/src/opengov_submission_check.ts new file mode 100644 index 000000000..0e8e1a995 --- /dev/null +++ b/web/packages/operations/src/opengov_submission_check.ts @@ -0,0 +1,127 @@ +/** + * Hex-parity check: verify that @snowbridge/api's `buildHaltBridgeSubmissionUrls` + * produces byte-for-byte the same Asset Hub batch and Collectives extrinsic as + * joepetrowski/opengov-cli, for a known fixture preimage. + * + * The fixture was captured by running opengov-cli locally: + * + * opengov-cli submit-referendum \ + * --proposal 0x \ + * --network polkadot \ + * --track whitelistedcaller \ + * --output AppsUiLink + * + * Outputs the two known papi.how `data=` payloads recorded in + * EXPECTED_ASSET_HUB_HEX and EXPECTED_COLLECTIVES_HEX below. + * + * Connects to live RPCs by default; override with ASSET_HUB_WS / COLLECTIVES_WS. + * Note: runtime upgrades on either chain can shift pallet indices or type + * shapes, in which case the fixture goes stale; capture a fresh one by + * re-running opengov-cli. + * + * Usage: `npx ts-node web/packages/operations/src/opengov_submission_check.ts` + */ + +import { ApiPromise, WsProvider } from "@polkadot/api" +import { governance } from "@snowbridge/api" + +const ASSET_HUB_WS = + process.env.ASSET_HUB_WS ?? "wss://polkadot-asset-hub-rpc.polkadot.io" +const COLLECTIVES_WS = + process.env.COLLECTIVES_WS ?? "wss://polkadot-collectives-rpc.polkadot.io" + +// Fixture preimage from a recorded opengov-cli run, the raw bytes you'd pass +// via `--proposal`. This is a halt-bridge preimage (XCM-send to BridgeHub +// + AH frontend halt + max-fee writes). +const FIXTURE_PREIMAGE = + "0x2804081f0005010100a90f05342f000006020102d96aca89700c5301012000060201826b28be89700c5a0101200006020102ca9a3b000c500101200006020102ca9a3b000c5b0101200006020102ca9a3b000c510001200006020102ca9a3b000c520301200028040c240001000404405fbc5c7ba58845ad1f1a9a7c5bc12fad40ffffffffffffffffffffffffffffffff00040440d0ed50b03e9a49e836dd934b425ba4c340ffffffffffffffffffffffffffffffff" + +// The two `data=` payloads opengov-cli emits for the fixture above. Copied +// verbatim from the recorded output. +const EXPECTED_ASSET_HUB_HEX = + "0x2804080500e10240032804081f0005010100a90f05342f000006020102d96aca89700c5301012000060201826b28be89700c5a0101200006020102ca9a3b000c500101200006020102ca9a3b000c5b0101200006020102ca9a3b000c510001200006020102ca9a3b000c520301200028040c240001000404405fbc5c7ba58845ad1f1a9a7c5bc12fad40ffffffffffffffffffffffffffffffff00040440d0ed50b03e9a49e836dd934b425ba4c340ffffffffffffffffffffffffffffffff3e003f0d023ed76771e81b331f874722bfaef740b3219ddd944fcc33021d53735ede9c0e3ab8000000010a000000" +const EXPECTED_COLLECTIVES_HEX = + "0x2804043d003e0201cc1f0005010100a10f05082f00000603008840009acbcdc7f00d57411325ef59d71f4861d4c9181e07eca54d62f27a7260951710010a000000" + +async function main() { + console.log(`Connecting to AssetHub: ${ASSET_HUB_WS}`) + console.log(`Connecting to Collectives: ${COLLECTIVES_WS}`) + + const [assetHub, collectives] = await Promise.all([ + ApiPromise.create({ provider: new WsProvider(ASSET_HUB_WS) }), + ApiPromise.create({ provider: new WsProvider(COLLECTIVES_WS) }), + ]) + + try { + const urls = await governance.buildHaltBridgeSubmissionUrls( + assetHub, + collectives, + FIXTURE_PREIMAGE, + ) + + console.log("\n--- SDK output ---") + console.log(`preimageHash: ${urls.preimageHash}`) + console.log(`wrappedPreimageHash: ${urls.wrappedPreimageHash}`) + console.log(`wrappedPreimageLen: ${urls.wrappedPreimageLen}`) + console.log(`\nassetHubBatchUrl: ${urls.assetHubBatchUrl}`) + console.log(`fellowshipWhitelist: ${urls.fellowshipWhitelistUrl}`) + + const ahMatch = urls.assetHubBatchCallData === EXPECTED_ASSET_HUB_HEX + const collectivesMatch = + urls.fellowshipWhitelistCallData === EXPECTED_COLLECTIVES_HEX + + console.log("\n--- Parity check vs opengov-cli ---") + reportMatch( + "Asset Hub batch", + ahMatch, + EXPECTED_ASSET_HUB_HEX, + urls.assetHubBatchCallData, + ) + reportMatch( + "Collectives batch", + collectivesMatch, + EXPECTED_COLLECTIVES_HEX, + urls.fellowshipWhitelistCallData, + ) + + if (!ahMatch || !collectivesMatch) { + process.exitCode = 1 + } + } finally { + await Promise.all([assetHub.disconnect(), collectives.disconnect()]) + } +} + +function reportMatch( + label: string, + match: boolean, + expected: string, + actual: string, +) { + if (match) { + console.log(` ${label}: PASS (${actual.length / 2 - 1} bytes)`) + return + } + console.log(` ${label}: FAIL`) + console.log(` expected: ${expected}`) + console.log(` actual: ${actual}`) + const firstDiff = firstDifference(expected, actual) + if (firstDiff !== null) { + console.log( + ` first diff at char ${firstDiff}: expected '${expected[firstDiff] ?? ""}', got '${actual[firstDiff] ?? ""}'`, + ) + } +} + +function firstDifference(a: string, b: string): number | null { + const len = Math.min(a.length, b.length) + for (let i = 0; i < len; i++) { + if (a[i] !== b[i]) return i + } + return a.length === b.length ? null : len +} + +main().catch((e) => { + console.error(e) + process.exit(1) +})