diff --git a/README.md b/README.md index c6a65f9..8fa9ad0 100644 --- a/README.md +++ b/README.md @@ -539,6 +539,55 @@ quantus call \ --- +### Chain Exercise Suite + +`quantus exercise` runs a live-node smoke/fuzz suite against a node — reads, balances, +reversible transfers, multisig, recovery, preimage, governance, negative cases, a seeded +fuzz loop, and a wormhole round-trip. It derives a handful of ephemeral accounts, funds them +from a **root account**, drives each pallet, and verifies on-chain state as it goes. Intended +for CI and post-upgrade validation. + +```bash +# Against a local dev node — crystal_alice is genesis-funded, so every phase runs +quantus exercise + +# Against a public testnet: fund from your own wallet, on a small budget, and skip +# governance (that phase needs the dev genesis tech-collective accounts) +quantus exercise \ + --root-account my-wallet --root-password \ + --total-amount 40 --skip governance \ + --node-url wss://a1-planck.quantus.cat + +# Run only specific phases, or skip the CPU-heavy wormhole phase +quantus exercise --phases reads,balances,multisig +quantus exercise --skip wormhole + +# Reproduce a fuzz failure from its seed; emit the report as JSON +quantus exercise --seed 12345 --json +``` + +Key flags: + +| Flag | Default | Description | +|------|---------|-------------| +| `--root-account ` | `crystal_alice` | Wallet that funds the run. Supply your own to run against a public testnet. | +| `--root-password ` / `--root-password-file ` | — | Password for the root wallet (or set `QUANTUS_WALLET_PASSWORD_`). | +| `--total-amount ` | `40` | Total budget drawn from the root account, split evenly across the ephemeral accounts. Discretionary test amounts are scaled to fit; fixed chain deposits (existential deposit, multisig/preimage/governance) are always covered on top. | +| `--ephemeral-accounts ` | `4` | Number of ephemeral accounts to derive and fund. | +| `--phases ` / `--skip ` | all | Comma-separated phases to run / skip. | +| `--seed ` | random | Reproducible fuzz seed. | +| `--fuzz-iterations ` | `25` | Number of fuzz iterations. | +| `--upgrade-wasm ` | — | Enable the runtime-upgrade phase with the given WASM (fast-governance node only). | +| `--fail-fast` | off | Stop at the first failed step. | +| `--json` | off | Emit the final report as JSON. | + +> **Notes:** +> - The `governance` phase relies on the dev genesis tech-collective accounts, so pass +> `--skip governance` when using a custom `--root-account` on a public testnet. +> - The `wormhole` phase is CPU-heavy (ZK proving) — use `--skip wormhole` for a faster run. +> - The suite fails fast in setup with a clear message if the root account can't cover +> `--total-amount` or the budget is too low for the fixed deposits. + ### Other Commands | Command | Description | diff --git a/src/cli/exercise/mod.rs b/src/cli/exercise/mod.rs index 86b68e1..d79dd63 100644 --- a/src/cli/exercise/mod.rs +++ b/src/cli/exercise/mod.rs @@ -15,6 +15,12 @@ use report::Report; use runner::ExerciseCtx; use std::path::PathBuf; +/// Divisor applied to every *discretionary* token amount used by the scenarios (transfers, +/// multisig funding, wormhole amount, …) so the suite can run on a small budget. Fixed, +/// chain-imposed amounts (existential deposit, multisig/preimage/governance deposits) are +/// read from the chain and never scaled. +pub(crate) const DISCRETIONARY_SCALE: u128 = 100; + #[derive(Args, Debug)] pub struct ExerciseArgs { /// Phases to run (default: all except upgrade; pass --upgrade-wasm to enable it). @@ -42,6 +48,28 @@ pub struct ExerciseArgs { #[arg(long, default_value_t = 4)] pub ephemeral_accounts: usize, + /// Wallet name to fund the exercise from. Defaults to the built-in `crystal_alice` dev + /// account (genesis-funded on `--dev` nodes). Supply a wallet of your own to run against + /// a public testnet. The `governance` phase still relies on the dev genesis accounts, so + /// pass `--skip governance` when using a custom root account. + #[arg(long)] + pub root_account: Option, + + /// Password for the `--root-account` wallet (or set `QUANTUS_WALLET_PASSWORD_`). + #[arg(long)] + pub root_password: Option, + + /// Read the `--root-account` password from a file. + #[arg(long)] + pub root_password_file: Option, + + /// Total budget, in whole tokens, drawn from the root account to fund the ephemeral test + /// accounts (split evenly across them). Fixed chain deposits (existential deposit, + /// multisig/preimage deposits) are covered on top and are not scaled; discretionary test + /// transfers are scaled down internally so a small budget suffices. + #[arg(long, default_value_t = 40.0)] + pub total_amount: f64, + #[arg(long)] pub fail_fast: bool, @@ -227,19 +255,78 @@ async fn setup( let ed_addr = quantus_subxt::api::constants().balances().existential_deposit(); let existential_deposit = client.client().constants().at(&ed_addr)?; - let alice = QuantumKeyPair::from_resonance_pair(&qp_dilithium_crypto::crystal_alice()); + let test_unit = unit / DISCRETIONARY_SCALE; + let bob = QuantumKeyPair::from_resonance_pair(&qp_dilithium_crypto::dilithium_bob()); let charlie = QuantumKeyPair::from_resonance_pair(&qp_dilithium_crypto::crystal_charlie()); - // Wormhole loads dev wallets by name from disk. - ensure_dev_wallets_on_disk().await?; + // Resolve the funding ("root") account. Defaults to the built-in crystal_alice dev + // account; a custom wallet can be supplied for public-testnet runs. + let (alice, root_name, root_password) = match &args.root_account { + Some(name) => { + let password = crate::wallet::password::get_wallet_password( + name, + args.root_password.clone(), + args.root_password_file.clone(), + )?; + let manager = crate::wallet::WalletManager::new()?; + let wallet_data = manager.load_wallet(name, &password)?; + (wallet_data.keypair, name.clone(), password) + }, + None => { + // Wormhole loads the dev wallet by name from disk. + ensure_dev_wallets_on_disk().await?; + ( + QuantumKeyPair::from_resonance_pair(&qp_dilithium_crypto::crystal_alice()), + "crystal_alice".to_string(), + String::new(), + ) + }, + }; + + let count = args.ephemeral_accounts.max(4); + let total_budget = (args.total_amount * unit as f64).round() as u128; + let funding_per_account = total_budget.checked_div(count as u128).unwrap_or(0); + + // The scaled discretionary base must stay above the existential deposit: the batch + // scenario creates fresh accounts holding `test_unit / 2`, which would be reaped below ED. + if test_unit == 0 || test_unit / 2 < existential_deposit { + return Err(QuantusError::Generic(format!( + "chain unit ({unit}) is too small relative to the existential deposit \ + ({existential_deposit}) for the built-in test scale (÷{DISCRETIONARY_SCALE})" + ))); + } + // Each ephemeral account must also cover fixed deposits (multisig, preimage) plus its + // scaled discretionary spend and fees. Guard against an obviously-too-small budget. + let min_per_account = existential_deposit + .saturating_mul(2) + .saturating_add(test_unit.saturating_mul(30)); + if funding_per_account < min_per_account { + return Err(QuantusError::Generic(format!( + "--total-amount {} is too low: only {funding_per_account} raw units per account \ + across {count} accounts (need at least {min_per_account} each to cover deposits)", + args.total_amount + ))); + } + + // Preflight: fail early with a clear message if the funder can't pay, instead of a raw + // "Inability to pay some fees" RPC rejection mid-run. + let root_ss58 = alice.to_account_id_ss58check(); + let root_balance = crate::cli::send::get_balance(&client, &root_ss58).await?; + if root_balance < total_budget { + return Err(QuantusError::Generic(format!( + "root account {root_name} ({root_ss58}) holds {root_balance} raw units but \ + --total-amount needs {total_budget}; fund it or lower --total-amount" + ))); + } report.record( "setup", "connect_and_wallets", started.elapsed(), Ok(format!( - "connected to {node_url}; token {symbol} ({decimals} decimals), ED {existential_deposit}" + "connected to {node_url}; token {symbol} ({decimals} decimals), ED {existential_deposit}; \ + funder {root_name} ({root_balance} raw), funding {count} accounts with {funding_per_account} each" )), ); @@ -252,15 +339,16 @@ async fn setup( bob, charlie, eph: Vec::new(), - unit, + test_unit, existential_deposit, + root_name, + root_password, rng, seed, fuzz_iterations: args.fuzz_iterations, }; - let count = args.ephemeral_accounts.max(4); - let funding_result = fund_ephemeral_accounts(&mut ctx, count).await; + let funding_result = fund_ephemeral_accounts(&mut ctx, count, funding_per_account).await; report.record("setup", "fund_ephemeral_accounts", started.elapsed(), funding_result); if report.has_failures() { return Err(QuantusError::Generic("setup failed while funding accounts".to_string())); @@ -280,8 +368,11 @@ async fn ensure_dev_wallets_on_disk() -> Result<()> { Ok(()) } -async fn fund_ephemeral_accounts(ctx: &mut ExerciseCtx, count: usize) -> Result { - let funding_per_account = 1_000 * ctx.unit; +async fn fund_ephemeral_accounts( + ctx: &mut ExerciseCtx, + count: usize, + funding_per_account: u128, +) -> Result { let mut addresses = Vec::with_capacity(count); for _ in 0..count { let keypair = ctx.fresh_keypair()?; @@ -325,5 +416,7 @@ async fn fund_ephemeral_accounts(ctx: &mut ExerciseCtx, count: usize) -> Result< } else { String::new() }; - Ok(format!("derived and funded {count} ephemeral accounts with 1000 tokens each{note}")) + Ok(format!( + "derived and funded {count} ephemeral accounts with {funding_per_account} raw units each{note}" + )) } diff --git a/src/cli/exercise/runner.rs b/src/cli/exercise/runner.rs index b321aed..d5c6be0 100644 --- a/src/cli/exercise/runner.rs +++ b/src/cli/exercise/runner.rs @@ -15,8 +15,15 @@ pub struct ExerciseCtx { pub bob: QuantumKeyPair, pub charlie: QuantumKeyPair, pub eph: Vec, - pub unit: u128, + /// Scaled-down base for discretionary test amounts (`unit / DISCRETIONARY_SCALE`). + /// Fixed chain amounts (existential deposit, deposits) are never derived from this. + pub test_unit: u128, pub existential_deposit: u128, + /// Name of the funding ("root") wallet on disk, used by scenarios that load it by + /// name (e.g. wormhole). + pub root_name: String, + /// Resolved password for the root wallet (empty string for dev accounts). + pub root_password: String, pub rng: StdRng, pub seed: u64, pub fuzz_iterations: u32, diff --git a/src/cli/exercise/scenarios/balances.rs b/src/cli/exercise/scenarios/balances.rs index 66b7487..77826b6 100644 --- a/src/cli/exercise/scenarios/balances.rs +++ b/src/cli/exercise/scenarios/balances.rs @@ -22,7 +22,7 @@ pub async fn run(ctx: &mut ExerciseCtx, report: &mut Report, phase: &str) -> Res async fn single_transfer(ctx: &mut ExerciseCtx) -> Result { let recipient = ctx.fresh_keypair()?; let recipient_ss58 = recipient.to_account_id_ss58check(); - let amount = ctx.unit; + let amount = ctx.test_unit; let sender = ctx.eph[0].clone(); let before = ctx.free_balance(&recipient_ss58).await?; @@ -50,7 +50,7 @@ async fn batch_transfer(ctx: &mut ExerciseCtx) -> Result { for _ in 0..n { recipients.push(ctx.fresh_keypair()?.to_account_id_ss58check()); } - let amount = ctx.unit / 2; + let amount = ctx.test_unit / 2; let transfers: Vec<(String, u128)> = recipients.iter().map(|r| (r.clone(), amount)).collect(); let sender = ctx.eph[0].clone(); @@ -70,8 +70,8 @@ async fn batch_transfer(ctx: &mut ExerciseCtx) -> Result { async fn transfer_with_tip(ctx: &mut ExerciseCtx) -> Result { let recipient = ctx.fresh_keypair()?.to_account_id_ss58check(); - let amount = ctx.unit; - let tip = ctx.unit / 10; + let amount = ctx.test_unit; + let tip = ctx.test_unit / 10; let sender = ctx.eph[1].clone(); crate::cli::send::transfer( &ctx.client, @@ -100,7 +100,7 @@ async fn transfer_manual_nonce(ctx: &mut ExerciseCtx) -> Result { &ctx.client, &sender, &recipient, - ctx.unit, + ctx.test_unit, None, Some(nonce as u32), ctx.wait_mode(), diff --git a/src/cli/exercise/scenarios/fuzz.rs b/src/cli/exercise/scenarios/fuzz.rs index d533821..7e9184c 100644 --- a/src/cli/exercise/scenarios/fuzz.rs +++ b/src/cli/exercise/scenarios/fuzz.rs @@ -58,7 +58,7 @@ fn random_amount(ctx: &mut ExerciseCtx) -> u128 { 2 => ed.saturating_sub(1), 3 => ed, 4 => ed.saturating_add(1), - 5 => ctx.rng.random_range(1..=100) * ctx.unit, + 5 => ctx.rng.random_range(1..=100) * ctx.test_unit, _ => u128::MAX, } } diff --git a/src/cli/exercise/scenarios/multisig.rs b/src/cli/exercise/scenarios/multisig.rs index e2b3925..674ee27 100644 --- a/src/cli/exercise/scenarios/multisig.rs +++ b/src/cli/exercise/scenarios/multisig.rs @@ -51,7 +51,7 @@ async fn lifecycle(ctx: &mut ExerciseCtx) -> Result { &ctx.client, &signer_a, &multisig_ss58, - 20 * ctx.unit, + 20 * ctx.test_unit, None, ctx.wait_mode(), ) @@ -59,7 +59,7 @@ async fn lifecycle(ctx: &mut ExerciseCtx) -> Result { let recipient = ctx.fresh_keypair()?; let recipient_ss58 = recipient.to_account_id_ss58check(); - let amount = 2 * ctx.unit; + let amount = 2 * ctx.test_unit; let inner = quantus_subxt::api::tx().balances().transfer_allow_death( subxt::ext::subxt_core::utils::MultiAddress::Id(account_id_of(&recipient)), amount, diff --git a/src/cli/exercise/scenarios/negative.rs b/src/cli/exercise/scenarios/negative.rs index 97e8fd9..7db0278 100644 --- a/src/cli/exercise/scenarios/negative.rs +++ b/src/cli/exercise/scenarios/negative.rs @@ -88,7 +88,7 @@ async fn stale_nonce(ctx: &mut ExerciseCtx) -> Result { )); } let recipient = ctx.fresh_keypair()?; - let call = transfer_call(account_id_of(&recipient), ctx.unit); + let call = transfer_call(account_id_of(&recipient), ctx.test_unit); match crate::cli::common::submit_transaction_with_nonce( &ctx.client, &sender, @@ -119,7 +119,7 @@ async fn reversible_delay_too_short(ctx: &mut ExerciseCtx) -> Result { use quantus_subxt::api::reversible_transfers::calls::types::schedule_transfer_with_delay::Delay; let call = quantus_subxt::api::tx().reversible_transfers().schedule_transfer_with_delay( subxt::ext::subxt_core::utils::MultiAddress::Id(account_id_of(&recipient)), - ctx.unit, + ctx.test_unit, Delay::BlockNumber(1), ); submit_expect_failure(ctx, &sender, call, &["DelayTooShort"]).await @@ -130,7 +130,7 @@ async fn reversible_default_delay_not_hs(ctx: &mut ExerciseCtx) -> Result Result { let sender = ctx.eph[2].clone(); let recipient = ctx.fresh_keypair()?.to_account_id_ss58check(); - let amount = ctx.unit; + let amount = ctx.test_unit; crate::cli::reversible::schedule_transfer_with_delay( &ctx.client, @@ -75,7 +75,7 @@ async fn schedule_with_delay(ctx: &mut ExerciseCtx) -> Result { &ctx.client, &sender, &recipient, - ctx.unit, + ctx.test_unit, delay_blocks, true, // delay in blocks ctx.wait_mode(), @@ -104,7 +104,7 @@ async fn set_high_security(ctx: &mut ExerciseCtx) -> Result { &ctx.client, &funder, &account_ss58, - 10 * ctx.unit, + 10 * ctx.test_unit, None, ctx.wait_mode(), ) @@ -140,7 +140,7 @@ async fn set_high_security(ctx: &mut ExerciseCtx) -> Result { &ctx.client, &account, &recipient, - ctx.unit, + ctx.test_unit, ctx.wait_mode(), ) .await?; diff --git a/src/cli/exercise/scenarios/wormhole.rs b/src/cli/exercise/scenarios/wormhole.rs index 2fcf482..b82fade 100644 --- a/src/cli/exercise/scenarios/wormhole.rs +++ b/src/cli/exercise/scenarios/wormhole.rs @@ -12,12 +12,18 @@ pub async fn run(ctx: &mut ExerciseCtx, report: &mut Report, phase: &str) -> Res } async fn multiround(ctx: &mut ExerciseCtx) -> Result { + // NOT scaled by DISCRETIONARY_SCALE: the wormhole round-trips this amount back to the + // funding wallet and draws it from the root account (funded independently of + // --total-amount), so shrinking it buys nothing. Small amounts are actively harmful — + // each round re-partitions across `num_proofs` and deducts fees, so a scaled-down amount + // rounds an output below the on-chain minimum in a later round and emits no transfer + // event ("No transfer event found"). 50 DEV is the proven value. let command = crate::cli::wormhole::WormholeCommands::Multiround { num_proofs: 5, rounds: 5, amount: 50.0, - wallet: "crystal_alice".to_string(), - password: Some(String::new()), + wallet: ctx.root_name.clone(), + password: Some(ctx.root_password.clone()), password_file: None, keep_files: false, output_dir: "/tmp/wormhole_exercise".to_string(),