diff --git a/examples/papi/lower-timeouts.ts b/examples/papi/lower-timeouts.ts new file mode 100644 index 00000000..cc8ad34a --- /dev/null +++ b/examples/papi/lower-timeouts.ts @@ -0,0 +1,389 @@ +// SPDX-License-Identifier: Apache-2.0 + +/** + * Tune the storage-backed protocol parameters of a running chain via + * `sudo(System.set_storage)`, no runtime upgrade needed. + * + * Both runtimes declare the pallet-storage-provider parameters as + * `parameter_types! { pub storage X: T = default; }` (see + * `runtimes//src/storage.rs`): each lives at the well-known unhashed key + * `twox128(":X:")` and falls back to the compiled default while the key is + * absent. Setting the key changes the effective value from the next block; + * deleting it restores the default. Built for compressing protocol timing in + * dev and stress-test runs (paritytech/web3-storage#267). + * + * Usage: + * pnpm --filter web3-storage-papi-demo run lower-timeouts -- [options] + * + * --chain-rpc chain WS endpoint (default ws://127.0.0.1:2222) + * --suri sudo account (default //Alice) + * --profile fast minute-scale timing preset (see FAST_PROFILE) + * --set Name=Value set one parameter, repeatable (see PARAMS for names; + * values are blocks or plancks, plain integers) + * --restore delete all overrides → compiled defaults + * --list print current overrides + effective values, then exit + * --dry-run print what would be submitted without submitting + */ + +import { parseArgs } from "node:util"; + +// PAPI itself has no public twox128; the hasher and integer codecs are the +// only things taken from substrate-bindings, per the repo JS/TS guidelines. +import { Twox128, u16, u32, u128 } from "@polkadot-api/substrate-bindings"; +import { + connect, + disconnect, + makeSigner, + sameAddress, + submitTxFinalized, + syncSs58Prefix, + toHex, + waitForChainReady, + READ_OPTS, + type ChainConnection, + type ParachainApi, +} from "@web3-storage/sdk"; + +// ── Parameter table ────────────────────────────────────────────────────────── + +/** Every `pub storage` parameter in `runtimes//src/storage.rs`, with its SCALE type. */ +const PARAMS = { + // BlockNumber (u32); 6s blocks, so 10 blocks = 1 minute + ChallengeTimeout: "u32", + MaxNonceAge: "u32", + SettlementTimeout: "u32", + RequestTimeout: "u32", + DefaultCheckpointInterval: "u32", + DefaultCheckpointGrace: "u32", + DeregisterAnnouncementPeriod: "u32", + // Balance (u128), plancks + MinProviderStake: "u128", + ChallengeDeposit: "u128", + MinStakePerByte: "u128", + CheckpointReward: "u128", + CheckpointMissPenalty: "u128", + MaxChallengesPerDeadline: "u16", +} as const; + +type ParamName = keyof typeof PARAMS; +type ParamType = (typeof PARAMS)[ParamName]; + +const PARAM_NAMES = Object.keys(PARAMS) as ParamName[]; + +/** + * Minute-scale timing for stress/dev runs. Only timing parameters; economic + * ones (stakes, deposits, rewards) keep their defaults unless --set overrides + * them. + */ +const FAST_PROFILE: Partial> = { + ChallengeTimeout: 100n, // 10 min + SettlementTimeout: 100n, // 10 min + RequestTimeout: 50n, // 5 min + // Roomy on purpose: the sign → build → broadcast → finalize choreography + // must fit inside it or every commitment gets rejected as stale. + MaxNonceAge: 600n, // 1 h + DefaultCheckpointInterval: 20n, // 2 min + DefaultCheckpointGrace: 10n, // 1 min + DeregisterAnnouncementPeriod: 150n, // 15 min +}; + +const PROFILES: Record>> = { + fast: FAST_PROFILE, +}; + +const MAX_VALUE: Record = { + u16: 0xffffn, + u32: 0xffff_ffffn, + u128: (1n << 128n) - 1n, +}; + +// ── SCALE + key helpers ────────────────────────────────────────────────────── + +/** `parameter_types!(pub storage X …)` stores under `twox128(":X:")`. */ +function paramKey(name: ParamName): Uint8Array { + return Twox128(new TextEncoder().encode(`:${name}:`)); +} + +function encodeValue(name: ParamName, value: bigint): Uint8Array { + const ty = PARAMS[name]; + if (value < 0n || value > MAX_VALUE[ty]) { + throw new Error(`${name}: ${value} out of range for ${ty}`); + } + if (ty === "u128") return u128.enc(value); + return ty === "u32" ? u32.enc(Number(value)) : u16.enc(Number(value)); +} + +function decodeValue(name: ParamName, hex: string): bigint { + const ty = PARAMS[name]; + if (ty === "u128") return u128.dec(hex); + return BigInt(ty === "u32" ? u32.dec(hex) : u16.dec(hex)); +} + +/** Raw read of a parameter's override key; undefined = compiled default in effect. */ +async function readOverride( + { papi }: ChainConnection, + name: ParamName, +): Promise { + const raw = await papi._request("state_getStorage", [ + toHex(paramKey(name)), + ]); + return raw == null ? undefined : decodeValue(name, raw); +} + +/** Effective value as the pallet sees it (compiled default or override). */ +async function readEffective( + { api }: ChainConnection, + name: ParamName, +): Promise { + const constants = api.constants.StorageProvider as unknown as Record< + ParamName, + () => Promise + >; + return BigInt(await constants[name]()); +} + +// ── Safety checks ──────────────────────────────────────────────────────────── + +/** + * The runtime's `integrity_test` only runs at build time, so a bad override + * would silently break the deregistration security assumptions. Re-check the + * documented invariants here against the post-change effective values. + */ +function checkInvariants(effective: Map): void { + const dereg = effective.get("DeregisterAnnouncementPeriod")!; + const challenge = effective.get("ChallengeTimeout")!; + const request = effective.get("RequestTimeout")!; + if (dereg <= challenge) { + throw new Error( + `DeregisterAnnouncementPeriod (${dereg}) must be > ChallengeTimeout (${challenge})`, + ); + } + if (dereg <= request) { + throw new Error( + `DeregisterAnnouncementPeriod (${dereg}) must be > RequestTimeout (${request})`, + ); + } +} + +// ── CLI ────────────────────────────────────────────────────────────────────── + +function parseCli() { + const { values } = parseArgs({ + options: { + "chain-rpc": { type: "string", default: "ws://127.0.0.1:2222" }, + suri: { type: "string", default: "//Alice" }, + profile: { type: "string" }, + set: { type: "string", multiple: true }, + restore: { type: "boolean", default: false }, + list: { type: "boolean", default: false }, + "dry-run": { type: "boolean", default: false }, + help: { type: "boolean", default: false }, + }, + }); + + if (values.help) { + console.log(USAGE); + process.exit(0); + } + + const changes = new Map(); + if (values.profile) { + const profile = PROFILES[values.profile]; + if (!profile) { + throw new Error( + `unknown profile "${values.profile}" (available: ${Object.keys(PROFILES).join(", ")})`, + ); + } + for (const [name, value] of Object.entries(profile)) { + changes.set(name as ParamName, value); + } + } + for (const pair of values.set ?? []) { + const eq = pair.indexOf("="); + const name = pair.slice(0, eq) as ParamName; + if (eq === -1 || !(name in PARAMS)) { + throw new Error( + `--set expects Name=Value with Name one of: ${PARAM_NAMES.join(", ")}`, + ); + } + const value = BigInt(pair.slice(eq + 1)); + if (value < 0n || value > MAX_VALUE[PARAMS[name]]) { + throw new Error(`${name}: ${value} out of range for ${PARAMS[name]}`); + } + changes.set(name, value); + } + + if (values.restore && changes.size > 0) { + throw new Error("--restore cannot be combined with --profile/--set"); + } + if (!values.restore && !values.list && changes.size === 0) { + throw new Error( + "nothing to do: pass --profile/--set, --restore, or --list (--help for usage)", + ); + } + + return { + chainRpc: values["chain-rpc"], + suri: values.suri, + changes, + restore: values.restore, + list: values.list, + dryRun: values["dry-run"], + }; +} + +const USAGE = `lower-timeouts: tune storage-backed protocol parameters via sudo setStorage + + --chain-rpc chain WS endpoint (default ws://127.0.0.1:2222) + --suri sudo account (default //Alice) + --profile fast minute-scale timing preset + --set Name=Value set one parameter (repeatable) + --restore delete all overrides → compiled defaults + --list print current overrides + effective values + --dry-run print without submitting + +Parameters: ${PARAM_NAMES.join(", ")}`; + +// ── Main ───────────────────────────────────────────────────────────────────── + +async function main() { + const opts = parseCli(); + const conn = connect(opts.chainRpc); + const { api } = conn; + try { + // PAPI's WS provider retries forever, so an unreachable endpoint would + // hang waitForChainReady indefinitely; race it against a hard deadline. + await Promise.race([ + waitForChainReady(api), + new Promise((_, reject) => { + setTimeout( + () => reject(new Error(`no chain reachable at ${opts.chainRpc}`)), + 30_000, + ).unref(); + }), + ]); + await syncSs58Prefix(api); + + if (opts.list) { + for (const name of PARAM_NAMES) { + const [effective, override] = await Promise.all([ + readEffective(conn, name), + readOverride(conn, name), + ]); + const source = override === undefined ? "compiled default" : "override"; + console.log(` ${name} = ${effective} (${source})`); + } + return; + } + + if (opts.restore) { + const keys = PARAM_NAMES.map((n) => paramKey(n)); + if (opts.dryRun) { + console.log("would submit sudo(System.kill_storage) for keys:"); + for (const [i, n] of PARAM_NAMES.entries()) { + console.log(` ${n}: ${toHex(keys[i])}`); + } + return; + } + await submitSudo( + conn, + opts.suri, + api.tx.System.kill_storage({ keys }).decodedCall, + ); + console.log("✅ all overrides cleared, compiled defaults in effect"); + return; + } + + // Post-change effective values: proposed change, else current raw + // override (real-time), else the metadata constant. The constant is + // evaluated at the block PAPI pinned on connect, so a value changed in + // the last few blocks can read stale; a --profile run is immune (it + // proposes all three invariant parameters together). + const effective = new Map(); + for (const name of [ + "DeregisterAnnouncementPeriod", + "ChallengeTimeout", + "RequestTimeout", + ] as const) { + effective.set( + name, + opts.changes.get(name) ?? + (await readOverride(conn, name)) ?? + (await readEffective(conn, name)), + ); + } + checkInvariants(effective); + + const items: Array<[Uint8Array, Uint8Array]> = []; + for (const [name, value] of opts.changes) { + const before = await readEffective(conn, name); + console.log(` ${name}: ${before} → ${value}`); + items.push([paramKey(name), encodeValue(name, value)]); + } + if (opts.dryRun) { + console.log("dry run, would submit sudo(System.set_storage) with:"); + for (const [k, v] of items) console.log(` ${toHex(k)} = ${toHex(v)}`); + return; + } + + await submitSudo( + conn, + opts.suri, + api.tx.System.set_storage({ items }).decodedCall, + ); + + // Read the raw keys back: proves the overrides landed (the effective + // values via metadata constants would come from this client's cached + // runtime and can be stale within this process). + for (const [name, value] of opts.changes) { + const now = await readOverride(conn, name); + if (now !== value) { + throw new Error(`${name}: override readback ${now} != ${value}`); + } + } + console.log(`✅ ${opts.changes.size} parameter(s) overridden (verify with --list)`); + } finally { + disconnect(conn); + } +} + +type SudoInnerCall = Parameters[0]["call"]; + +async function submitSudo( + { papi, api }: ChainConnection, + suri: string, + call: SudoInnerCall, +): Promise { + const sudo = makeSigner(suri); + const sudoKey = await api.query.Sudo.Key.getValue(READ_OPTS); + if (!sudoKey || !sameAddress(sudoKey, sudo.address)) { + throw new Error( + `${sudo.address} (--suri) is not the sudo key (${sudoKey ?? "unset"})`, + ); + } + const tx = api.tx.Sudo.sudo({ call }); + const result = await submitTxFinalized(tx, sudo.signer, { + label: "sudo(setStorage)", + client: papi, + }); + // submitTx only asserts the sudo extrinsic dispatched; the inner call's + // outcome is in the Sudid event. + const sudid = (result.events as Array<{ type: string; value: any }>).find( + (e) => e.type === "Sudo" && e.value?.type === "Sudid", + ); + if (!sudid || sudid.value.value?.sudo_result?.success !== true) { + throw new Error( + `inner call failed: ${JSON.stringify(sudid?.value?.value ?? "no Sudid event")}`, + ); + } +} + +main().then( + // PAPI's teardown can leave the event loop alive after destroy(); exit + // explicitly like the e2e runners do. + () => process.exit(0), + (err) => { + console.error(`❌ ${err.message ?? err}`); + process.exit(1); + }, +); diff --git a/examples/papi/package.json b/examples/papi/package.json index 3b816761..92e0d3c0 100644 --- a/examples/papi/package.json +++ b/examples/papi/package.json @@ -10,10 +10,12 @@ "demo:checkpoint-missed": "node --import tsx checkpoint-missed.ts", "demo:sc-flow": "node --import tsx sc-flow.ts", "e2e": "node --import tsx e2e/runner.bin.ts", + "lower-timeouts": "node --import tsx lower-timeouts.ts", "typecheck": "tsc --noEmit" }, "dependencies": { "@polkadot-api/descriptors": "workspace:*", + "@polkadot-api/substrate-bindings": "^0.20.3", "@polkadot-labs/hdkd": "^0.0.28", "@polkadot-labs/hdkd-helpers": "^0.0.30", "@web3-storage/sdk": "workspace:*", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 76211862..fccce600 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: '@polkadot-api/descriptors': specifier: workspace:* version: link:../../packages/papi/.papi/descriptors + '@polkadot-api/substrate-bindings': + specifier: ^0.20.3 + version: 0.20.3 '@polkadot-labs/hdkd': specifier: ^0.0.28 version: 0.0.28 diff --git a/runtimes/web3-storage-local/src/storage.rs b/runtimes/web3-storage-local/src/storage.rs index 4e8ca49b..9279935e 100644 --- a/runtimes/web3-storage-local/src/storage.rs +++ b/runtimes/web3-storage-local/src/storage.rs @@ -14,37 +14,43 @@ use crate::{ AccountId, Balance, Balances, BlockNumber, Runtime, RuntimeEvent, }; +// Storage-backed parameters: each value below is the default, but can be +// overridden at runtime via `system.setStorage` (under sudo/governance) without +// a runtime upgrade. Useful for compressing protocol timing in dev and +// stress-test runs (see `examples/papi/lower-timeouts.ts`). Each `pub storage X` +// exposes `X::key()` / `X::set()` and reads the current value from unhashed +// storage, falling back to the default. parameter_types! { - pub const MinProviderStake: Balance = 1_000 * UNIT; // 1000 tokens minimum stake - pub const ChallengeTimeout: BlockNumber = 48 * HOURS; // 48 hours to respond + pub storage MinProviderStake: Balance = 1_000 * UNIT; // 1000 tokens minimum stake + pub storage ChallengeTimeout: BlockNumber = 48 * HOURS; // Replay-protection window for `CommitmentPayload::nonce`. A signature whose // nonce is older than this is rejected. Set wide enough to accommodate // normal off-chain choreography (provider signs, client builds & broadcasts // tx, tx finalises) without forcing re-signing. - pub const MaxNonceAge: BlockNumber = 24 * HOURS; + pub storage MaxNonceAge: BlockNumber = 24 * HOURS; // Reserved from the challenger when opening a challenge. 1 token at 12 // decimals = floor on spam economics. Previously hardcoded `100u32` // (1e-10 of a token) which made challenge spam effectively free. - pub const ChallengeDeposit: Balance = UNIT; - pub const SettlementTimeout: BlockNumber = 24 * HOURS; - pub const RequestTimeout: BlockNumber = 6 * HOURS; + pub storage ChallengeDeposit: Balance = UNIT; + pub storage SettlementTimeout: BlockNumber = 24 * HOURS; + pub storage RequestTimeout: BlockNumber = 6 * HOURS; // 1 token (1e12) per 1 GB (1e9 bytes) = 1000 per byte - pub const MinStakePerByte: Balance = 1_000; - pub const DefaultCheckpointInterval: BlockNumber = 100; - pub const DefaultCheckpointGrace: BlockNumber = 20; - pub const CheckpointReward: Balance = 1_000_000_000_000; // 1 token - pub const CheckpointMissPenalty: Balance = 500_000_000_000; // 0.5 token + pub storage MinStakePerByte: Balance = 1_000; + pub storage DefaultCheckpointInterval: BlockNumber = 100; + pub storage DefaultCheckpointGrace: BlockNumber = 20; + pub storage CheckpointReward: Balance = 1_000_000_000_000; // 1 token + pub storage CheckpointMissPenalty: Balance = 500_000_000_000; // 0.5 token /// Must be `> ChallengeTimeout` so any challenge opened up to the /// announcement block matures (provider stays slashable) before the /// provider can withdraw stake, and `> RequestTimeout` so a /// pre-deregistration agreement quote expires before re-registration (the /// re-register replay defense). Both are checked in `integrity_test`. /// Value: the 48h challenge window plus a 6h grace. - pub const DeregisterAnnouncementPeriod: BlockNumber = 54 * HOURS; + pub storage DeregisterAnnouncementPeriod: BlockNumber = 54 * HOURS; /// Caps the challenges sharing one deadline block so the `on_finalize` /// slash sweep stays bounded. Generous: only challenges created in the /// same block share a deadline. - pub const MaxChallengesPerDeadline: u16 = 1_000; + pub storage MaxChallengesPerDeadline: u16 = 1_000; } /// Treasury account that receives slashed funds.