Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 () => {

@rockbmb rockbmb Jun 29, 2026

Copy link
Copy Markdown
Collaborator

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 describe calls? 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:

  • What do the fee keys (FEE_KEY_V1, FEE_KEY_V2) actually control? Why does setting them to MAX_U128 halt the bridge?
  • What's the relationship between Asset Hub and Bridge Hub in this halt flow?
  • Why are there V1 and V2 versions of everything?
  • What does "halt" actually mean operationally - does it stop inbound, outbound, or both?

Copy link
Copy Markdown
Contributor Author

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.

Copy link
Copy Markdown
Collaborator

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!

Copy link
Copy Markdown
Contributor Author

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.

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)
})
})
1 change: 1 addition & 0 deletions packages/shared/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export * from './registrar.js'
export * from './remoteProxy.js'
export * from './scheduler.js'
export * from './setup.js'
export * from './snowbridge/governance.js'
export * from './staking.js'
export * from './system.js'
export * from './treasury.js'
Expand Down
196 changes: 196 additions & 0 deletions packages/shared/src/snowbridge/governance.ts
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',
})
}
22 changes: 22 additions & 0 deletions packages/shared/src/snowbridge/referencePreimages.json
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
}
}
Loading