-
Notifications
You must be signed in to change notification settings - Fork 20
Run Snowbridge halt and resume bridge preimages for verification #636
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
rockbmb
merged 7 commits into
open-web3-stack:master
from
claravanstaden:clara/snowbridge-halt-preimage
Jun 29, 2026
Merged
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
6fd3473
snowbridge halt pet tests
claravanstaden a43e28f
remove API version
claravanstaden 59d3144
preimage len comment
claravanstaden 1940837
Merge branch 'master' into clara/snowbridge-halt-preimage
rockbmb b8a42e3
refactor
claravanstaden 4dff6b0
Merge remote-tracking branch 'fork/clara/snowbridge-halt-preimage' in…
claravanstaden c065054
explanations
claravanstaden File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
19 changes: 19 additions & 0 deletions
19
packages/polkadot/src/assetHubPolkadot.bridgeHubPolkadot.snowbridgeGovernance.e2e.test.ts
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| import { assetHubPolkadot, bridgeHubPolkadot } from '@e2e-test/networks/chains' | ||
| import { setupNetworks, snowbridgeHaltResumeTest, verifyReferencePreimageHashes } from '@e2e-test/shared' | ||
|
|
||
| import { describe, test } from 'vitest' | ||
|
|
||
| // Exercises the version-controlled Snowbridge halt/resume governance preimage against forked Asset Hub + | ||
| // Bridge Hub. See `@e2e-test/shared/snowbridge/governance` for the documented test bodies and the rationale | ||
| // behind each assertion. | ||
| describe('Snowbridge governance halt/resume preimage', async () => { | ||
| const [assetHub, bridgeHub] = await setupNetworks(assetHubPolkadot, bridgeHubPolkadot) | ||
|
|
||
| test('reference hashes match reference bytes', () => { | ||
| verifyReferencePreimageHashes() | ||
| }) | ||
|
|
||
| test('committed halt preimage halts the bridge; resume restores it', async () => { | ||
| await snowbridgeHaltResumeTest(assetHub, bridgeHub) | ||
| }) | ||
| }) | ||
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
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,196 @@ | ||
| import { sendTransaction } from '@acala-network/chopsticks-testing' | ||
|
|
||
| import { defaultAccounts } from '@e2e-test/networks' | ||
|
|
||
| import { hexToU8a } from '@polkadot/util' | ||
| import { blake2AsHex } from '@polkadot/util-crypto' | ||
|
|
||
| import { expect } from 'vitest' | ||
|
|
||
| import { assertExpectedEvents, scheduleLookupCallWithOrigin } from '../helpers/index.js' | ||
| import type { Client } from '../types.js' | ||
| import reference from './referencePreimages.json' with { type: 'json' } | ||
|
|
||
| /** | ||
| * Snowbridge governance halt/resume tests. | ||
| * | ||
| * Snowbridge can be paused ("halted") and unpaused ("resumed") by governance. In production this is | ||
| * done by enacting a single, pre-built governance preimage. Because that preimage is the artifact an | ||
| * operator would actually submit in an emergency, this repo keeps a copy of it under version control | ||
| * (`referencePreimages.json`) so it can be reviewed, hashed, and tested against mainnet, end to end. | ||
| * | ||
| * These tests execute that committed preimage against a forked Asset Hub + Bridge Hub and assert that | ||
| * the bridge actually halts and then resumes. The point is to let an operator trust a freshly generated | ||
| * preimage by comparing its hash against the one proven here, rather than blindly submitting bytes. | ||
| * | ||
| * Asset Hub vs Bridge Hub | ||
| * ----------------------- | ||
| * The two chains play different roles in the bridge, so the halt has to touch both: | ||
| * - Bridge Hub contains the actual bridge machinery: the queues that handle messages to and from | ||
| * Ethereum, the Ethereum client (also called the beacon client in some places, named after Ethereum's | ||
| * consensus chain, the beacon chain) that tracks Ethereum consensus, and the Ethereum gateway contract | ||
| * (which is the entry point into Polkadot from Ethereum). This is where messages are accepted, | ||
| * verified, and dispatched. | ||
| * - Asset Hub is the user-facing entry point for P->E transfers. Users initiate a message from Asset | ||
| * Hub, and Asset Hub charges them the export base fee before forwarding the message (via XCM) to | ||
| * Bridge Hub. | ||
| * | ||
| * So a full halt sends an XCM from Asset Hub to Bridge Hub to stop the bridge machinery, and also halts the | ||
| * Asset Hub frontend + increases fees to the max value, so no new exports are even accepted upstream. | ||
| * | ||
| * V1 vs V2 | ||
| * -------- | ||
| * Snowbridge is mid-migration from a V1 protocol to a V2 protocol, and both run in parallel during the | ||
| * transition. Each version has its own inbound queue, gateway, and export base-fee key, so a complete halt | ||
| * must cover both: halting only one version would leave the other path open. | ||
| * | ||
| * What the halt preimage does (a `utility.forceBatch` enacted as Root on Asset Hub): | ||
| * 1. Sends an XCM to Bridge Hub that, in a nested batch, sets `operatingMode = Halted` on the inbound | ||
| * queues (V1 + V2), the outbound queue, the beacon client, and both Ethereum gateways (V1 + V2). This | ||
| * stops traffic in both directions: inbound (Ethereum -> Polkadot) is blocked by halting the inbound | ||
| * queues and freezing the beacon client (no consensus updates means inbound messages can't be proven), | ||
| * and outbound (Polkadot -> Ethereum) is blocked by halting the outbound queue and the gateways. | ||
| * 2. Sets the Asset Hub Snowbridge frontend export operating mode to `Halted`, blocking new exports. | ||
| * 3. Overwrites both export base-fee storage items (V1 + V2) with `u128::MAX`, so any export that slips | ||
| * through is economically impossible. This is defense-in-depth on top of step 2. | ||
| * | ||
| * The resume preimage is the symmetric inverse: it sets every operating mode back to `Normal` and | ||
| * restores the base fees to sane values. | ||
| */ | ||
|
|
||
| /** Largest u128 value; the halt preimage sets the bridge base fees to this to make message prohibitively expensive. */ | ||
| const MAX_U128 = (1n << 128n) - 1n | ||
|
|
||
| // Asset Hub storage keys for the Snowbridge export base fee (V1 + V2), addressed directly because they are | ||
| // not exposed as typed storage items. Each is `twox_128(":BridgeHubEthereumBaseFee:")` (V1) and | ||
| // `twox_128(":BridgeHubEthereumBaseFeeV2:")` (V2). | ||
| // | ||
| // This fee is the per-message price Asset Hub charges a user to export to Ethereum. The halt pins it to | ||
| // `u128::MAX`, which no account can pay, so the export is rejected on the fee check before it is ever | ||
| // forwarded to Bridge Hub. That is why writing this value halts (the outbound side of) the bridge. | ||
| const FEE_KEY_V1 = '0x5fbc5c7ba58845ad1f1a9a7c5bc12fad' | ||
| const FEE_KEY_V2 = '0xd0ed50b03e9a49e836dd934b425ba4c3' | ||
|
|
||
| // Bridge Hub pallets that expose a local `operatingMode` storage item we can read back to confirm the halt. | ||
| // | ||
| // Note the Gateway pallets V1/V2 (`ethereumSystem` / `ethereumSystemV2`) are deliberately absent: they have | ||
| // no local `operatingMode`. Their `setOperatingMode` instead enqueues an outbound command, which is covered | ||
| // by the `messageQueue.Processed` assertion rather than a storage read. | ||
| const BH_OPERATING_MODE_PALLETS = [ | ||
| 'ethereumInboundQueue', | ||
| 'ethereumInboundQueueV2', | ||
| 'ethereumOutboundQueue', | ||
| 'ethereumBeaconClient', | ||
| ] as const | ||
|
|
||
| type PreimageEntry = { hash: string; callData: string } | ||
|
|
||
| /** Read a raw Asset Hub storage key and decode it as a u128. */ | ||
| async function readFeeU128(assetHub: Client<any, any>, key: string): Promise<bigint> { | ||
| const raw = await assetHub.api.rpc.state.getStorage(key) | ||
| return assetHub.api.createType('u128', hexToU8a((raw as any).toHex())).toBigInt() | ||
| } | ||
|
|
||
| /** Read every Bridge Hub `operatingMode` of interest into a `{ pallet: mode }` map. */ | ||
| async function bridgeHubOperatingModes(bridgeHub: Client<any, any>): Promise<Record<string, string>> { | ||
| const out: Record<string, string> = {} | ||
| for (const p of BH_OPERATING_MODE_PALLETS) { | ||
| out[p] = (await (bridgeHub.api.query as any)[p].operatingMode()).toString() | ||
| } | ||
| return out | ||
| } | ||
|
|
||
| /** Expected `OperatingModeChanged` events, one per Bridge Hub pallet, for the given mode. */ | ||
| const bhModeChangedEvents = (bridgeHub: Client<any, any>, mode: string) => | ||
| BH_OPERATING_MODE_PALLETS.map((p) => ({ | ||
| type: (bridgeHub.api.events as any)[p].OperatingModeChanged, | ||
| args: { mode }, | ||
| })) | ||
|
|
||
| /** | ||
| * Enact one preimage entry as Root on Asset Hub. | ||
| * | ||
| * Mirrors what governance does: fund a submitter, note the preimage on chain, then schedule a Root-origin | ||
| * `Lookup` call referencing it so it executes in the next block. | ||
| */ | ||
| async function dispatchPreimageAsRoot(assetHub: Client<any, any>, entry: PreimageEntry): Promise<void> { | ||
| const blockProvider = assetHub.config.properties.schedulerBlockProvider | ||
| await assetHub.dev.setStorage({ | ||
| System: { | ||
| account: [[[defaultAccounts.alice.address], { providers: 1, data: { free: 10000n * 10n ** 10n } }]], | ||
| }, | ||
| }) | ||
| await sendTransaction(assetHub.api.tx.preimage.notePreimage(entry.callData).signAsync(defaultAccounts.alice)) | ||
| await assetHub.dev.newBlock() | ||
| // Preimage length in bytes: hex string minus the "0x" prefix, 2 chars per byte. | ||
| const len = (entry.callData.length - 2) / 2 | ||
| await scheduleLookupCallWithOrigin(assetHub, { hash: entry.hash, len }, { system: 'Root' }, blockProvider) | ||
| await assetHub.dev.newBlock() | ||
| } | ||
|
|
||
| /** | ||
| * Sanity check on the committed reference data itself: hashing the stored call bytes must reproduce the | ||
| * stored hash. This guards against the JSON drifting (e.g. bytes edited without regenerating the hash), | ||
| * which would silently invalidate any operator who trusts the hash. | ||
| */ | ||
| export function verifyReferencePreimageHashes(): void { | ||
| expect(blake2AsHex(reference.halt.callData, 256)).toBe(reference.halt.hash) | ||
| expect(blake2AsHex(reference.resume.callData, 256)).toBe(reference.resume.hash) | ||
| } | ||
|
|
||
| /** | ||
| * Full halt-then-resume round trip. | ||
| * | ||
| * Halt: enact the committed halt preimage on Asset Hub and assert the export operating mode flips to | ||
| * `Halted`, both base fees are pushed to `MAX_U128`, and the resulting XCM lands on Bridge Hub where every | ||
| * queued command processes successfully and each pallet's `operatingMode` reads back as `Halted`. | ||
| * | ||
| * Resume: enact the committed resume preimage and assert the symmetric reversal: mode back to `Normal`, | ||
| * fees back below `MAX_U128`, and every Bridge Hub pallet `Normal` again. | ||
| */ | ||
| export async function snowbridgeHaltResumeTest(assetHub: Client<any, any>, bridgeHub: Client<any, any>): Promise<void> { | ||
| // Halt | ||
| await dispatchPreimageAsRoot(assetHub, reference.halt) | ||
| assertExpectedEvents(await assetHub.api.query.system.events(), [ | ||
| { type: assetHub.api.events.polkadotXcm.Sent }, | ||
| { type: assetHub.api.events.snowbridgeSystemFrontend.ExportOperatingModeChanged, args: { mode: 'Halted' } }, | ||
| ]) | ||
| expect((await assetHub.api.query.snowbridgeSystemFrontend.exportOperatingMode()).toString()).toBe('Halted') | ||
| expect(await readFeeU128(assetHub, FEE_KEY_V1)).toBe(MAX_U128) | ||
| expect(await readFeeU128(assetHub, FEE_KEY_V2)).toBe(MAX_U128) | ||
|
|
||
| await bridgeHub.dev.newBlock() | ||
| // success: true proves every Transact ran, including the two gateway commands. | ||
| assertExpectedEvents(await bridgeHub.api.query.system.events(), [ | ||
| { type: bridgeHub.api.events.messageQueue.Processed, args: { success: true } }, | ||
| ...bhModeChangedEvents(bridgeHub, 'Halted'), | ||
| ]) | ||
| expect(await bridgeHubOperatingModes(bridgeHub)).toEqual({ | ||
| ethereumInboundQueue: 'Halted', | ||
| ethereumInboundQueueV2: 'Halted', | ||
| ethereumOutboundQueue: 'Halted', | ||
| ethereumBeaconClient: 'Halted', | ||
| }) | ||
|
|
||
| // Resume | ||
| await dispatchPreimageAsRoot(assetHub, reference.resume) | ||
| assertExpectedEvents(await assetHub.api.query.system.events(), [ | ||
| { type: assetHub.api.events.polkadotXcm.Sent }, | ||
| { type: assetHub.api.events.snowbridgeSystemFrontend.ExportOperatingModeChanged, args: { mode: 'Normal' } }, | ||
| ]) | ||
| expect((await assetHub.api.query.snowbridgeSystemFrontend.exportOperatingMode()).toString()).toBe('Normal') | ||
| expect(await readFeeU128(assetHub, FEE_KEY_V1)).toBeLessThan(MAX_U128) | ||
| expect(await readFeeU128(assetHub, FEE_KEY_V2)).toBeLessThan(MAX_U128) | ||
|
|
||
| await bridgeHub.dev.newBlock() | ||
| assertExpectedEvents(await bridgeHub.api.query.system.events(), [ | ||
| { type: bridgeHub.api.events.messageQueue.Processed, args: { success: true } }, | ||
| ...bhModeChangedEvents(bridgeHub, 'Normal'), | ||
| ]) | ||
| expect(await bridgeHubOperatingModes(bridgeHub)).toEqual({ | ||
| ethereumInboundQueue: 'Normal', | ||
| ethereumInboundQueueV2: 'Normal', | ||
| ethereumOutboundQueue: 'Normal', | ||
| ethereumBeaconClient: 'Normal', | ||
| }) | ||
| } |
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| { | ||
| "description": "Canonical Snowbridge full halt / full resume governance preimages. Reviewed by the Polkadot Fellowship and executed against forked live chains by polkadot-ecosystem-tests. Verify an operator-generated preimage by comparing its hash against the matching hash here.", | ||
| "generatedAt": "2026-06-11", | ||
| "assetHubRuntime": { | ||
| "specName": "statemint", | ||
| "specVersion": 2002001 | ||
| }, | ||
| "bridgeHubRuntime": { | ||
| "specName": "bridge-hub-polkadot", | ||
| "specVersion": 2002001 | ||
| }, | ||
| "halt": { | ||
| "hash": "0x73d11a7aaa375cac5a119988438d5dd2f771f083660461d5c82b49da6dff92bf", | ||
| "callData": "0x2804081f0005010100a90f05342f000006020107005847f80d020040000c530101200006020107005847f80d020040000c5a0101200006020107005847f80d020040000c500101200006020107005847f80d020040000c5b0101200006020107005847f80d020040000c510001200006020107005847f80d020040000c520301200028040c240001000404405fbc5c7ba58845ad1f1a9a7c5bc12fad40ffffffffffffffffffffffffffffffff00040440d0ed50b03e9a49e836dd934b425ba4c340ffffffffffffffffffffffffffffffff", | ||
| "encodedSize": 210 | ||
| }, | ||
| "resume": { | ||
| "hash": "0x19432aba252a5e3fed7b7273b48388c2667c1e94dc7899ad143d7bc2450985df", | ||
| "callData": "0x2804081f0005010100a90f05342f000006020107005847f80d020040000c530100200006020107005847f80d020040000c5a0100200006020107005847f80d020040000c500100200006020107005847f80d020040000c5b0100200006020107005847f80d020040000c510000200006020107005847f80d020040000c520300200028040c240000000404405fbc5c7ba58845ad1f1a9a7c5bc12fad4086b7de7903000000000000000000000000040440d0ed50b03e9a49e836dd934b425ba4c34000ca9a3b000000000000000000000000", | ||
| "encodedSize": 210 | ||
| } | ||
| } |
Oops, something went wrong.
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.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can you extract these test bodies out of the
describecalls? That also allows for documentation to be written that explains the purpose/mechanism behind these tests.I get that they're trivial to you as a Snowbridge maintainer, but consider someone without your context. Specifically:
FEE_KEY_V1,FEE_KEY_V2) actually control? Why does setting them to MAX_U128 halt the bridge?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@rockbmb good point! Added in b8a42e3.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@claravanstaden I was unclear in my previous comment. I've updated it with concrete examples of desirable things to comment, which I guess landed just before you pushed your commit! Sorry!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for the extra info @rockbmb! I added specific points to your questions in #636.