From 7637180f126878c65a6bc7f02c7afb458b7bbbc4 Mon Sep 17 00:00:00 2001 From: illuzen Date: Tue, 22 Sep 2026 17:42:24 +0800 Subject: [PATCH 1/5] Add airdrop pay command Fetches the claim server's /unpaid list, presents recorded (claimed but unpaid) payouts for review with totals and verification times, batches the transfers into utility.batch extrinsics sized by the chain's safe limit (overridable with --batch-size), and marks each claim paid once its batch is in a block. The admin token (file or QUANTUS_AIRDROP_ADMIN_TOKEN) is loaded before any payment so completed payouts can always be marked; batches are waited on before mark-paid; mark-paid failures after payment are reported loudly and fail the command. Cold wallets work through the shared submit stage (one QR roundtrip per batch). --dry-run prints the plan without submitting. Co-authored-by: Cursor --- README.md | 56 ++++++ src/cli/airdrop.rs | 474 ++++++++++++++++++++++++++++++++++++++++++++- src/cli/mod.rs | 3 +- 3 files changed, 531 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 65b6c3d..a206ed1 100644 --- a/README.md +++ b/README.md @@ -439,6 +439,62 @@ For each match the CLI builds the appropriate proof: The command exits non-zero if any claim fails, and prints a summary of recorded, skipped, and failed claims. +#### `quantus airdrop pay` + +Operator command: pay out recorded (claimed but unpaid) rewards and mark them +paid on the claim server. It fetches the server's unpaid list, presents every +pending payout for review, batches the transfers into as few extrinsics as the +chain's batch limit allows, and marks each claim paid once its batch is in a +block. + +```bash +# Review and pay everything pending, confirming interactively +quantus airdrop pay --from treasury_wallet --admin-token-file ./admin.token + +# Air-gapped payout: any cold wallet works, one QR roundtrip per batch +quantus airdrop pay --from my_cold --admin-token-file ./admin.token + +# See the full payout plan without touching the chain or the server +quantus airdrop pay --from treasury_wallet --dry-run + +# Unattended (skips the review prompt) +quantus airdrop pay --from treasury_wallet --yes \ + --password-file ./pass --admin-token-file ./admin.token +``` + +Example review output: + +``` +3 recorded claim(s) awaiting payout: + qRewarded1 → qPayout1 150.00 QUAN dilithium-v08-padded (verified 2026-09-20 11:02 UTC) + qRewarded2 → qPayout2 75.50 QUAN wormhole-rate8-compact (verified 2026-09-21 08:44 UTC) + qRewarded3 → qPayout3 10.00 QUAN dilithium-v10-padded (verified 2026-09-21 09:15 UTC) +Total: 235.50 QUAN to 3 account(s); 1201 snapshot row(s) remain unclaimed. +Plan: 1 batch extrinsic(s) of up to 256 transfer(s) each. +Pay 235.50 QUAN to 3 account(s) in 1 batch(es) from 'treasury_wallet'? [y/N] +``` + +- `--from`: Wallet that funds the payouts — hot or cold. Cold wallets follow + the standard QR signing flow, one roundtrip per batch extrinsic. +- `--admin-token-file`: File with the claim server's admin token (`chmod + 600`), used for `mark-paid`. Falls back to the `QUANTUS_AIRDROP_ADMIN_TOKEN` + environment variable. Required up front, before anything is paid, so every + completed payout can be marked. +- `--batch-size`: Max transfers per batch extrinsic (default: the chain's + safe `utility.batch` limit). +- `--tip`: Optional tip per batch extrinsic. +- `--yes`: Skip the interactive review confirmation. +- `--dry-run`: Print the payout plan (rows, batches, raw amounts) without + submitting transfers or marking anything paid. +- `--server`: Claim server base URL. + +Safety properties: each batch is waited on until it is in a block before its +claims are marked paid, and a claim is only marked after its transfer +succeeded. If a `mark-paid` call fails after payment, the command prints the +affected addresses loudly and exits non-zero — mark them manually before +re-running, or those rows would be paid twice. If a batch fails, everything +already paid is already marked, so re-running continues where it left off. + --- ### Developer Tools diff --git a/src/cli/airdrop.rs b/src/cli/airdrop.rs index 5f62a9d..32486a6 100644 --- a/src/cli/airdrop.rs +++ b/src/cli/airdrop.rs @@ -130,9 +130,55 @@ pub enum AirdropCommands { #[arg(long)] dry_run: bool, }, + + /// Pay recorded (claimed but unpaid) rewards with batch transfers and + /// mark them paid on the claim server. Requires the server admin token. + Pay { + /// Claim server base URL + #[arg(long, default_value = DEFAULT_SERVER)] + server: String, + + /// Wallet that funds the payouts (hot or cold) + #[arg(long, short)] + from: String, + + /// Password for the wallet (unsupported on argv; use --password-file or prompt) + #[arg(short, long, hide = true)] + password: Option, + + /// Read password from file (for scripting) + #[arg(long)] + password_file: Option, + + /// File with the claim server admin token (chmod 600). Falls back to + /// the QUANTUS_AIRDROP_ADMIN_TOKEN environment variable. + #[arg(long)] + admin_token_file: Option, + + /// Max transfers per batch extrinsic (default: the chain's safe + /// batch limit) + #[arg(long)] + batch_size: Option, + + /// Optional tip amount per batch to prioritize inclusion (e.g. "0.5") + #[arg(long)] + tip: Option, + + /// Skip the interactive review confirmation + #[arg(long)] + yes: bool, + + /// Show the payout plan without submitting or marking anything paid + #[arg(long)] + dry_run: bool, + }, } -pub async fn handle_airdrop_command(command: AirdropCommands) -> Result<()> { +pub async fn handle_airdrop_command( + command: AirdropCommands, + node_url: &str, + execution_mode: crate::cli::common::ExecutionMode, +) -> Result<()> { match command { AirdropCommands::Check { server, @@ -180,6 +226,31 @@ pub async fn handle_airdrop_command(command: AirdropCommands) -> Result<()> { dry_run, ) .await, + AirdropCommands::Pay { + server, + from, + password, + password_file, + admin_token_file, + batch_size, + tip, + yes, + dry_run, + } => + handle_pay( + server, + from, + password, + password_file, + admin_token_file, + batch_size, + tip, + yes, + dry_run, + node_url, + execution_mode, + ) + .await, } } @@ -316,6 +387,350 @@ fn finish_claims( Ok(()) } +/// One `/unpaid` row from the claim server. `status` is `recorded` (claimed, +/// awaiting payout) or `unclaimed` (snapshot row nobody has claimed). +#[derive(Clone, Debug, Deserialize)] +struct UnpaidRow { + address: String, + claim_account: Option, + amount_hundredths: u64, + kind: String, + scheme: Option, + verified_at: Option, + status: String, +} + +#[derive(Debug, Deserialize)] +struct UnpaidResponse { + rows: Vec, +} + +/// A recorded claim awaiting payout: pay `claim_account` on chain, then mark +/// the rewarded `address` paid on the server. +#[derive(Clone, Debug)] +struct Payout { + address: String, + claim_account: String, + amount_hundredths: u64, + scheme: String, + verified_at: Option, +} + +fn recorded_payouts(rows: Vec) -> Result> { + let mut payouts = Vec::new(); + for row in rows { + if row.status != "recorded" { + continue; + } + let scheme = row.scheme.unwrap_or(row.kind); + let claim_account = row.claim_account.ok_or_else(|| { + QuantusError::Generic(format!("recorded claim {} has no claim account", row.address)) + })?; + if row.amount_hundredths == 0 { + continue; + } + payouts.push(Payout { + address: row.address, + claim_account, + amount_hundredths: row.amount_hundredths, + scheme, + verified_at: row.verified_at, + }); + } + Ok(payouts) +} + +/// Snapshot amounts are hundredths of a QUAN; the chain wants raw units. +fn hundredths_to_raw(amount_hundredths: u64, decimals: u8) -> Result { + let scale = decimals.checked_sub(2).ok_or_else(|| { + QuantusError::Generic(format!( + "chain has {decimals} decimal(s); cannot represent hundredths of a QUAN" + )) + })?; + let unit = 10u128 + .checked_pow(u32::from(scale)) + .ok_or_else(|| QuantusError::Generic("decimal scale overflow".into()))?; + u128::from(amount_hundredths) + .checked_mul(unit) + .ok_or_else(|| QuantusError::Generic("payout amount overflow".into())) +} + +/// The mark-paid admin token, from a chmod-600 file or the +/// QUANTUS_AIRDROP_ADMIN_TOKEN environment variable. Required before any +/// payment goes out so a paid claim can always be marked. +fn load_admin_token(admin_token_file: Option<&str>) -> Result { + if let Some(path) = admin_token_file { + return Ok(password::read_secret_file(path, "admin token")?.trim().to_string()); + } + if let Ok(token) = std::env::var("QUANTUS_AIRDROP_ADMIN_TOKEN") { + let token = token.trim().to_string(); + if !token.is_empty() { + return Ok(token); + } + } + Err(QuantusError::Generic( + "provide --admin-token-file or set QUANTUS_AIRDROP_ADMIN_TOKEN; the token is required \ + up front so every paid claim can be marked paid" + .into(), + )) +} + +fn format_verified_at(verified_at: Option) -> String { + match verified_at.and_then(|t| chrono::DateTime::from_timestamp(t, 0)) { + Some(when) => when.format("%Y-%m-%d %H:%M UTC").to_string(), + None => "-".to_string(), + } +} + +fn confirm_payout(total: &str, accounts: usize, batches: usize, from: &str) -> Result<()> { + use std::io::Write; + print!( + "Pay {total} QUAN to {accounts} account(s) in {batches} batch(es) from '{from}'? [y/N] " + ); + std::io::stdout() + .flush() + .map_err(|e| QuantusError::Generic(format!("Failed to flush confirmation prompt: {e}")))?; + let mut response = String::new(); + std::io::stdin() + .read_line(&mut response) + .map_err(|e| QuantusError::Generic(format!("Failed to read confirmation: {e}")))?; + let response = response.trim().to_lowercase(); + if response != "y" && response != "yes" { + return Err(QuantusError::Generic("Payout aborted".into())); + } + Ok(()) +} + +async fn mark_paid( + client: &reqwest::Client, + server: &str, + admin_token: &str, + address: &str, +) -> Result<()> { + let url = format!("{}/mark-paid", server.trim_end_matches('/')); + let response = client + .post(&url) + .bearer_auth(admin_token) + .json(&serde_json::json!({ "address": address })) + .send() + .await + .map_err(http_err)?; + let status = response.status(); + if status.is_success() { + return Ok(()); + } + let text = response.text().await.map_err(http_err)?; + if status == reqwest::StatusCode::CONFLICT { + // Already marked (e.g. a concurrent operator); the payout stands. + log_verbose!("{address} was already marked paid"); + return Ok(()); + } + Err(QuantusError::Generic(format_server_error(status, &text))) +} + +#[allow(clippy::too_many_arguments)] +async fn handle_pay( + server: String, + from: String, + password: Option, + password_file: Option, + admin_token_file: Option, + batch_size: Option, + tip: Option, + yes: bool, + dry_run: bool, + node_url: &str, + execution_mode: crate::cli::common::ExecutionMode, +) -> Result<()> { + let client = http_client()?; + let url = format!("{}/unpaid", server.trim_end_matches('/')); + let response = client.get(&url).send().await.map_err(http_err)?; + let status = response.status(); + let text = response.text().await.map_err(http_err)?; + if !status.is_success() { + return Err(QuantusError::Generic(format_server_error(status, &text))); + } + let unpaid: UnpaidResponse = serde_json::from_str(&text) + .map_err(|e| QuantusError::Generic(format!("unpaid JSON: {e}")))?; + let unclaimed = unpaid.rows.iter().filter(|r| r.status == "unclaimed").count(); + let payouts = recorded_payouts(unpaid.rows)?; + if payouts.is_empty() { + log_print!( + "No recorded claims awaiting payout ({unclaimed} snapshot row(s) remain unclaimed)." + ); + return Ok(()); + } + + // Review. + let mut total_hundredths: u64 = 0; + log_print!("{} recorded claim(s) awaiting payout:", payouts.len()); + for payout in &payouts { + total_hundredths = total_hundredths + .checked_add(payout.amount_hundredths) + .ok_or_else(|| QuantusError::Generic("payout total overflow".into()))?; + log_print!( + " {} → {} {} QUAN {} (verified {})", + payout.address.bright_cyan(), + payout.claim_account.bright_green(), + format_hundredths(payout.amount_hundredths), + payout.scheme, + format_verified_at(payout.verified_at), + ); + } + let total = format_hundredths(total_hundredths); + log_print!( + "Total: {} QUAN to {} account(s); {} snapshot row(s) remain unclaimed.", + total.bright_yellow(), + payouts.len(), + unclaimed + ); + + // Plan batches against the chain's limits. + let quantus_client = crate::chain::client::QuantusClient::new(node_url).await?; + let (_, decimals) = crate::cli::send::get_chain_properties(&quantus_client).await?; + let (safe_limit, _) = crate::cli::send::get_batch_limits(&quantus_client).await?; + let per_batch = match batch_size { + Some(0) => return Err(QuantusError::Generic("--batch-size must be at least 1".into())), + Some(size) if size > safe_limit => { + log_print!("--batch-size {size} exceeds the chain's safe limit; using {safe_limit}."); + safe_limit as usize + }, + Some(size) => size as usize, + None => safe_limit as usize, + }; + let mut transfers = Vec::with_capacity(payouts.len()); + for payout in &payouts { + transfers.push(( + payout.claim_account.clone(), + hundredths_to_raw(payout.amount_hundredths, decimals)?, + )); + } + let batches = transfers.len().div_ceil(per_batch); + log_print!("Plan: {batches} batch extrinsic(s) of up to {per_batch} transfer(s) each."); + + if dry_run { + for (index, chunk) in transfers.chunks(per_batch).enumerate() { + log_print!("Batch {}/{batches}:", index + 1); + for (to, amount) in chunk { + log_print!(" {} ← {} raw units", to, amount); + } + } + log_print!("Dry run finished. Nothing was submitted or marked paid."); + return Ok(()); + } + + // The token is loaded before anything is paid so a completed payout can + // always be marked on the server (re-running an unmarked payout would + // double-pay). + let admin_token = load_admin_token(admin_token_file.as_deref())?; + + if !yes { + confirm_payout(&total, payouts.len(), batches, &from)?; + } + + let signer = crate::wallet::load_signer_from_wallet(&from, password, password_file)?; + crate::cli::send::validate_batch_transfer_request(&quantus_client, &signer, &transfers).await?; + + let tip_amount = match tip { + Some(tip_str) => { + let (value, _) = + crate::cli::send::validate_and_format_amount(&quantus_client, &tip_str).await?; + Some(value) + }, + None => None, + }; + let per_batch_tip = crate::cli::send::effective_tip_amount(tip_amount); + let submit_tip = crate::cli::send::positive_tip_amount(tip_amount); + + let from_account = signer.try_account_id_ss58check()?; + let balance = crate::cli::send::get_balance(&quantus_client, &from_account).await?; + let total_amount = transfers.iter().try_fold(0u128, |acc, (_, amount)| { + crate::cli::send::checked_add(acc, *amount, "payout total") + })?; + let total_tips = per_batch_tip + .checked_mul(batches as u128) + .ok_or_else(|| QuantusError::Generic("tip total overflow".into()))?; + let exact_required = + crate::cli::send::checked_add(total_amount, total_tips, "required payout balance")?; + // Fee estimation covers the first batch; later batches add fees on top, + // so this is a floor, not a guarantee. + let first_chunk = &transfers[..per_batch.min(transfers.len())]; + let first_call = crate::cli::send::build_batch_transfer_call(first_chunk)?; + crate::cli::send::ensure_balance_covers_call( + &quantus_client, + &signer, + &first_call, + balance, + exact_required, + submit_tip, + "payout", + ) + .await?; + + // Never mark a claim paid before its transfer is in a block. + let wait_mode = + crate::cli::common::ExecutionMode { wait_for_transaction: true, ..execution_mode }; + + let mut paid_rows = 0usize; + let mut paid_hundredths = 0u64; + let mut unmarked = Vec::new(); + for (index, (payout_chunk, transfer_chunk)) in + payouts.chunks(per_batch).zip(transfers.chunks(per_batch)).enumerate() + { + log_print!( + "Submitting batch {}/{batches} ({} transfer(s))…", + index + 1, + transfer_chunk.len() + ); + let call = crate::cli::send::build_batch_transfer_call(transfer_chunk)?; + let tx_hash = crate::cli::send::submit_prebuilt_batch_transfer_call( + &quantus_client, + &signer, + transfer_chunk, + call, + tip_amount, + wait_mode, + ) + .await + .map_err(|e| { + QuantusError::Generic(format!( + "batch {}/{batches} failed ({e}); {paid_rows} row(s) from earlier batches were \ + paid and marked, nothing from this batch was paid — re-run to continue", + index + 1 + )) + })?; + log_success!("Batch {}/{batches} in block: {:?}", index + 1, tx_hash); + for payout in payout_chunk { + if let Err(e) = mark_paid(&client, &server, &admin_token, &payout.address).await { + log_error!("mark-paid failed for {}: {e}", payout.address); + unmarked.push(payout.address.clone()); + } + paid_rows += 1; + paid_hundredths = paid_hundredths.saturating_add(payout.amount_hundredths); + } + } + + log_success!( + "Paid {} QUAN across {paid_rows} claim(s) in {batches} batch(es).", + format_hundredths(paid_hundredths) + ); + if !unmarked.is_empty() { + log_error!( + "{} payout(s) were PAID but not marked on the server — mark them before running \ + pay again or they will be paid twice:", + unmarked.len() + ); + for address in &unmarked { + log_error!(" {address}"); + } + return Err(QuantusError::Generic(format!( + "{} mark-paid call(s) failed after payment", + unmarked.len() + ))); + } + Ok(()) +} + /// Move-only wormhole spend secret. Zeroized on drop; Debug never prints it. struct SpendSecret([u8; 32]); @@ -1974,6 +2389,63 @@ mod tests { assert!(resolve_claim_account(None, &credentials).is_err()); } + fn unpaid_row(status: &str, claim_account: Option<&str>, amount: u64) -> UnpaidRow { + UnpaidRow { + address: "qAddr".into(), + claim_account: claim_account.map(str::to_string), + amount_hundredths: amount, + kind: "dilithium".into(), + scheme: Some("dilithium-v08-padded".into()), + verified_at: Some(1_760_000_000), + status: status.into(), + } + } + + #[test] + fn recorded_payouts_keeps_only_recorded_rows_with_accounts() { + let payouts = recorded_payouts(vec![ + unpaid_row("recorded", Some("qDest"), 150), + unpaid_row("unclaimed", None, 999), + unpaid_row("recorded", Some("qDest2"), 0), + ]) + .unwrap(); + assert_eq!(payouts.len(), 1); + assert_eq!(payouts[0].claim_account, "qDest"); + assert_eq!(payouts[0].amount_hundredths, 150); + + // A recorded row without a claim account is a server bug, not a skip. + assert!(recorded_payouts(vec![unpaid_row("recorded", None, 150)]).is_err()); + } + + #[test] + fn hundredths_convert_to_raw_chain_units() { + // 1.50 QUAN at 12 decimals. + assert_eq!(hundredths_to_raw(150, 12).unwrap(), 1_500_000_000_000); + // 2 decimals: hundredths are already the raw unit. + assert_eq!(hundredths_to_raw(150, 2).unwrap(), 150); + // Fewer than 2 decimals cannot represent hundredths. + assert!(hundredths_to_raw(150, 1).is_err()); + // Overflow is an error, not a wrap. + assert!(hundredths_to_raw(u64::MAX, 38).is_err()); + } + + #[test] + fn unpaid_response_parses_server_wire_format() { + let unpaid: UnpaidResponse = serde_json::from_str( + r#"{"rows":[{"address":"qA","claim_account":"qB","amount_hundredths":150, + "kind":"dilithium","scheme":"dilithium-v10-padded","verified_at":1760000000, + "status":"recorded"},{"address":"qC","claim_account":null,"amount_hundredths":10, + "kind":"wormhole","scheme":null,"verified_at":null,"status":"unclaimed"}], + "total_amount_hundredths":160}"#, + ) + .unwrap(); + assert_eq!(unpaid.rows.len(), 2); + let payouts = recorded_payouts(unpaid.rows).unwrap(); + assert_eq!(payouts.len(), 1); + assert_eq!(payouts[0].address, "qA"); + assert_eq!(payouts[0].scheme, "dilithium-v10-padded"); + } + #[test] fn finish_claims_fails_when_all_submissions_failed() { assert!(finish_claims(false, 0, 0, 0, 3).is_err()); diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 318cd40..38768c3 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -515,7 +515,8 @@ pub async fn execute_command( Commands::Block(block_cmd) => block::handle_block_command(block_cmd, node_url).await, Commands::Wormhole(wormhole_cmd) => wormhole::handle_wormhole_command(wormhole_cmd, node_url, execution_mode).await, - Commands::Airdrop(airdrop_cmd) => airdrop::handle_airdrop_command(airdrop_cmd).await, + Commands::Airdrop(airdrop_cmd) => + airdrop::handle_airdrop_command(airdrop_cmd, node_url, execution_mode).await, Commands::Multisend { from, addresses_file, From 898ef5973685fd81bbbc97c71d8dcf3f8781deff Mon Sep 17 00:00:00 2001 From: illuzen Date: Tue, 22 Sep 2026 17:54:35 +0800 Subject: [PATCH 2/5] Verify paid addresses leave /unpaid; reject admin token on argv After all batches land and claims are marked, pay re-fetches /unpaid and fails loudly if any paid address is still listed (it would be paid again on the next run). A hidden --admin-token arg is rejected with guidance toward --admin-token-file / QUANTUS_AIRDROP_ADMIN_TOKEN, matching the password convention. Co-authored-by: Cursor --- README.md | 3 ++ src/cli/airdrop.rs | 111 +++++++++++++++++++++++++++++++++++++++------ 2 files changed, 99 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index a206ed1..0b0dadb 100644 --- a/README.md +++ b/README.md @@ -494,6 +494,9 @@ succeeded. If a `mark-paid` call fails after payment, the command prints the affected addresses loudly and exits non-zero — mark them manually before re-running, or those rows would be paid twice. If a batch fails, everything already paid is already marked, so re-running continues where it left off. +After all batches land, the command re-fetches `/unpaid` and verifies none of +the paid addresses are still listed, failing loudly if any are. Like +passwords, the bearer token is never accepted as a command-line value. --- diff --git a/src/cli/airdrop.rs b/src/cli/airdrop.rs index 32486a6..43721c4 100644 --- a/src/cli/airdrop.rs +++ b/src/cli/airdrop.rs @@ -150,6 +150,11 @@ pub enum AirdropCommands { #[arg(long)] password_file: Option, + /// Admin token (unsupported on argv; use --admin-token-file or + /// QUANTUS_AIRDROP_ADMIN_TOKEN) + #[arg(long, hide = true)] + admin_token: Option, + /// File with the claim server admin token (chmod 600). Falls back to /// the QUANTUS_AIRDROP_ADMIN_TOKEN environment variable. #[arg(long)] @@ -231,6 +236,7 @@ pub async fn handle_airdrop_command( from, password, password_file, + admin_token, admin_token_file, batch_size, tip, @@ -242,7 +248,7 @@ pub async fn handle_airdrop_command( from, password, password_file, - admin_token_file, + AdminTokenSource { argv: admin_token, file: admin_token_file }, batch_size, tip, yes, @@ -455,11 +461,25 @@ fn hundredths_to_raw(amount_hundredths: u64, decimals: u8) -> Result { .ok_or_else(|| QuantusError::Generic("payout amount overflow".into())) } +/// Where the mark-paid bearer token may come from. Raw argv values are +/// rejected like passwords are. +struct AdminTokenSource { + argv: Option, + file: Option, +} + /// The mark-paid admin token, from a chmod-600 file or the /// QUANTUS_AIRDROP_ADMIN_TOKEN environment variable. Required before any /// payment goes out so a paid claim can always be marked. -fn load_admin_token(admin_token_file: Option<&str>) -> Result { - if let Some(path) = admin_token_file { +fn load_admin_token(source: &AdminTokenSource) -> Result { + if source.argv.is_some() { + return Err(QuantusError::Generic( + "Passing the admin token with --admin-token is not supported (argv is visible in \ + process listings); use --admin-token-file or QUANTUS_AIRDROP_ADMIN_TOKEN" + .to_string(), + )); + } + if let Some(path) = &source.file { return Ok(password::read_secret_file(path, "admin token")?.trim().to_string()); } if let Ok(token) = std::env::var("QUANTUS_AIRDROP_ADMIN_TOKEN") { @@ -501,6 +521,26 @@ fn confirm_payout(total: &str, accounts: usize, batches: usize, from: &str) -> R Ok(()) } +async fn fetch_unpaid(client: &reqwest::Client, server: &str) -> Result { + let url = format!("{}/unpaid", server.trim_end_matches('/')); + let response = client.get(&url).send().await.map_err(http_err)?; + let status = response.status(); + let text = response.text().await.map_err(http_err)?; + if !status.is_success() { + return Err(QuantusError::Generic(format_server_error(status, &text))); + } + serde_json::from_str(&text).map_err(|e| QuantusError::Generic(format!("unpaid JSON: {e}"))) +} + +/// Addresses we paid that the server still lists on `/unpaid` (any status). +fn addresses_still_listed(rows: &[UnpaidRow], paid: &[String]) -> Vec { + rows.iter() + .map(|row| &row.address) + .filter(|a| paid.contains(a)) + .cloned() + .collect() +} + async fn mark_paid( client: &reqwest::Client, server: &str, @@ -534,7 +574,7 @@ async fn handle_pay( from: String, password: Option, password_file: Option, - admin_token_file: Option, + admin_token_source: AdminTokenSource, batch_size: Option, tip: Option, yes: bool, @@ -543,15 +583,7 @@ async fn handle_pay( execution_mode: crate::cli::common::ExecutionMode, ) -> Result<()> { let client = http_client()?; - let url = format!("{}/unpaid", server.trim_end_matches('/')); - let response = client.get(&url).send().await.map_err(http_err)?; - let status = response.status(); - let text = response.text().await.map_err(http_err)?; - if !status.is_success() { - return Err(QuantusError::Generic(format_server_error(status, &text))); - } - let unpaid: UnpaidResponse = serde_json::from_str(&text) - .map_err(|e| QuantusError::Generic(format!("unpaid JSON: {e}")))?; + let unpaid = fetch_unpaid(&client, &server).await?; let unclaimed = unpaid.rows.iter().filter(|r| r.status == "unclaimed").count(); let payouts = recorded_payouts(unpaid.rows)?; if payouts.is_empty() { @@ -622,7 +654,7 @@ async fn handle_pay( // The token is loaded before anything is paid so a completed payout can // always be marked on the server (re-running an unmarked payout would // double-pay). - let admin_token = load_admin_token(admin_token_file.as_deref())?; + let admin_token = load_admin_token(&admin_token_source)?; if !yes { confirm_payout(&total, payouts.len(), batches, &from)?; @@ -728,7 +760,31 @@ async fn handle_pay( unmarked.len() ))); } - Ok(()) + + // Re-fetch /unpaid and verify every paid address is gone from the list; + // anything still listed would be paid again on the next run. + let paid_addresses: Vec = payouts.iter().map(|p| p.address.clone()).collect(); + let still_listed = + addresses_still_listed(&fetch_unpaid(&client, &server).await?.rows, &paid_addresses); + if still_listed.is_empty() { + log_success!( + "Server verification: none of the {} paid address(es) remain on /unpaid.", + paid_addresses.len() + ); + return Ok(()); + } + log_error!( + "{} paid address(es) still appear on /unpaid — resolve on the server before running \ + pay again or they will be paid twice:", + still_listed.len() + ); + for address in &still_listed { + log_error!(" {address}"); + } + Err(QuantusError::Generic(format!( + "{} paid address(es) are still listed unpaid by the server", + still_listed.len() + ))) } /// Move-only wormhole spend secret. Zeroized on drop; Debug never prints it. @@ -2417,6 +2473,31 @@ mod tests { assert!(recorded_payouts(vec![unpaid_row("recorded", None, 150)]).is_err()); } + /// Like passwords, the admin bearer token must not be accepted on argv. + #[test] + fn admin_token_rejected_on_argv() { + let err = load_admin_token(&AdminTokenSource { + argv: Some("token".into()), + file: Some("/tmp/whatever".into()), + }) + .unwrap_err() + .to_string(); + assert!(err.contains("--admin-token-file"), "unexpected error: {err}"); + } + + #[test] + fn verification_flags_paid_addresses_still_listed() { + let rows = + vec![unpaid_row("recorded", Some("qDest"), 150), unpaid_row("unclaimed", None, 10)]; + // unpaid_row uses address "qAddr" for every row. + assert_eq!( + addresses_still_listed(&rows, &["qAddr".to_string(), "qOther".to_string()]), + vec!["qAddr".to_string(), "qAddr".to_string()], + ); + assert!(addresses_still_listed(&rows, &["qGone".to_string()]).is_empty()); + assert!(addresses_still_listed(&[], &["qAddr".to_string()]).is_empty()); + } + #[test] fn hundredths_convert_to_raw_chain_units() { // 1.50 QUAN at 12 decimals. From a2dbf5a89021b9fdc7af66aec48f4afed51ac737 Mon Sep 17 00:00:00 2001 From: illuzen Date: Tue, 22 Sep 2026 18:16:20 +0800 Subject: [PATCH 3/5] Airdrop output: QTC symbol, formatted dry-run amounts Co-authored-by: Cursor --- README.md | 14 +++++++------- src/cli/airdrop.rs | 34 ++++++++++++++++++---------------- 2 files changed, 25 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 0b0dadb..2d91a3a 100644 --- a/README.md +++ b/README.md @@ -375,8 +375,8 @@ Output: ``` Snapshot v1 (3f9c2a81be04) — 1234 rewarded addresses 2 snapshot match(es): - qDx... 150.00 QUAN Resonance dilithium-v08-padded (dilithium) [claimable] - qDy... 75.50 QUAN Planck wormhole-rate8-compact (wormhole) [claimable] + qDx... 150.00 QTC Resonance dilithium-v08-padded (dilithium) [claimable] + qDy... 75.50 QTC Planck wormhole-rate8-compact (wormhole) [claimable] ``` - `--wallet`: Hot wallet used to derive Dilithium and HD wormhole addresses. @@ -466,12 +466,12 @@ Example review output: ``` 3 recorded claim(s) awaiting payout: - qRewarded1 → qPayout1 150.00 QUAN dilithium-v08-padded (verified 2026-09-20 11:02 UTC) - qRewarded2 → qPayout2 75.50 QUAN wormhole-rate8-compact (verified 2026-09-21 08:44 UTC) - qRewarded3 → qPayout3 10.00 QUAN dilithium-v10-padded (verified 2026-09-21 09:15 UTC) -Total: 235.50 QUAN to 3 account(s); 1201 snapshot row(s) remain unclaimed. + qRewarded1 → qPayout1 150.00 QTC dilithium-v08-padded (verified 2026-09-20 11:02 UTC) + qRewarded2 → qPayout2 75.50 QTC wormhole-rate8-compact (verified 2026-09-21 08:44 UTC) + qRewarded3 → qPayout3 10.00 QTC dilithium-v10-padded (verified 2026-09-21 09:15 UTC) +Total: 235.50 QTC to 3 account(s); 1201 snapshot row(s) remain unclaimed. Plan: 1 batch extrinsic(s) of up to 256 transfer(s) each. -Pay 235.50 QUAN to 3 account(s) in 1 batch(es) from 'treasury_wallet'? [y/N] +Pay 235.50 QTC to 3 account(s) in 1 batch(es) from 'treasury_wallet'? [y/N] ``` - `--from`: Wallet that funds the payouts — hot or cold. Cold wallets follow diff --git a/src/cli/airdrop.rs b/src/cli/airdrop.rs index 43721c4..08d56ae 100644 --- a/src/cli/airdrop.rs +++ b/src/cli/airdrop.rs @@ -378,12 +378,12 @@ fn finish_claims( ); } else if failed == 0 { log_success!( - "Recorded {} QUAN across {recorded} claim(s); {skipped} skipped.", + "Recorded {} QTC across {recorded} claim(s); {skipped} skipped.", format_hundredths(claimed_hundredths) ); } else { log_print!( - "Recorded {} QUAN across {recorded} claim(s); {skipped} skipped; {failed} failed.", + "Recorded {} QTC across {recorded} claim(s); {skipped} skipped; {failed} failed.", format_hundredths(claimed_hundredths) ); } @@ -446,11 +446,11 @@ fn recorded_payouts(rows: Vec) -> Result> { Ok(payouts) } -/// Snapshot amounts are hundredths of a QUAN; the chain wants raw units. +/// Snapshot amounts are hundredths of a QTC; the chain wants raw units. fn hundredths_to_raw(amount_hundredths: u64, decimals: u8) -> Result { let scale = decimals.checked_sub(2).ok_or_else(|| { QuantusError::Generic(format!( - "chain has {decimals} decimal(s); cannot represent hundredths of a QUAN" + "chain has {decimals} decimal(s); cannot represent hundredths of a QTC" )) })?; let unit = 10u128 @@ -504,9 +504,7 @@ fn format_verified_at(verified_at: Option) -> String { fn confirm_payout(total: &str, accounts: usize, batches: usize, from: &str) -> Result<()> { use std::io::Write; - print!( - "Pay {total} QUAN to {accounts} account(s) in {batches} batch(es) from '{from}'? [y/N] " - ); + print!("Pay {total} QTC to {accounts} account(s) in {batches} batch(es) from '{from}'? [y/N] "); std::io::stdout() .flush() .map_err(|e| QuantusError::Generic(format!("Failed to flush confirmation prompt: {e}")))?; @@ -601,7 +599,7 @@ async fn handle_pay( .checked_add(payout.amount_hundredths) .ok_or_else(|| QuantusError::Generic("payout total overflow".into()))?; log_print!( - " {} → {} {} QUAN {} (verified {})", + " {} → {} {} QTC {} (verified {})", payout.address.bright_cyan(), payout.claim_account.bright_green(), format_hundredths(payout.amount_hundredths), @@ -611,7 +609,7 @@ async fn handle_pay( } let total = format_hundredths(total_hundredths); log_print!( - "Total: {} QUAN to {} account(s); {} snapshot row(s) remain unclaimed.", + "Total: {} QTC to {} account(s); {} snapshot row(s) remain unclaimed.", total.bright_yellow(), payouts.len(), unclaimed @@ -641,10 +639,14 @@ async fn handle_pay( log_print!("Plan: {batches} batch extrinsic(s) of up to {per_batch} transfer(s) each."); if dry_run { - for (index, chunk) in transfers.chunks(per_batch).enumerate() { + for (index, chunk) in payouts.chunks(per_batch).enumerate() { log_print!("Batch {}/{batches}:", index + 1); - for (to, amount) in chunk { - log_print!(" {} ← {} raw units", to, amount); + for payout in chunk { + log_print!( + " {} ← {} QTC", + payout.claim_account, + format_hundredths(payout.amount_hundredths) + ); } } log_print!("Dry run finished. Nothing was submitted or marked paid."); @@ -743,7 +745,7 @@ async fn handle_pay( } log_success!( - "Paid {} QUAN across {paid_rows} claim(s) in {batches} batch(es).", + "Paid {} QTC across {paid_rows} claim(s) in {batches} batch(es).", format_hundredths(paid_hundredths) ); if !unmarked.is_empty() { @@ -1322,7 +1324,7 @@ fn print_matches(matches: &[FoundReward]) { }; let note = if claimable { "claimable" } else { "not claimable yet" }; log_print!( - " {} {} QUAN {} {} ({}) [{}]", + " {} {} QTC {} {} ({}) [{}]", found.ss58.bright_cyan(), format_hundredths(found.amount_hundredths), found.testnets.join(","), @@ -1406,7 +1408,7 @@ async fn submit_claim( let recorded: ClaimResponse = serde_json::from_str(&text) .map_err(|e| QuantusError::Generic(format!("claim JSON: {e}")))?; log_success!( - "Recorded {} → {} ({} QUAN)", + "Recorded {} → {} ({} QTC)", recorded.address.bright_cyan(), recorded.claim_account.bright_green(), format_hundredths(recorded.amount_hundredths) @@ -2500,7 +2502,7 @@ mod tests { #[test] fn hundredths_convert_to_raw_chain_units() { - // 1.50 QUAN at 12 decimals. + // 1.50 QTC at 12 decimals. assert_eq!(hundredths_to_raw(150, 12).unwrap(), 1_500_000_000_000); // 2 decimals: hundredths are already the raw unit. assert_eq!(hundredths_to_raw(150, 2).unwrap(), 150); From e49035b9133990e4409afd571520fee89996284d Mon Sep 17 00:00:00 2001 From: illuzen Date: Tue, 22 Sep 2026 18:25:23 +0800 Subject: [PATCH 4/5] Add --limit and --only payout selection to airdrop pay Co-authored-by: Cursor --- README.md | 11 ++++++ src/cli/airdrop.rs | 84 ++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 93 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 2d91a3a..801b9c7 100644 --- a/README.md +++ b/README.md @@ -457,6 +457,12 @@ quantus airdrop pay --from my_cold --admin-token-file ./admin.token # See the full payout plan without touching the chain or the server quantus airdrop pay --from treasury_wallet --dry-run +# Test the flow end-to-end by paying a single claim first +quantus airdrop pay --from treasury_wallet --admin-token-file ./admin.token --limit 1 + +# Or pay one specific rewarded address +quantus airdrop pay --from treasury_wallet --admin-token-file ./admin.token --only qRewarded1 + # Unattended (skips the review prompt) quantus airdrop pay --from treasury_wallet --yes \ --password-file ./pass --admin-token-file ./admin.token @@ -480,6 +486,11 @@ Pay 235.50 QTC to 3 account(s) in 1 batch(es) from 'treasury_wallet'? [y/N] 600`), used for `mark-paid`. Falls back to the `QUANTUS_AIRDROP_ADMIN_TOKEN` environment variable. Required up front, before anything is paid, so every completed payout can be marked. +- `--limit`: Pay at most this many claims this run — `--limit 1` to test the + whole flow on a single claim before paying the rest (a re-run pays the + remainder, since paid claims drop off `/unpaid`). +- `--only`: Pay only the given rewarded address(es) (repeatable). Errors if + an address has no recorded unpaid claim. - `--batch-size`: Max transfers per batch extrinsic (default: the chain's safe `utility.batch` limit). - `--tip`: Optional tip per batch extrinsic. diff --git a/src/cli/airdrop.rs b/src/cli/airdrop.rs index 08d56ae..442e46f 100644 --- a/src/cli/airdrop.rs +++ b/src/cli/airdrop.rs @@ -160,6 +160,16 @@ pub enum AirdropCommands { #[arg(long)] admin_token_file: Option, + /// Pay only this rewarded address (repeatable). Errors if the + /// address has no recorded unpaid claim. + #[arg(long)] + only: Vec, + + /// Pay at most this many claims this run (e.g. 1 to test the flow + /// end-to-end before paying the rest) + #[arg(long)] + limit: Option, + /// Max transfers per batch extrinsic (default: the chain's safe /// batch limit) #[arg(long)] @@ -238,6 +248,8 @@ pub async fn handle_airdrop_command( password_file, admin_token, admin_token_file, + only, + limit, batch_size, tip, yes, @@ -249,6 +261,7 @@ pub async fn handle_airdrop_command( password, password_file, AdminTokenSource { argv: admin_token, file: admin_token_file }, + PayoutSelection { only, limit }, batch_size, tip, yes, @@ -446,6 +459,33 @@ fn recorded_payouts(rows: Vec) -> Result> { Ok(payouts) } +/// Which recorded claims this run pays: an optional address allowlist +/// (`--only`, repeatable) and an optional cap (`--limit`). +struct PayoutSelection { + only: Vec, + limit: Option, +} + +fn select_payouts(mut payouts: Vec, selection: &PayoutSelection) -> Result> { + if !selection.only.is_empty() { + for wanted in &selection.only { + if !payouts.iter().any(|p| &p.address == wanted) { + return Err(QuantusError::Generic(format!( + "--only {wanted} has no recorded unpaid claim" + ))); + } + } + payouts.retain(|p| selection.only.contains(&p.address)); + } + if let Some(limit) = selection.limit { + if limit == 0 { + return Err(QuantusError::Generic("--limit must be at least 1".into())); + } + payouts.truncate(limit); + } + Ok(payouts) +} + /// Snapshot amounts are hundredths of a QTC; the chain wants raw units. fn hundredths_to_raw(amount_hundredths: u64, decimals: u8) -> Result { let scale = decimals.checked_sub(2).ok_or_else(|| { @@ -573,6 +613,7 @@ async fn handle_pay( password: Option, password_file: Option, admin_token_source: AdminTokenSource, + selection: PayoutSelection, batch_size: Option, tip: Option, yes: bool, @@ -583,13 +624,21 @@ async fn handle_pay( let client = http_client()?; let unpaid = fetch_unpaid(&client, &server).await?; let unclaimed = unpaid.rows.iter().filter(|r| r.status == "unclaimed").count(); - let payouts = recorded_payouts(unpaid.rows)?; - if payouts.is_empty() { + let recorded = recorded_payouts(unpaid.rows)?; + if recorded.is_empty() { log_print!( "No recorded claims awaiting payout ({unclaimed} snapshot row(s) remain unclaimed)." ); return Ok(()); } + let pending = recorded.len(); + let payouts = select_payouts(recorded, &selection)?; + if payouts.len() < pending { + log_print!( + "Paying {} of {pending} pending claim(s) this run (--only/--limit).", + payouts.len() + ); + } // Review. let mut total_hundredths: u64 = 0; @@ -2500,6 +2549,37 @@ mod tests { assert!(addresses_still_listed(&[], &["qAddr".to_string()]).is_empty()); } + #[test] + fn payout_selection_filters_and_limits() { + let payout = |address: &str| Payout { + address: address.into(), + claim_account: "qDest".into(), + amount_hundredths: 100, + scheme: "dilithium-v10-padded".into(), + verified_at: None, + }; + let all = vec![payout("qA"), payout("qB"), payout("qC")]; + + let none = PayoutSelection { only: vec![], limit: None }; + assert_eq!(select_payouts(all.clone(), &none).unwrap().len(), 3); + + let one = PayoutSelection { only: vec![], limit: Some(1) }; + let selected = select_payouts(all.clone(), &one).unwrap(); + assert_eq!(selected.len(), 1); + assert_eq!(selected[0].address, "qA"); + + let only_b = PayoutSelection { only: vec!["qB".into()], limit: None }; + let selected = select_payouts(all.clone(), &only_b).unwrap(); + assert_eq!(selected.len(), 1); + assert_eq!(selected[0].address, "qB"); + + let missing = PayoutSelection { only: vec!["qZ".into()], limit: None }; + assert!(select_payouts(all.clone(), &missing).is_err()); + + let zero = PayoutSelection { only: vec![], limit: Some(0) }; + assert!(select_payouts(all, &zero).is_err()); + } + #[test] fn hundredths_convert_to_raw_chain_units() { // 1.50 QTC at 12 decimals. From 27cdb44e0ef0b13a5c4a5fcf8504c1c4e362f2f3 Mon Sep 17 00:00:00 2001 From: illuzen Date: Tue, 22 Sep 2026 19:11:16 +0800 Subject: [PATCH 5/5] Show checkphrases next to airdrop payout destinations Co-authored-by: Cursor --- README.md | 10 +++++++--- src/cli/airdrop.rs | 21 +++++++++++++++++---- 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 801b9c7..7e63ec3 100644 --- a/README.md +++ b/README.md @@ -472,14 +472,18 @@ Example review output: ``` 3 recorded claim(s) awaiting payout: - qRewarded1 → qPayout1 150.00 QTC dilithium-v08-padded (verified 2026-09-20 11:02 UTC) - qRewarded2 → qPayout2 75.50 QTC wormhole-rate8-compact (verified 2026-09-21 08:44 UTC) - qRewarded3 → qPayout3 10.00 QTC dilithium-v10-padded (verified 2026-09-21 09:15 UTC) + qRewarded1 → qPayout1 [Apple-River-Stone-Cloud-Fox] 150.00 QTC dilithium-v08-padded (verified 2026-09-20 11:02 UTC) + qRewarded2 → qPayout2 [Maple-Tiger-Coral-Dawn-Iris] 75.50 QTC wormhole-rate8-compact (verified 2026-09-21 08:44 UTC) + qRewarded3 → qPayout3 [Cedar-Whale-Amber-Frost-Owl] 10.00 QTC dilithium-v10-padded (verified 2026-09-21 09:15 UTC) Total: 235.50 QTC to 3 account(s); 1201 snapshot row(s) remain unclaimed. Plan: 1 batch extrinsic(s) of up to 256 transfer(s) each. Pay 235.50 QTC to 3 account(s) in 1 batch(es) from 'treasury_wallet'? [y/N] ``` +Every destination address is shown with its human checkphrase (the same +five-word phrase the wallet apps display), so payout accounts can be verified +against the recipient out of band before confirming. + - `--from`: Wallet that funds the payouts — hot or cold. Cold wallets follow the standard QR signing flow, one roundtrip per batch extrinsic. - `--admin-token-file`: File with the claim server's admin token (`chmod diff --git a/src/cli/airdrop.rs b/src/cli/airdrop.rs index 442e46f..87a6565 100644 --- a/src/cli/airdrop.rs +++ b/src/cli/airdrop.rs @@ -347,7 +347,10 @@ async fn handle_claim( print_snapshot_header(&snapshot); print_matches(&matches); - log_print!("Payout destination: {}", bytes_to_quantus_ss58(&claim_account).bright_cyan()); + log_print!( + "Payout destination: {}", + address_with_checkphrase(&bytes_to_quantus_ss58(&claim_account)) + ); if matches.is_empty() { log_print!("No snapshot addresses to claim."); @@ -459,6 +462,16 @@ fn recorded_payouts(rows: Vec) -> Result> { Ok(payouts) } +/// Destination address with its human checkphrase so the operator can +/// verify it against the recipient out of band. +fn address_with_checkphrase(address: &str) -> String { + format!( + "{} [{}]", + address.bright_green(), + crate::wallet::checkphrase::checkphrase(address).bright_blue() + ) +} + /// Which recorded claims this run pays: an optional address allowlist /// (`--only`, repeatable) and an optional cap (`--limit`). struct PayoutSelection { @@ -650,7 +663,7 @@ async fn handle_pay( log_print!( " {} → {} {} QTC {} (verified {})", payout.address.bright_cyan(), - payout.claim_account.bright_green(), + address_with_checkphrase(&payout.claim_account), format_hundredths(payout.amount_hundredths), payout.scheme, format_verified_at(payout.verified_at), @@ -693,7 +706,7 @@ async fn handle_pay( for payout in chunk { log_print!( " {} ← {} QTC", - payout.claim_account, + address_with_checkphrase(&payout.claim_account), format_hundredths(payout.amount_hundredths) ); } @@ -1459,7 +1472,7 @@ async fn submit_claim( log_success!( "Recorded {} → {} ({} QTC)", recorded.address.bright_cyan(), - recorded.claim_account.bright_green(), + address_with_checkphrase(&recorded.claim_account), format_hundredths(recorded.amount_hundredths) ); Ok(ClaimOutcome::Recorded { amount_hundredths: recorded.amount_hundredths })