diff --git a/integration-tests/README.md b/integration-tests/README.md index 982a91515..771579e4a 100644 --- a/integration-tests/README.md +++ b/integration-tests/README.md @@ -92,6 +92,12 @@ Unable to map [u8; 32] to a lookup index pnpm exec moonwall test zombienet_zeitgeist_upgrade --runInBand ``` +#### Test the upgrade to the WASM from `./target/release/wbuild/battery-station-runtime` on zombienet: + +```bash +pnpm exec moonwall test zombienet_battery_station_upgrade --runInBand +``` + #### Test the upgrade to the WASM from `./target/release/wbuild/zeitgeist-runtime` on the live main-net fork using chopsticks: ```bash diff --git a/integration-tests/configs/hydradx.yml b/integration-tests/configs/hydradx.yml index 1837b3d00..a18a0d186 100644 --- a/integration-tests/configs/hydradx.yml +++ b/integration-tests/configs/hydradx.yml @@ -1,4 +1,8 @@ -endpoint: wss://rpc.hydradx.cloud/public-ws +endpoint: + - wss://hydration-rpc.n.dwellir.com + - wss://hydration.ibp.network + - wss://rpc.helikon.io/hydradx + - wss://hydration.dotters.network mock-signature-host: true block: ${env.HYDRADX_BLOCK_NUMBER} db: ./tmp/hydradx_db_mba.sqlite diff --git a/integration-tests/configs/zombieBatteryStation.json b/integration-tests/configs/zombieBatteryStation.json new file mode 100644 index 000000000..d5736119f --- /dev/null +++ b/integration-tests/configs/zombieBatteryStation.json @@ -0,0 +1,45 @@ +{ + "settings": { + "timeout": 1000, + "provider": "native" + }, + "relaychain": { + "chain": "rococo-local", + "default_command": "./tmp/polkadot", + "default_args": ["--no-hardware-benchmarks", "-lparachain=debug", "--database=paritydb", "--no-beefy", "--detailed-log-output"], + "nodes": [ + { + "name": "charlie", + "rpc_port": 9947, + "validator": true + }, + { + "name": "bob", + "rpc_port": 9950, + "validator": true + } + ] + }, + "parachains": [ + { + "id": 2101, + "chain": "dev", + "collators": [ + { + "name": "alice", + "command": "../target/release/zeitgeist", + "rpc_port": 9944, + "p2p_port": 33049, + "args": ["-lparachain=debug", "--force-authoring", "--detailed-log-output"] + } + ] + } + ], + "types": { + "Header": { + "number": "u64", + "parent_hash": "Hash", + "post_state": "Hash" + } + } +} diff --git a/integration-tests/configs/zombieZeitgeist.json b/integration-tests/configs/zombieZeitgeist.json index d5736119f..16d66cd49 100644 --- a/integration-tests/configs/zombieZeitgeist.json +++ b/integration-tests/configs/zombieZeitgeist.json @@ -1,45 +1,55 @@ { - "settings": { - "timeout": 1000, - "provider": "native" - }, - "relaychain": { - "chain": "rococo-local", - "default_command": "./tmp/polkadot", - "default_args": ["--no-hardware-benchmarks", "-lparachain=debug", "--database=paritydb", "--no-beefy", "--detailed-log-output"], - "nodes": [ - { - "name": "charlie", - "rpc_port": 9947, - "validator": true - }, - { - "name": "bob", - "rpc_port": 9950, - "validator": true - } - ] - }, - "parachains": [ - { - "id": 2101, - "chain": "dev", - "collators": [ - { - "name": "alice", - "command": "../target/release/zeitgeist", - "rpc_port": 9944, - "p2p_port": 33049, - "args": ["-lparachain=debug", "--force-authoring", "--detailed-log-output"] - } - ] - } + "settings": { + "timeout": 1000, + "provider": "native" + }, + "relaychain": { + "chain": "rococo-local", + "default_command": "./tmp/polkadot", + "default_args": [ + "--no-hardware-benchmarks", + "-lparachain=debug", + "--database=paritydb", + "--no-beefy", + "--detailed-log-output" ], - "types": { - "Header": { - "number": "u64", - "parent_hash": "Hash", - "post_state": "Hash" + "nodes": [ + { + "name": "charlie", + "rpc_port": 9957, + "validator": true + }, + { + "name": "bob", + "rpc_port": 9960, + "validator": true + } + ] + }, + "parachains": [ + { + "id": 2092, + "chain": "./zeitgeist-parachain-2092.json", + "collators": [ + { + "name": "alice", + "command": "../target/release/zeitgeist", + "rpc_port": 9954, + "p2p_port": 33059, + "args": [ + "-lparachain=debug", + "--force-authoring", + "--detailed-log-output" + ] } + ] + } + ], + "types": { + "Header": { + "number": "u64", + "parent_hash": "Hash", + "post_state": "Hash" } + } } diff --git a/integration-tests/moonwall.config.json b/integration-tests/moonwall.config.json index 2e33b7c36..fb37099a6 100644 --- a/integration-tests/moonwall.config.json +++ b/integration-tests/moonwall.config.json @@ -3,6 +3,39 @@ "defaultTestTimeout": 120000, "scriptsDir": "scripts/", "environments": [ + { + "name": "zombienet_battery_station_upgrade", + "testFileDir": ["tests/rt-upgrade-zombienet"], + "runScripts": [ + "build-node.sh", + "build-zeitgeist-spec.sh", + "download-polkadot.sh" + ], + "foundation": { + "launchSpec": [ + { + "binPath": "../target/release/zeitgeist" + } + ], + "rtUpgradePath": "../target/release/wbuild/battery-station-runtime/battery_station_runtime.compact.compressed.wasm", + "type": "zombie", + "zombieSpec": { + "configPath": "./configs/zombieBatteryStation.json" + } + }, + "connections": [ + { + "name": "Relay", + "type": "polkadotJs", + "endpoints": ["ws://127.0.0.1:9947"] + }, + { + "name": "parachain", + "type": "polkadotJs", + "endpoints": ["ws://127.0.0.1:9944"] + } + ] + }, { "name": "zombienet_zeitgeist_upgrade", "testFileDir": ["tests/rt-upgrade-zombienet"], @@ -27,12 +60,12 @@ { "name": "Relay", "type": "polkadotJs", - "endpoints": ["ws://127.0.0.1:9947"] + "endpoints": ["ws://127.0.0.1:9957"] }, { "name": "parachain", "type": "polkadotJs", - "endpoints": ["ws://127.0.0.1:9944"] + "endpoints": ["ws://127.0.0.1:9954"] } ] }, diff --git a/integration-tests/package.json b/integration-tests/package.json index 08f6df946..7d8749ebb 100644 --- a/integration-tests/package.json +++ b/integration-tests/package.json @@ -12,6 +12,7 @@ "@polkadot/api": "16.5.2", "@polkadot/keyring": "13.5.2", "@polkadot/types": "16.5.2", + "@polkadot/util": "13.5.2", "@polkadot/util-crypto": "13.5.2", "@types/node": "22.10.2", "debug": "4.3.4", diff --git a/integration-tests/pnpm-lock.yaml b/integration-tests/pnpm-lock.yaml index e31e1a4b3..00ad9c999 100644 --- a/integration-tests/pnpm-lock.yaml +++ b/integration-tests/pnpm-lock.yaml @@ -57,6 +57,9 @@ importers: '@polkadot/types': specifier: 16.5.2 version: 16.5.2 + '@polkadot/util': + specifier: 13.5.2 + version: 13.5.2 '@polkadot/util-crypto': specifier: 13.5.2 version: 13.5.2(@polkadot/util@13.5.2) diff --git a/integration-tests/scripts/build-zeitgeist-spec.sh b/integration-tests/scripts/build-zeitgeist-spec.sh index 6a3d6dd4b..e1b970424 100755 --- a/integration-tests/scripts/build-zeitgeist-spec.sh +++ b/integration-tests/scripts/build-zeitgeist-spec.sh @@ -2,10 +2,68 @@ # SPDX-License-Identifier: GPL-3.0-or-later # Exit on any error -set -e +set -euo pipefail # Always run the commands from the "integration-tests" dir cd $(dirname $0)/.. mkdir -p specs -../target/release/zeitgeist build-spec --chain=zeitgeist --raw > specs/zeitgeist-parachain-2092.json \ No newline at end of file +# Start from the dev plain spec so zombienet can customize it. Avoid parsing +# and re-stringifying the whole JSON (which can switch large integers into +# exponent form) by doing targeted string replacements. +tmp_spec="$(mktemp)" +../target/release/zeitgeist build-spec --chain=dev --disable-default-bootnode > "${tmp_spec}" + +out_path="$(pwd)/zeitgeist-parachain-2092.json" + +node - "${tmp_spec}" "${out_path}" <<'NODE' +const fs = require("fs"); +const path = require("path"); +const [,, inPath, outPath] = process.argv; +if (!inPath || !outPath) { + console.error("spec path arg missing"); + process.exit(1); +} + +let text = fs.readFileSync(inPath, "utf8"); +const replace = (pattern, replacement, label) => { + const next = text.replace(pattern, replacement); + if (next === text) { + console.error(`WARN: pattern not replaced (${label})`); + } + text = next; +}; + +replace(/"name"\s*:\s*"[^"]*"/, '"name": "Zeitgeist Rococo Local"', "name"); +replace(/"id"\s*:\s*"[^"]*"/, '"id": "zeitgeist_rococo_local"', "id"); +replace(/"relay_chain"\s*:\s*"[^"]*"/, '"relay_chain": "rococo-local"', "relay_chain"); +replace(/"parachain_id"\s*:\s*\d+/, '"parachain_id": 2092', "parachain_id"); +replace(/"chainType"\s*:\s*"[^"]*"/, '"chainType": "Local"', "chainType"); +replace(/"bootNodes"\s*:\s*\[[\s\S]*?\]/, '"bootNodes": []', "bootNodes"); +replace(/"telemetryEndpoints"\s*:\s*(\[[\s\S]*?\]|null)/, '"telemetryEndpoints": null', "telemetryEndpoints"); +replace(/"protocolId"\s*:\s*(null|"[^"]*")/, '"protocolId": "zeitgeist-rococo"', "protocolId"); +replace(/"ss58Format"\s*:\s*\d+/, '"ss58Format": 73', "ss58Format"); +replace(/"tokenDecimals"\s*:\s*[^,}\n]+/, '"tokenDecimals": 10', "tokenDecimals"); +replace(/"tokenSymbol"\s*:\s*"[^"]*"/, '"tokenSymbol": "ZTG"', "tokenSymbol"); +replace(/("parachainInfo"\s*:\s*\{\s*"parachainId"\s*:\s*)\d+/, "$1 2092", "parachainInfo.parachainId"); + +fs.writeFileSync(outPath, text); +console.log(`Wrote ${outPath}`); + +// Keep the runtime wasm that Moonwall will upload in sync with the code baked +// into the genesis so the upgrade test compares against the correct artifact. +try { + const spec = JSON.parse(text); + const codeHex = spec?.genesis?.runtimeGenesis?.code || spec?.genesis?.runtime?.code; + if (typeof codeHex === "string" && codeHex.startsWith("0x")) { + const wasmPath = path.resolve(process.cwd(), "../target/release/wbuild/zeitgeist-runtime/zeitgeist_runtime.compact.compressed.wasm"); + fs.mkdirSync(path.dirname(wasmPath), { recursive: true }); + fs.writeFileSync(wasmPath, Buffer.from(codeHex.slice(2), "hex")); + console.log(`Wrote runtime wasm to ${wasmPath}`); + } else { + console.error("WARN: runtime code not found in spec; skipping wasm write"); + } +} catch (err) { + console.error("WARN: failed to parse spec JSON to write wasm", err?.message || err); +} +NODE diff --git a/integration-tests/scripts/download-polkadot.sh b/integration-tests/scripts/download-polkadot.sh index dcd9b6fac..b5cb4cbb4 100755 --- a/integration-tests/scripts/download-polkadot.sh +++ b/integration-tests/scripts/download-polkadot.sh @@ -190,6 +190,10 @@ delete_if_not_binary "tmp/polkadot-prepare-worker" if [[ -x tmp/polkadot && -x tmp/polkadot-execute-worker && -x tmp/polkadot-prepare-worker ]]; then POLKADOT_VERSION=$(tmp/polkadot --version || true) + if [[ -n "${POLKADOT_SKIP_DOWNLOAD:-}" ]]; then + echo "POLKADOT_SKIP_DOWNLOAD set; using existing polkadot binaries (${POLKADOT_VERSION:-unknown})." + exit 0 + fi if [[ "${POLKADOT_RELEASE}" == "latest" || "${POLKADOT_VERSION}" == *"${POLKADOT_RELEASE}"* ]]; then echo "Polkadot binaries already match the requested release." exit 0 diff --git a/integration-tests/tests/rt-upgrade-zombienet/test-zombienet-runtime-upgrade.ts b/integration-tests/tests/rt-upgrade-zombienet/test-zombienet-runtime-upgrade.ts index 247790815..b93ac0e7e 100644 --- a/integration-tests/tests/rt-upgrade-zombienet/test-zombienet-runtime-upgrade.ts +++ b/integration-tests/tests/rt-upgrade-zombienet/test-zombienet-runtime-upgrade.ts @@ -24,6 +24,8 @@ import { } from "@moonwall/cli"; import { KeyringPair } from "@moonwall/util"; import { ApiPromise, Keyring } from "@polkadot/api"; +import { blake2AsHex } from "@polkadot/util-crypto"; +import { u8aToBigInt } from "@polkadot/util"; import fs from "node:fs"; import { RuntimeVersion } from "@polkadot/types/interfaces"; @@ -83,6 +85,12 @@ describeSuite({ const codeString = currentCode.toString(); const moonwallContext = await MoonwallContext.getContext(); + const specVersion = ( + paraApi.consts.system.version as unknown as RuntimeVersion + ).specVersion.toNumber(); + log( + `Parachain specVersion=${specVersion}, block=${blockNumberBefore}, rtUpgradePath=${moonwallContext.rtUpgradePath}` + ); log( "Moonwall Context providers: " + moonwallContext.providers.map((p) => p.name).join(", ") @@ -109,16 +117,188 @@ describeSuite({ ); } - await context.upgradeRuntime({ from: alice, logger: log }); + const txStatus = async (tx: any, label: string) => + new Promise((resolve, reject) => { + let unsubscribe: (() => void) | undefined; + tx.signAndSend(alice, (result: any) => { + if (result.dispatchError) { + // Dispatch errors won't throw, so surface them explicitly. + const errText = result.dispatchError.toString(); + log(`${label} dispatchError=${errText}`); + reject(new Error(`${label} failed: ${errText}`)); + unsubscribe?.(); + return; + } + log( + `${label} status=${result.status?.type ?? "unknown"}, events=${result.events + ?.map((ev: any) => `${ev.event.section}.${ev.event.method}`) + .join(",")}` + ); + if (result.status?.isInBlock || result.status?.isFinalized) { + unsubscribe?.(); + resolve(); + } + }) + .then((unsub: () => void) => { + unsubscribe = unsub; + }) + .catch(reject); + }); + + const findCall = (callName: string) => { + for (const [section, calls] of Object.entries(paraApi.tx)) { + const typedCalls = calls as Record; + if (typedCalls?.[callName]) { + return { call: typedCalls[callName], section }; + } + } + return undefined; + }; + + const upgradeCallLocation = { + authorize: undefined as string | undefined, + enact: undefined as string | undefined, + }; + + const authorizeUpgradeResult = findCall("authorizeUpgrade"); + if (authorizeUpgradeResult) { + upgradeCallLocation.authorize = authorizeUpgradeResult.section; + } + + // On this SDK the enact call lives in frame-system as applyAuthorizedUpgrade. + const applyAuthorizedUpgradeResult = findCall("applyAuthorizedUpgrade"); + if (applyAuthorizedUpgradeResult) { + upgradeCallLocation.enact = applyAuthorizedUpgradeResult.section; + } + + const authorizeUpgrade = authorizeUpgradeResult?.call; + const applyAuthorizedUpgrade = applyAuthorizedUpgradeResult?.call; + const upgradeAvailable = authorizeUpgrade && applyAuthorizedUpgrade; + + const upgradeSections = Object.keys(paraApi.tx).filter((section) => + /parachain|upgrade|system/i.test(section) + ); + log(`tx sections matching /parachain|upgrade|system/: ${upgradeSections.join(",")}`); + + if (upgradeAvailable) { + // Zeitgeist runtime blocks `setCode`, so use the authorized upgrade flow. + const wasmHash = blake2AsHex(wasm); + log("Authorizing runtime upgrade via system.authorizeUpgrade"); + const authorizeTx = + authorizeUpgrade.meta.args.length === 1 + ? authorizeUpgrade(wasmHash) + : authorizeUpgrade(wasmHash, true); + log( + `authorizeUpgrade located in section=${upgradeCallLocation.authorize}, args=${authorizeUpgrade.meta.args.length}` + ); + await txStatus( + paraApi.tx.sudo.sudo(authorizeTx), + "authorizeUpgrade" + ); + + log("Waiting for validation function approval"); + await context.waitBlock(2); + + log("Enacting authorized upgrade"); + log( + `applyAuthorizedUpgrade located in section=${upgradeCallLocation.enact}, args=${applyAuthorizedUpgrade.meta.args.length}` + ); + await txStatus( + paraApi.tx.sudo.sudo(applyAuthorizedUpgrade(rtHex)), + "applyAuthorizedUpgrade" + ); + } else { + throw new Error( + "Runtime upgrade calls missing in metadata; expected system.authorizeUpgrade/applyAuthorizedUpgrade" + ); + } + await context.waitBlock(2); const blockNumberAfter = ( await paraApi.rpc.chain.getBlock() ).block.header.number.toNumber(); - log(`Before: #${blockNumberBefore}, After: #${blockNumberAfter}`); + const codeAfter = (await paraApi.rpc.state.getStorage(":code"))?.toString(); + log( + `Before: #${blockNumberBefore}, After: #${blockNumberAfter}, code changed=${ + codeAfter !== codeString + }` + ); + log( + `Code (before): ${codeString.slice(0, 10)}...${codeString.slice(-10)}, code (after): ${ + codeAfter ? codeAfter.slice(0, 10) + "..." + codeAfter.slice(-10) : "undefined" + }` + ); expect( blockNumberAfter, "Block number did not increase" ).to.be.greaterThan(blockNumberBefore); + expect(codeAfter, "Runtime code should match upgraded wasm").to.equal(rtHex); + }, + }); + + it({ + id: "T03", + title: "Relay timestamp (from relay proof) is present and increases across blocks", + timeout: 120000, + test: async function () { + const relayTsStorageKey = + "0x54dbd40f5201dbc18b0eed4b2ecd9cc67e2cdf745d68eeb295336330e3a1a063"; + + const readRelayTs = async (): Promise => { + const raw = await paraApi.rpc.state.getStorage(relayTsStorageKey); + expect(raw, "RelayTimestampNow storage should exist").to.not.be.null; + const rawHex = raw?.toHex(); + expect(rawHex, "RelayTimestampNow should decode to hex").to.not.be.undefined; + log(`RelayTimestampNow raw=${rawHex}`); + // Storage encodes u64 little-endian; decode explicitly. + const ts = u8aToBigInt(raw?.toU8a(true) ?? new Uint8Array(), true); + return ts; + }; + + let tsRelay1 = 0n; + let retries = 0; + while (tsRelay1 === 0n && retries < 5) { + log(`Attempt ${retries + 1}: reading RelayTimestampNow`); + tsRelay1 = await readRelayTs(); + const rawDirect = await paraApi.rpc.state.getStorage( + relayTsStorageKey + ); + log(`RelayTimestampNow direct RPC read: ${rawDirect?.toHex() ?? "null"}`); + if (tsRelay1 === 0n) { + await context.waitBlock(1); + } + retries++; + } + + const tsPara1 = (await paraApi.query.timestamp.now()).toBigInt(); + const block1 = ( + await paraApi.rpc.chain.getBlock() + ).block.header.number.toNumber(); + + expect(tsRelay1, "Initial relay timestamp should be non-zero").to.be.greaterThan(0n); + const drift = tsPara1 > tsRelay1 ? tsPara1 - tsRelay1 : tsRelay1 - tsPara1; + const driftLimitMs = 5_000n; // allow small drift between local timestamp inherent and relay value + expect(drift, "Parachain timestamp should be close to relay timestamp").to.be.lte( + driftLimitMs + ); + + await context.waitBlock(2); + + const tsRelay2 = await readRelayTs(); + const tsPara2 = (await paraApi.query.timestamp.now()).toBigInt(); + const block2 = ( + await paraApi.rpc.chain.getBlock() + ).block.header.number.toNumber(); + + expect(block2, "Block height should advance").to.be.greaterThan(block1); + expect( + tsRelay2, + "Relay timestamp should increase with new relay proofs" + ).to.be.greaterThan(tsRelay1); + const drift2 = tsPara2 > tsRelay2 ? tsPara2 - tsRelay2 : tsRelay2 - tsPara2; + expect(drift2, "Parachain timestamp should be close to relay timestamp").to.be.lte( + driftLimitMs + ); }, }); },