Skip to content
Open
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
49 changes: 49 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <pw> \
--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 <NAME>` | `crystal_alice` | Wallet that funds the run. Supply your own to run against a public testnet. |
| `--root-password <PW>` / `--root-password-file <PATH>` | — | Password for the root wallet (or set `QUANTUS_WALLET_PASSWORD_<NAME>`). |
| `--total-amount <TOKENS>` | `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 <N>` | `4` | Number of ephemeral accounts to derive and fund. |
| `--phases <LIST>` / `--skip <LIST>` | all | Comma-separated phases to run / skip. |
| `--seed <N>` | random | Reproducible fuzz seed. |
| `--fuzz-iterations <N>` | `25` | Number of fuzz iterations. |
| `--upgrade-wasm <PATH>` | — | 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 |
Expand Down
113 changes: 103 additions & 10 deletions src/cli/exercise/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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<String>,

/// Password for the `--root-account` wallet (or set `QUANTUS_WALLET_PASSWORD_<NAME>`).
#[arg(long)]
pub root_password: Option<String>,

/// Read the `--root-account` password from a file.
#[arg(long)]
pub root_password_file: Option<String>,

/// 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,

Expand Down Expand Up @@ -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"
)),
);

Expand All @@ -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()));
Expand All @@ -280,8 +368,11 @@ async fn ensure_dev_wallets_on_disk() -> Result<()> {
Ok(())
}

async fn fund_ephemeral_accounts(ctx: &mut ExerciseCtx, count: usize) -> Result<String> {
let funding_per_account = 1_000 * ctx.unit;
async fn fund_ephemeral_accounts(
ctx: &mut ExerciseCtx,
count: usize,
funding_per_account: u128,
) -> Result<String> {
let mut addresses = Vec::with_capacity(count);
for _ in 0..count {
let keypair = ctx.fresh_keypair()?;
Expand Down Expand Up @@ -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}"
))
}
9 changes: 8 additions & 1 deletion src/cli/exercise/runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,15 @@ pub struct ExerciseCtx {
pub bob: QuantumKeyPair,
pub charlie: QuantumKeyPair,
pub eph: Vec<QuantumKeyPair>,
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,
Expand Down
10 changes: 5 additions & 5 deletions src/cli/exercise/scenarios/balances.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> {
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?;
Expand Down Expand Up @@ -50,7 +50,7 @@ async fn batch_transfer(ctx: &mut ExerciseCtx) -> Result<String> {
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();
Expand All @@ -70,8 +70,8 @@ async fn batch_transfer(ctx: &mut ExerciseCtx) -> Result<String> {

async fn transfer_with_tip(ctx: &mut ExerciseCtx) -> Result<String> {
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,
Expand Down Expand Up @@ -100,7 +100,7 @@ async fn transfer_manual_nonce(ctx: &mut ExerciseCtx) -> Result<String> {
&ctx.client,
&sender,
&recipient,
ctx.unit,
ctx.test_unit,
None,
Some(nonce as u32),
ctx.wait_mode(),
Expand Down
2 changes: 1 addition & 1 deletion src/cli/exercise/scenarios/fuzz.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}
Expand Down
4 changes: 2 additions & 2 deletions src/cli/exercise/scenarios/multisig.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,15 +51,15 @@ async fn lifecycle(ctx: &mut ExerciseCtx) -> Result<String> {
&ctx.client,
&signer_a,
&multisig_ss58,
20 * ctx.unit,
20 * ctx.test_unit,
None,
ctx.wait_mode(),
)
.await?;

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,
Expand Down
6 changes: 3 additions & 3 deletions src/cli/exercise/scenarios/negative.rs
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ async fn stale_nonce(ctx: &mut ExerciseCtx) -> Result<String> {
));
}
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,
Expand Down Expand Up @@ -119,7 +119,7 @@ async fn reversible_delay_too_short(ctx: &mut ExerciseCtx) -> Result<String> {
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
Expand All @@ -130,7 +130,7 @@ async fn reversible_default_delay_not_hs(ctx: &mut ExerciseCtx) -> Result<String
let recipient = ctx.fresh_keypair()?;
let call = quantus_subxt::api::tx().reversible_transfers().schedule_transfer(
subxt::ext::subxt_core::utils::MultiAddress::Id(account_id_of(&recipient)),
ctx.unit,
ctx.test_unit,
);
submit_expect_failure(ctx, &sender, call, &["AccountNotHighSecurity"]).await
}
Expand Down
8 changes: 4 additions & 4 deletions src/cli/exercise/scenarios/reversible.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ async fn pending_ids(
async fn schedule_and_cancel(ctx: &mut ExerciseCtx) -> Result<String> {
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,
Expand Down Expand Up @@ -75,7 +75,7 @@ async fn schedule_with_delay(ctx: &mut ExerciseCtx) -> Result<String> {
&ctx.client,
&sender,
&recipient,
ctx.unit,
ctx.test_unit,
delay_blocks,
true, // delay in blocks
ctx.wait_mode(),
Expand Down Expand Up @@ -104,7 +104,7 @@ async fn set_high_security(ctx: &mut ExerciseCtx) -> Result<String> {
&ctx.client,
&funder,
&account_ss58,
10 * ctx.unit,
10 * ctx.test_unit,
None,
ctx.wait_mode(),
)
Expand Down Expand Up @@ -140,7 +140,7 @@ async fn set_high_security(ctx: &mut ExerciseCtx) -> Result<String> {
&ctx.client,
&account,
&recipient,
ctx.unit,
ctx.test_unit,
ctx.wait_mode(),
)
.await?;
Expand Down
10 changes: 8 additions & 2 deletions src/cli/exercise/scenarios/wormhole.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,18 @@ pub async fn run(ctx: &mut ExerciseCtx, report: &mut Report, phase: &str) -> Res
}

async fn multiround(ctx: &mut ExerciseCtx) -> Result<String> {
// 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(),
Expand Down
Loading