use crate::{
cli::{
address_format::bytes_to_quantus_ss58, common::resolve_address_with_subxt_account_id,
wormhole::parse_secret_hex,
},
error::{QuantusError, Result, WalletError},
log_error, log_print, log_success, log_verbose,
wallet::{keystore::WalletType, password, DilithiumScheme, QuantumKeyPair, WalletManager},
};
use clap::Subcommand;
use colored::Colorize;
use nam_tiny_hderive::bip32::ExtendedPrivKey;
use qp_ownership_circuit::{CircuitInputs, Secret};
use qp_poseidon_core_v09 as v09;
use qp_rusty_crystals_dilithium::{fips202, ml_dsa_87::SecretKey, packing, params, poly, polyvec};
use qp_rusty_crystals_hdwallet::{
generate_wormhole_from_seed, mnemonic_to_seed, SensitiveBytes64, QUANTUS_WORMHOLE_CHAIN_ID,
};
use qp_zk_circuits_common::utils::BytesDigest;
use serde::{Deserialize, Serialize};
use sp_core::crypto::{AccountId32, Ss58Codec};
use std::{collections::HashMap, path::PathBuf, time::Duration};
const CLAIM_CONTEXT: &[u8] = b"qp-airdrop-claim-v1";
const CLAIM_TTL_SECS: i64 = 10 * 60;
const DEFAULT_SERVER: &str = "http://127.0.0.1:8080";
const HD_WORMHOLE_INDEXES: std::ops::RangeInclusive<usize> = 0..=16;
const CLAIMABLE_WORMHOLE_SCHEME: &str = "wormhole-rate8-compact";
const DILITHIUM_CHAIN_ID: &str = "189189'";
#[derive(Subcommand, Debug)]
pub enum AirdropCommands {
Check {
#[arg(long, default_value = DEFAULT_SERVER)]
server: String,
#[arg(long, short)]
wallet: Option<String>,
#[arg(short, long, hide = true)]
password: Option<String>,
#[arg(long)]
password_file: Option<String>,
#[arg(long)]
wormhole_secret_file: Option<PathBuf>,
#[arg(long)]
wormhole_secret_prompt: bool,
#[arg(long)]
wormhole_index: Option<usize>,
#[arg(long, default_value_t = 8)]
scan_accounts: u32,
#[arg(long, default_value_t = 8)]
scan_rounds: usize,
},
Claim {
#[arg(long, default_value = DEFAULT_SERVER)]
server: String,
#[arg(long, short)]
wallet: Option<String>,
#[arg(long, short)]
to: Option<String>,
#[arg(short, long, hide = true)]
password: Option<String>,
#[arg(long)]
password_file: Option<String>,
#[arg(long)]
wormhole_secret_file: Option<PathBuf>,
#[arg(long)]
wormhole_secret_prompt: bool,
#[arg(long)]
wormhole_index: Option<usize>,
#[arg(long, default_value_t = 8)]
scan_accounts: u32,
#[arg(long, default_value_t = 8)]
scan_rounds: usize,
#[arg(long)]
dry_run: bool,
},
}
pub async fn handle_airdrop_command(command: AirdropCommands) -> Result<()> {
match command {
AirdropCommands::Check {
server,
wallet,
password,
password_file,
wormhole_secret_file,
wormhole_secret_prompt,
wormhole_index,
scan_accounts,
scan_rounds,
} =>
handle_check(
server,
wallet,
password,
password_file,
wormhole_secret_file,
wormhole_secret_prompt,
ScanWindow { wormhole_index, accounts: scan_accounts, rounds: scan_rounds },
)
.await,
AirdropCommands::Claim {
server,
wallet,
to,
password,
password_file,
wormhole_secret_file,
wormhole_secret_prompt,
wormhole_index,
scan_accounts,
scan_rounds,
dry_run,
} =>
handle_claim(
server,
wallet,
to,
password,
password_file,
wormhole_secret_file,
wormhole_secret_prompt,
ScanWindow { wormhole_index, accounts: scan_accounts, rounds: scan_rounds },
dry_run,
)
.await,
}
}
#[derive(Clone, Copy)]
struct ScanWindow {
wormhole_index: Option<usize>,
accounts: u32,
rounds: usize,
}
async fn handle_check(
server: String,
wallet: Option<String>,
password: Option<String>,
password_file: Option<String>,
wormhole_secret_file: Option<PathBuf>,
wormhole_secret_prompt: bool,
scan: ScanWindow,
) -> Result<()> {
let snapshot = fetch_snapshot(&server).await?;
let credentials = collect_credentials(
wallet.as_deref(),
password,
password_file,
wormhole_secret_file.as_deref(),
wormhole_secret_prompt,
scan,
)?;
if credentials.dilithium.is_none() && credentials.wormhole_secrets.is_empty() {
return Err(QuantusError::Generic(
"provide --wallet, --wormhole-secret-file, and/or --wormhole-secret-prompt".into(),
));
}
let matches = find_matches(&snapshot, &credentials);
print_snapshot_header(&snapshot);
print_matches(&matches);
Ok(())
}
async fn handle_claim(
server: String,
wallet: Option<String>,
to: Option<String>,
password: Option<String>,
password_file: Option<String>,
wormhole_secret_file: Option<PathBuf>,
wormhole_secret_prompt: bool,
scan: ScanWindow,
dry_run: bool,
) -> Result<()> {
let credentials = collect_credentials(
wallet.as_deref(),
password,
password_file,
wormhole_secret_file.as_deref(),
wormhole_secret_prompt,
scan,
)?;
if credentials.dilithium.is_none() && credentials.wormhole_secrets.is_empty() {
return Err(QuantusError::Generic(
"provide --wallet, --wormhole-secret-file, and/or --wormhole-secret-prompt".into(),
));
}
let claim_account = resolve_claim_account(to.as_deref(), &credentials)?;
let snapshot = fetch_snapshot(&server).await?;
let matches = find_matches(&snapshot, &credentials);
print_snapshot_header(&snapshot);
print_matches(&matches);
log_print!("Payout destination: {}", bytes_to_quantus_ss58(&claim_account).bright_cyan());
if matches.is_empty() {
log_print!("No snapshot addresses to claim.");
return Ok(());
}
let client = http_client()?;
let mut claimed = 0u64;
let mut recorded = 0usize;
let mut skipped = 0usize;
let mut failed = 0usize;
for found in &matches {
match submit_claim(&client, &server, found, &claim_account, &credentials, dry_run).await {
Ok(ClaimOutcome::Recorded { amount_hundredths }) => {
recorded += 1;
claimed = claimed.saturating_add(amount_hundredths);
},
Ok(ClaimOutcome::Skipped) => skipped += 1,
Err(e) => {
log_error!("Failed {}: {e}", found.ss58);
failed += 1;
},
}
}
finish_claims(dry_run, claimed, recorded, skipped, failed)
}
fn finish_claims(
dry_run: bool,
claimed_hundredths: u64,
recorded: usize,
skipped: usize,
failed: usize,
) -> Result<()> {
if dry_run {
log_print!(
"Dry run finished. Would submit {recorded} claim(s); {skipped} skipped; {failed} failed."
);
} else if failed == 0 {
log_success!(
"Recorded {} QUAN across {recorded} claim(s); {skipped} skipped.",
format_hundredths(claimed_hundredths)
);
} else {
log_print!(
"Recorded {} QUAN across {recorded} claim(s); {skipped} skipped; {failed} failed.",
format_hundredths(claimed_hundredths)
);
}
if failed > 0 {
return Err(QuantusError::Generic(format!("{failed} claim submission(s) failed")));
}
Ok(())
}
struct SpendSecret([u8; 32]);
impl SpendSecret {
fn bytes(&self) -> &[u8; 32] {
&self.0
}
}
impl Drop for SpendSecret {
fn drop(&mut self) {
crate::wallet::keystore::zeroize_bytes(&mut self.0);
}
}
impl std::fmt::Debug for SpendSecret {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("SpendSecret(<redacted>)")
}
}
struct Credentials {
dilithium: Option<QuantumKeyPair>,
wallet_account: Option<[u8; 32]>,
wormhole_secrets: Vec<(SpendSecret, String)>,
hd_seed: Option<SensitiveBytes64>,
wallet_derivation_path: Option<String>,
scan_accounts: u32,
}
fn collect_credentials(
wallet: Option<&str>,
password: Option<String>,
password_file: Option<String>,
wormhole_secret_file: Option<&std::path::Path>,
wormhole_secret_prompt: bool,
scan: ScanWindow,
) -> Result<Credentials> {
let mut wormhole_secrets = Vec::new();
let mut dilithium = None;
let mut wallet_account = None;
let mut hd_seed = None;
let mut wallet_derivation_path = None;
if let Some(name) = wallet {
let (keypair, mnemonic, derivation_path) =
load_wallet_material(name, password, password_file)?;
wallet_account = Some(*keypair.try_to_account_id_32()?.as_ref());
wallet_derivation_path = derivation_path;
if keypair.scheme != DilithiumScheme::MlDsa87 {
log_print!(
"Wallet '{}' is {:?}; Dilithium airdrop claims require ML-DSA-87.",
name,
keypair.scheme
);
} else {
dilithium = Some(keypair);
}
if let Some(mnemonic) = mnemonic {
let mut seed = SensitiveBytes64::zeroed();
mnemonic_to_seed(mnemonic, None, &mut seed)
.map_err(|e| QuantusError::Generic(format!("invalid mnemonic: {e:?}")))?;
wormhole_secrets.extend(derive_hd_wormhole_secrets(
&seed,
scan.wormhole_index,
scan.rounds,
)?);
hd_seed = Some(seed);
} else {
log_verbose!("Wallet '{}' has no mnemonic; HD derivation skipped", name);
}
}
if let Some(path) = wormhole_secret_file {
let secret = read_wormhole_secret(path)?;
wormhole_secrets.push((secret, path.display().to_string()));
}
if wormhole_secret_prompt {
wormhole_secrets.push((prompt_wormhole_secret()?, "pasted secret".to_string()));
}
Ok(Credentials {
dilithium,
wallet_account,
wormhole_secrets,
hd_seed,
wallet_derivation_path,
scan_accounts: scan.accounts,
})
}
fn load_wallet_material(
wallet_name: &str,
password: Option<String>,
password_file: Option<String>,
) -> Result<(QuantumKeyPair, Option<String>, Option<String>)> {
let wallet_manager = WalletManager::new()?;
if wallet_manager.wallet_type(wallet_name)? == Some(WalletType::Cold) {
return Err(WalletError::ColdWalletNoKeys(wallet_name.to_string()).into());
}
let wallet_password = password::get_wallet_password(wallet_name, password, password_file)?;
let mut wallet_data = wallet_manager.load_wallet(wallet_name, &wallet_password)?;
let mnemonic = wallet_data.take_mnemonic();
let derivation_path = Some(wallet_data.derivation_path.clone());
Ok((wallet_data.take_keypair(), mnemonic, derivation_path))
}
fn derive_hd_wormhole_secrets(
seed: &SensitiveBytes64,
wormhole_index: Option<usize>,
scan_rounds: usize,
) -> Result<Vec<(SpendSecret, String)>> {
let indexes: Vec<usize> = match wormhole_index {
Some(index) => vec![index],
None => HD_WORMHOLE_INDEXES.collect(),
};
let mut out = Vec::new();
for branch in 0..=scan_rounds {
for &index in &indexes {
let path = format!("m/44'/{}/0'/{}'/{}'", QUANTUS_WORMHOLE_CHAIN_ID, branch, index);
let pair = generate_wormhole_from_seed(seed, &path)
.map_err(|e| QuantusError::Generic(format!("HD derivation failed: {e:?}")))?;
out.push((SpendSecret(*pair.secret().as_bytes()), format!("hd {path}")));
}
}
let mut legacy_paths = vec!["m".to_string()];
for branch in 0..=scan_rounds {
for &index in &indexes {
legacy_paths
.push(format!("m/44'/{}/0'/{}'/{}'", QUANTUS_WORMHOLE_CHAIN_ID, branch, index));
}
}
for path in legacy_paths {
let entropy = ExtendedPrivKey::derive(seed.as_bytes(), path.as_str())
.map_err(|e| {
QuantusError::Generic(format!("BIP32 derivation failed at {path}: {e:?}"))
})?
.secret();
out.push((SpendSecret(entropy), format!("bitcoin-seed {path}")));
}
Ok(out)
}
struct SecretKeyBytes(Vec<u8>);
impl Drop for SecretKeyBytes {
fn drop(&mut self) {
crate::wallet::keystore::zeroize_bytes(&mut self.0);
}
}
struct HistoricalKeypair {
public: Vec<u8>,
secret: SecretKeyBytes,
}
fn dilithium_keygen_ids(scan_accounts: u32, wallet_path: Option<&str>) -> Vec<String> {
let mut ids = vec![
"v1:seed".to_string(),
"fips:seed".to_string(),
"fips:seed32".to_string(),
];
for account in 0..=scan_accounts {
ids.push(format!("v1:bip32:m/44'/{DILITHIUM_CHAIN_ID}/{account}'/0/0"));
ids.push(format!("fips:bip32:m/44'/{DILITHIUM_CHAIN_ID}/{account}'/0/0"));
ids.push(format!("fips:bip32:m/44'/{DILITHIUM_CHAIN_ID}/{account}'/0'/0'"));
ids.push(format!("fips:bip32:m/44'/{DILITHIUM_CHAIN_ID}/{account}'"));
ids.push(format!("fips:hd:m/44'/{DILITHIUM_CHAIN_ID}/{account}'/0'/0'"));
}
if let Some(path) = wallet_path.map(|p| p.trim_end_matches('/')) {
if path != "m" && !path.is_empty() {
for id in [
format!("v1:bip32:{path}"),
format!("fips:bip32:{path}"),
format!("fips:hd:{path}"),
] {
if !ids.contains(&id) {
ids.push(id);
}
}
}
}
ids
}
fn derive_historical_dilithium(seed: &SensitiveBytes64, id: &str) -> Result<HistoricalKeypair> {
let (expansion, source) = id
.split_once(':')
.ok_or_else(|| QuantusError::Generic(format!("malformed keygen id {id:?}")))?;
let v1 = match expansion {
"v1" => true,
"fips" => false,
_ => return Err(QuantusError::Generic(format!("unknown keygen era in {id:?}"))),
};
if source == "seed" {
return Ok(mldsa87_keypair(seed.as_bytes(), v1));
}
if source == "seed32" {
return Ok(mldsa87_keypair(&seed.as_bytes()[..32], v1));
}
if let Some(path) = source.strip_prefix("bip32:") {
let mut entropy = ExtendedPrivKey::derive(seed.as_bytes(), path)
.map_err(|e| {
QuantusError::Generic(format!("BIP32 derivation failed at {path}: {e:?}"))
})?
.secret();
let keypair = mldsa87_keypair(&entropy, v1);
crate::wallet::keystore::zeroize_bytes(&mut entropy);
return Ok(keypair);
}
if let Some(path) = source.strip_prefix("hd:") {
if v1 {
return Err(QuantusError::Generic(format!(
"keygen id {id:?} is inconsistent: no v1-era wallet used the Dilithium-seed tree"
)));
}
let keypair = qp_rusty_crystals_hdwallet::ml_dsa_87::derive_key_from_seed(seed, path)
.map_err(|e| QuantusError::Generic(format!("HD derivation failed at {path}: {e:?}")))?;
let secret = SecretKeyBytes(keypair.secret().to_bytes().to_vec());
return Ok(HistoricalKeypair { public: keypair.public().to_bytes().to_vec(), secret });
}
Err(QuantusError::Generic(format!("unknown keygen source in {id:?}")))
}
fn mldsa87_keypair(seed: &[u8], v1: bool) -> HistoricalKeypair {
use crate::wallet::keystore::zeroize_bytes;
use params::{
ml_dsa_87::{ETA, K, L, PUBLICKEYBYTES, SECRETKEYBYTES},
CRHBYTES, SEEDBYTES, TR_BYTES,
};
use polyvec::Polyvec;
debug_assert!(seed.len() == 32 || seed.len() == 64);
let mut seedbuf = [0u8; 2 * SEEDBYTES + CRHBYTES];
if v1 {
fips202::shake256(&mut seedbuf, &seed[..SEEDBYTES]);
} else {
let mut preimage = [0u8; 64 + 2];
preimage[..seed.len()].copy_from_slice(seed);
preimage[seed.len()] = K as u8;
preimage[seed.len() + 1] = L as u8;
fips202::shake256(&mut seedbuf, &preimage[..seed.len() + 2]);
zeroize_bytes(&mut preimage);
}
let mut rho = [0u8; SEEDBYTES];
rho.copy_from_slice(&seedbuf[..SEEDBYTES]);
let mut rhoprime = [0u8; CRHBYTES];
rhoprime.copy_from_slice(&seedbuf[SEEDBYTES..SEEDBYTES + CRHBYTES]);
let mut key = [0u8; SEEDBYTES];
key.copy_from_slice(&seedbuf[SEEDBYTES + CRHBYTES..]);
zeroize_bytes(&mut seedbuf);
let mut s1 = Polyvec::<L>::default();
for (i, p) in s1.vec.iter_mut().enumerate() {
poly::uniform_eta::<ETA>(p, &rhoprime, i as u16);
}
let mut s2 = Polyvec::<K>::default();
for (i, p) in s2.vec.iter_mut().enumerate() {
poly::uniform_eta::<ETA>(p, &rhoprime, (L + i) as u16);
}
zeroize_bytes(&mut rhoprime);
let mut s1hat = s1.clone();
polyvec::ntt(&mut s1hat);
let mut t1 = Polyvec::<K>::default();
polyvec::matrix_pointwise_montgomery_streamed(&mut t1, &rho, &s1hat);
polyvec::reduce(&mut t1);
polyvec::invntt_tomont(&mut t1);
polyvec::add(&mut t1, &s2);
polyvec::caddq(&mut t1);
let mut t0 = Polyvec::<K>::default();
polyvec::power2round(&mut t1, &mut t0);
let mut pk = [0u8; PUBLICKEYBYTES];
packing::pack_pk::<K, PUBLICKEYBYTES>(&mut pk, &rho, &t1);
let mut tr = [0u8; TR_BYTES];
fips202::shake256(&mut tr, &pk);
let mut sk = [0u8; SECRETKEYBYTES];
packing::pack_sk::<K, L, ETA, SECRETKEYBYTES>(&mut sk, &rho, &tr, &key, &t0, &s1, &s2);
zeroize_bytes(&mut key);
let secret = SecretKeyBytes(sk.to_vec());
zeroize_bytes(&mut sk);
HistoricalKeypair { public: pk.to_vec(), secret }
}
fn prompt_wormhole_secret() -> Result<SpendSecret> {
log_print!("{}", "Paste wormhole secret (64 hex chars; input is hidden)".bright_yellow());
let mut hex_str = rpassword::read_password()
.map_err(|e| QuantusError::Generic(format!("Failed to read secret: {e}")))?;
let parsed = parse_secret_hex(&hex_str);
crate::wallet::keystore::zeroize_string(&mut hex_str);
parsed.map(SpendSecret).map_err(QuantusError::Generic)
}
fn read_wormhole_secret(path: &std::path::Path) -> Result<SpendSecret> {
let mut hex_str = password::read_secret_file(
path.to_str()
.ok_or_else(|| QuantusError::Generic("secret path is not UTF-8".into()))?,
"secret",
)?;
let parsed = parse_secret_hex(&hex_str);
crate::wallet::keystore::zeroize_string(&mut hex_str);
parsed.map(SpendSecret).map_err(QuantusError::Generic)
}
fn resolve_claim_account(to: Option<&str>, credentials: &Credentials) -> Result<[u8; 32]> {
if let Some(to) = to {
let (_, account) = resolve_address_with_subxt_account_id(to)?;
return Ok(*account.as_ref());
}
if let Some(account) = credentials.wallet_account {
return Ok(account);
}
Err(QuantusError::Generic("--to is required when claiming without --wallet".into()))
}
#[derive(Clone, Debug)]
struct SnapshotFile {
version: u32,
sha256: String,
rows: Vec<SnapshotRow>,
by_account: HashMap<[u8; 32], SnapshotRow>,
}
#[derive(Clone, Debug, Deserialize)]
struct SnapshotRow {
address: String,
account: String,
amount_hundredths: u64,
testnets: Vec<String>,
kind: String,
}
#[derive(Deserialize)]
struct SnapshotWire {
version: u32,
sha256: String,
rows: Vec<SnapshotRow>,
}
#[derive(Clone, Debug)]
struct FoundReward {
account: [u8; 32],
ss58: String,
amount_hundredths: u64,
testnets: Vec<String>,
kind: String,
scheme: &'static str,
source: RewardSource,
}
#[derive(Clone, Debug)]
enum RewardSource {
Dilithium,
DilithiumHistorical { keygen: String },
Wormhole { secret_index: usize, label: String },
}
fn find_matches(snapshot: &SnapshotFile, credentials: &Credentials) -> Vec<FoundReward> {
let mut found = Vec::new();
if let Some(keypair) = &credentials.dilithium {
for scheme in DilithiumHash::ALL {
let address = scheme.derive(&keypair.public_key);
if let Some(row) = snapshot.by_account.get(&address) {
found.push(FoundReward {
account: address,
ss58: row.address.clone(),
amount_hundredths: row.amount_hundredths,
testnets: row.testnets.clone(),
kind: row.kind.clone(),
scheme: scheme.id(),
source: RewardSource::Dilithium,
});
}
}
}
if let Some(seed) = &credentials.hd_seed {
for id in dilithium_keygen_ids(
credentials.scan_accounts,
credentials.wallet_derivation_path.as_deref(),
) {
let keypair = match derive_historical_dilithium(seed, &id) {
Ok(keypair) => keypair,
Err(e) => {
log_verbose!("keygen {id} skipped: {e}");
continue;
},
};
for scheme in DilithiumHash::ALL {
let address = scheme.derive(&keypair.public);
if let Some(row) = snapshot.by_account.get(&address) {
found.push(FoundReward {
account: address,
ss58: row.address.clone(),
amount_hundredths: row.amount_hundredths,
testnets: row.testnets.clone(),
kind: row.kind.clone(),
scheme: scheme.id(),
source: RewardSource::DilithiumHistorical { keygen: id.clone() },
});
}
}
}
}
for (secret_index, (secret, label)) in credentials.wormhole_secrets.iter().enumerate() {
for scheme in WormholeHash::ALL {
let derived = scheme.derive(secret.bytes());
if let Some(row) = snapshot.by_account.get(&derived.address) {
found.push(FoundReward {
account: derived.address,
ss58: row.address.clone(),
amount_hundredths: row.amount_hundredths,
testnets: row.testnets.clone(),
kind: row.kind.clone(),
scheme: scheme.id(),
source: RewardSource::Wormhole { secret_index, label: label.clone() },
});
}
}
}
found.sort_by(|a, b| a.ss58.cmp(&b.ss58).then(a.scheme.cmp(b.scheme)));
found.dedup_by(|a, b| a.account == b.account && a.scheme == b.scheme);
found
}
fn print_snapshot_header(snapshot: &SnapshotFile) {
log_print!(
"Snapshot v{} ({}) — {} rewarded addresses",
snapshot.version,
&snapshot.sha256[..snapshot.sha256.len().min(12)],
snapshot.rows.len()
);
}
fn print_matches(matches: &[FoundReward]) {
if matches.is_empty() {
log_print!("No airdrop addresses found for the supplied credentials.");
return;
}
log_print!("{} snapshot match(es):", matches.len());
for found in matches {
let claimable = match &found.source {
RewardSource::Dilithium | RewardSource::DilithiumHistorical { .. } => true,
RewardSource::Wormhole { .. } => found.scheme == CLAIMABLE_WORMHOLE_SCHEME,
};
let note = if claimable { "claimable" } else { "not claimable yet" };
log_print!(
" {} {} QUAN {} {} ({}) [{}]",
found.ss58.bright_cyan(),
format_hundredths(found.amount_hundredths),
found.testnets.join(","),
found.scheme,
found.kind,
note
);
}
}
enum ClaimOutcome {
Recorded { amount_hundredths: u64 },
Skipped,
}
async fn submit_claim(
client: &reqwest::Client,
server: &str,
found: &FoundReward,
claim_account: &[u8; 32],
credentials: &Credentials,
dry_run: bool,
) -> Result<ClaimOutcome> {
let body = match &found.source {
RewardSource::Dilithium => {
let keypair = credentials.dilithium.as_ref().ok_or_else(|| {
QuantusError::Generic("Dilithium match without a loaded wallet".into())
})?;
ClaimBody::Dilithium(build_dilithium_claim(
&keypair.public_key,
&keypair.private_key,
found.account,
*claim_account,
)?)
},
RewardSource::DilithiumHistorical { keygen } => {
let seed = credentials.hd_seed.as_ref().ok_or_else(|| {
QuantusError::Generic("historical Dilithium match without a wallet mnemonic".into())
})?;
let keypair = derive_historical_dilithium(seed, keygen)?;
ClaimBody::Dilithium(build_dilithium_claim(
&keypair.public,
&keypair.secret.0,
found.account,
*claim_account,
)?)
},
RewardSource::Wormhole { secret_index, label } => {
if found.scheme != CLAIMABLE_WORMHOLE_SCHEME {
log_print!(
"Skipping {} ({}) from {label}: server only accepts {CLAIMABLE_WORMHOLE_SCHEME}",
found.ss58,
found.scheme
);
return Ok(ClaimOutcome::Skipped);
}
let (secret, _) = credentials.wormhole_secrets.get(*secret_index).ok_or_else(|| {
QuantusError::Generic("wormhole secret index out of range".into())
})?;
log_print!("Proving wormhole ownership for {}…", found.ss58.bright_cyan());
ClaimBody::Wormhole(build_wormhole_claim(secret, *claim_account).await?)
},
};
if dry_run {
let json = serde_json::to_string_pretty(&body)
.map_err(|e| QuantusError::Generic(format!("claim JSON: {e}")))?;
log_print!("Dry run: would POST {} ({}):", found.ss58, found.scheme);
log_print!("{json}");
return Ok(ClaimOutcome::Recorded { amount_hundredths: found.amount_hundredths });
}
let url = format!("{}/claim", server.trim_end_matches('/'));
let response = client.post(&url).json(&body).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 recorded: ClaimResponse = serde_json::from_str(&text)
.map_err(|e| QuantusError::Generic(format!("claim JSON: {e}")))?;
log_success!(
"Recorded {} → {} ({} QUAN)",
recorded.address.bright_cyan(),
recorded.claim_account.bright_green(),
format_hundredths(recorded.amount_hundredths)
);
Ok(ClaimOutcome::Recorded { amount_hundredths: recorded.amount_hundredths })
}
fn build_dilithium_claim(
public_key: &[u8],
secret_key: &[u8],
address: [u8; 32],
claim_account: [u8; 32],
) -> Result<DilithiumClaimBody> {
let expiry_unix = now_unix()?.saturating_add(CLAIM_TTL_SECS);
let msg = claim_message(&address, &claim_account, expiry_unix);
let secret = SecretKey::from_bytes(secret_key)
.map_err(|_| QuantusError::Generic("invalid ML-DSA-87 secret key".into()))?;
let signature = secret
.sign(&msg, Some(CLAIM_CONTEXT), None)
.map_err(|e| QuantusError::Generic(format!("ML-DSA sign failed: {e}")))?;
let scheme = DilithiumHash::ALL
.iter()
.find(|s| s.derive(public_key) == address)
.ok_or_else(|| QuantusError::Generic("could not identify Dilithium hash scheme".into()))?;
Ok(DilithiumClaimBody {
scheme: scheme.id().to_string(),
address: bytes_to_quantus_ss58(&address),
claim_account: bytes_to_quantus_ss58(&claim_account),
public_key: hex::encode(public_key),
signature: hex::encode(signature),
expiry_unix,
})
}
async fn build_wormhole_claim(
secret: &SpendSecret,
claim_account: [u8; 32],
) -> Result<WormholeClaimBody> {
let mut secret_bytes = *secret.bytes();
let secret = Secret::new(&mut secret_bytes)
.map_err(|e| QuantusError::Generic(format!("invalid wormhole secret: {e:?}")))?;
let claim = BytesDigest::try_from(claim_account.as_slice())
.map_err(|e| QuantusError::Generic(format!("invalid claim account: {e:?}")))?;
let inputs = CircuitInputs::from_secret(secret, claim);
let proof = tokio::task::spawn_blocking(move || {
let prover = qp_ownership_prover::build_fresh().commit(&inputs)?;
prover.prove()
})
.await
.map_err(|e| QuantusError::Generic(format!("ownership prover task failed: {e}")))?
.map_err(|e| QuantusError::Generic(format!("ownership proof failed: {e}")))?;
Ok(WormholeClaimBody {
proof_kind: "wormhole_rate8".into(),
proof: hex::encode(proof.to_bytes()),
})
}
#[derive(Serialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
enum ClaimBody {
Dilithium(DilithiumClaimBody),
Wormhole(WormholeClaimBody),
}
#[derive(Serialize)]
struct DilithiumClaimBody {
scheme: String,
address: String,
claim_account: String,
public_key: String,
signature: String,
expiry_unix: i64,
}
#[derive(Serialize)]
struct WormholeClaimBody {
proof_kind: String,
proof: String,
}
#[derive(Deserialize)]
struct ClaimResponse {
address: String,
claim_account: String,
amount_hundredths: u64,
}
#[derive(Deserialize)]
struct ErrorBody {
error: String,
}
async fn fetch_snapshot(server: &str) -> Result<SnapshotFile> {
let url = format!("{}/snapshot", server.trim_end_matches('/'));
log_verbose!("GET {url}");
let response = http_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 wire: SnapshotWire = serde_json::from_str(&text)
.map_err(|e| QuantusError::Generic(format!("snapshot JSON: {e}")))?;
let mut by_account = HashMap::new();
for row in &wire.rows {
let account = parse_account_id(&row.account).or_else(|_| parse_account_id(&row.address))?;
by_account.insert(account, row.clone());
}
Ok(SnapshotFile { version: wire.version, sha256: wire.sha256, rows: wire.rows, by_account })
}
fn parse_account_id(s: &str) -> Result<[u8; 32]> {
let s = s.trim();
if s.starts_with("qz") {
let (account, _) = AccountId32::from_ss58check_with_version(s)
.map_err(|e| QuantusError::Generic(format!("invalid SS58 {s}: {e:?}")))?;
return Ok(*account.as_ref());
}
let hex_str = s.strip_prefix("0x").unwrap_or(s);
let bytes = hex::decode(hex_str)
.map_err(|e| QuantusError::Generic(format!("invalid account hex: {e}")))?;
bytes.try_into().map_err(|b: Vec<u8>| {
QuantusError::Generic(format!("account must be 32 bytes, got {}", b.len()))
})
}
fn http_client() -> Result<reqwest::Client> {
reqwest::Client::builder()
.timeout(Duration::from_secs(120))
.build()
.map_err(|e| QuantusError::Generic(format!("HTTP client: {e}")))
}
fn http_err(e: reqwest::Error) -> QuantusError {
QuantusError::NetworkError(e.to_string())
}
fn format_server_error(status: reqwest::StatusCode, body: &str) -> String {
if let Ok(err) = serde_json::from_str::<ErrorBody>(body) {
format!("server {status}: {}", err.error)
} else {
format!("server {status}: {}", body.trim())
}
}
fn format_hundredths(amount: u64) -> String {
format!("{}.{:02}", amount / 100, amount % 100)
}
fn now_unix() -> Result<i64> {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.map_err(|e| QuantusError::Generic(format!("system clock: {e}")))
}
fn claim_message(address: &[u8; 32], claim_account: &[u8; 32], expiry_unix: i64) -> [u8; 72] {
let mut msg = [0u8; 72];
msg[..32].copy_from_slice(address);
msg[32..64].copy_from_slice(claim_account);
msg[64..].copy_from_slice(&expiry_unix.to_be_bytes());
msg
}
#[derive(Clone, Copy, Debug)]
enum DilithiumHash {
V08Padded,
V091Padded,
V09Padded,
V10Padded,
Rate8HashBytes,
}
impl DilithiumHash {
const ALL: &'static [Self] = &[
Self::V08Padded,
Self::V091Padded,
Self::V09Padded,
Self::V10Padded,
Self::Rate8HashBytes,
];
fn id(self) -> &'static str {
match self {
Self::V08Padded => "dilithium-v08-padded",
Self::V091Padded => "dilithium-v091-padded",
Self::V09Padded => "dilithium-v09-padded",
Self::V10Padded => "dilithium-v10-padded",
Self::Rate8HashBytes => "dilithium-rate8-hash-bytes",
}
}
fn derive(self, public_key: &[u8]) -> [u8; 32] {
match self {
Self::V08Padded => hash_padded_legacy(public_key, 8, 73),
Self::V091Padded => hash_padded_legacy(public_key, 4, 188),
Self::V09Padded => hash_padded_v09(public_key),
Self::V10Padded => hash_padded_v10(public_key),
Self::Rate8HashBytes => qp_poseidon_core::hash_bytes(public_key),
}
}
}
#[derive(Clone, Copy, Debug)]
enum WormholeHash {
V09Injective,
Rate4Compact,
Rate8Compact,
Rate4Injective,
Rate8Injective,
}
impl WormholeHash {
const ALL: &'static [Self] = &[
Self::V09Injective,
Self::Rate4Compact,
Self::Rate8Compact,
Self::Rate4Injective,
Self::Rate8Injective,
];
fn id(self) -> &'static str {
match self {
Self::V09Injective => "wormhole-v09-injective",
Self::Rate4Compact => "wormhole-rate4-compact",
Self::Rate8Compact => "wormhole-rate8-compact",
Self::Rate4Injective => "wormhole-rate4-injective",
Self::Rate8Injective => "wormhole-rate8-injective",
}
}
fn derive(self, secret: &[u8; 32]) -> DerivedWormhole {
if matches!(self, Self::V09Injective) {
return derive_v09(secret);
}
let salt = injective4(b"wormhole");
let mut preimage =
SensitiveFelts::with_capacity(qp_poseidon_core::Goldilocks::ZERO, salt.len() + 9);
for felt in salt {
preimage.push(felt);
}
match self {
Self::V09Injective => unreachable!("handled above"),
Self::Rate4Injective | Self::Rate8Injective => {
for word in injective4_secret_words(secret) {
preimage.push(qp_poseidon_core::Goldilocks::from_u64(word));
}
},
Self::Rate4Compact | Self::Rate8Compact => {
let mut digest = compact8_decode(secret);
for felt in digest {
preimage.push(felt);
}
wipe_felts(&mut digest);
},
}
let first_hash = self.sponge().hash_felts(preimage.as_slice());
let address = self.sponge().rehash(&first_hash);
DerivedWormhole { first_hash, address }
}
fn sponge(self) -> Sponge {
match self {
Self::V09Injective => unreachable!("v09 uses its own field type; see derive()"),
Self::Rate4Compact | Self::Rate4Injective => Sponge::Rate4Pad10,
Self::Rate8Compact | Self::Rate8Injective => Sponge::Rate8Pad10,
}
}
}
struct DerivedWormhole {
#[allow(dead_code)]
first_hash: [u8; 32],
address: [u8; 32],
}
#[derive(Clone, Copy)]
enum Sponge {
Rate4Pad10,
Rate8Pad10,
}
impl Sponge {
fn hash_felts(self, input: &[qp_poseidon_core::Goldilocks]) -> [u8; 32] {
match self {
Self::Rate4Pad10 => hash_felts_rate4_pad10(input),
Self::Rate8Pad10 => qp_poseidon_core::hash_to_bytes(input),
}
}
fn rehash(self, digest: &[u8; 32]) -> [u8; 32] {
self.hash_felts(&compact8_decode(digest))
}
}
fn derive_v09(secret: &[u8; 32]) -> DerivedWormhole {
use p3_field::{integers::QuotientMap, PrimeCharacteristicRing};
type F = p3_goldilocks::Goldilocks;
let salt = v09::injective_bytes_to_felts(b"wormhole");
let mut preimage = SensitiveFelts::with_capacity(F::ZERO, salt.len() + 9);
for felt in salt {
preimage.push(felt);
}
for word in injective4_secret_words(secret) {
preimage.push(F::from_int(word));
}
let first_hash = v09_hash_no_pad(preimage.as_slice());
let address = v09_hash_no_pad(&v09::digest_bytes_to_felts(&first_hash));
DerivedWormhole { first_hash, address }
}
fn v09_permutation() -> &'static p3_goldilocks::Poseidon2Goldilocks<12> {
use rand_chacha::{rand_core::SeedableRng, ChaCha8Rng};
static PERMUTATION: std::sync::OnceLock<p3_goldilocks::Poseidon2Goldilocks<12>> =
std::sync::OnceLock::new();
PERMUTATION.get_or_init(|| {
const V09_POSEIDON2_SEED: u64 = 0x189189189189189;
let mut rng = ChaCha8Rng::seed_from_u64(V09_POSEIDON2_SEED);
p3_goldilocks::Poseidon2Goldilocks::<12>::new_from_rng_128(&mut rng)
})
}
fn v09_hash_no_pad(input: &[p3_goldilocks::Goldilocks]) -> [u8; 32] {
use p3_field::{PrimeCharacteristicRing, PrimeField64};
use p3_symmetric::Permutation;
type F = p3_goldilocks::Goldilocks;
const WIDTH: usize = 12;
const RATE: usize = 4;
let wipe = |felts: &mut [F]| {
felts.fill(F::ZERO);
core::hint::black_box(felts);
};
let poseidon2 = v09_permutation();
let mut state = [F::ZERO; WIDTH];
let mut block = [F::ZERO; RATE];
let num_chunks = input.chunks(RATE).len();
let mut unpadded = false;
for (j, chunk) in input.chunks(RATE).enumerate() {
block.fill(F::ZERO);
if j == num_chunks - 1 {
if chunk.len() < RATE {
block[chunk.len()] = F::ONE;
} else {
unpadded = true;
}
}
block[..chunk.len()].copy_from_slice(chunk);
for i in 0..RATE {
state[i] += block[i];
}
poseidon2.permute_mut(&mut state);
}
if unpadded {
state[0] += F::ONE;
poseidon2.permute_mut(&mut state);
}
state[RATE - 1] += F::ONE;
poseidon2.permute_mut(&mut state);
let mut out = [0u8; 32];
for (i, felt) in state[..RATE].iter().enumerate() {
out[i * 8..(i + 1) * 8].copy_from_slice(&felt.as_canonical_u64().to_le_bytes());
}
wipe(&mut state);
wipe(&mut block);
out
}
fn injective4_secret_words(secret: &[u8; 32]) -> impl Iterator<Item = u64> + '_ {
secret
.chunks(4)
.map(|chunk| u32::from_le_bytes(chunk.try_into().expect("4-byte chunk")) as u64)
.chain([1u64])
}
struct SensitiveFelts<F: Copy> {
felts: Vec<F>,
zero: F,
}
impl<F: Copy> SensitiveFelts<F> {
fn with_capacity(zero: F, capacity: usize) -> Self {
Self { felts: Vec::with_capacity(capacity), zero }
}
fn push(&mut self, felt: F) {
debug_assert!(self.felts.len() < self.felts.capacity(), "SensitiveFelts must be pre-sized");
self.felts.push(felt);
}
fn as_slice(&self) -> &[F] {
&self.felts
}
}
impl<F: Copy> Drop for SensitiveFelts<F> {
fn drop(&mut self) {
self.felts.fill(self.zero);
core::hint::black_box(self.felts.as_mut_slice());
}
}
fn wipe_felts(felts: &mut [qp_poseidon_core::Goldilocks]) {
felts.fill(qp_poseidon_core::Goldilocks::ZERO);
core::hint::black_box(felts);
}
fn compact8_decode(
bytes: &[u8; 32],
) -> [qp_poseidon_core::Goldilocks; qp_poseidon_core::POSEIDON2_OUTPUT] {
qp_poseidon_core::serialization::bytes_to_digest_lossy(bytes)
}
fn injective4(bytes: &[u8]) -> Vec<qp_poseidon_core::Goldilocks> {
use qp_poseidon_core::Goldilocks;
if bytes.is_empty() {
return Vec::new();
}
const N: usize = 4;
let mut out = Vec::new();
let num_chunks = bytes.len().div_ceil(N);
let mut unpadded = false;
for (i, chunk) in bytes.chunks(N).enumerate() {
let mut word = [0u8; N];
if i == num_chunks - 1 {
if chunk.len() < N {
word[chunk.len()] = 1;
} else {
unpadded = true;
}
}
word[..chunk.len()].copy_from_slice(chunk);
out.push(Goldilocks::from_u64(u32::from_le_bytes(word) as u64));
}
if unpadded {
out.push(Goldilocks::from_u64(1));
}
out
}
fn hash_felts_rate4_pad10(x: &[qp_poseidon_core::Goldilocks]) -> [u8; 32] {
use qp_poseidon_core::{Goldilocks, Poseidon2, POSEIDON2_OUTPUT, SPONGE_WIDTH};
const RATE_4: usize = 4;
let poseidon = Poseidon2::new();
let mut state = [Goldilocks::ZERO; SPONGE_WIDTH];
let mut buf = [Goldilocks::ZERO; RATE_4];
let mut buf_len = 0usize;
let absorb = |felt: Goldilocks,
state: &mut [Goldilocks; SPONGE_WIDTH],
buf: &mut [Goldilocks; RATE_4],
buf_len: &mut usize| {
buf[*buf_len] = felt;
*buf_len += 1;
if *buf_len == RATE_4 {
for i in 0..RATE_4 {
state[i] += buf[i];
}
poseidon.permute_mut(state);
*buf = [Goldilocks::ZERO; RATE_4];
*buf_len = 0;
}
};
for &felt in x {
absorb(felt, &mut state, &mut buf, &mut buf_len);
}
absorb(Goldilocks::ONE, &mut state, &mut buf, &mut buf_len);
while buf_len != 0 {
absorb(Goldilocks::ZERO, &mut state, &mut buf, &mut buf_len);
}
let digest: [Goldilocks; POSEIDON2_OUTPUT] =
state[..POSEIDON2_OUTPUT].try_into().expect("width > output");
wipe_felts(&mut state);
wipe_felts(&mut buf);
qp_poseidon_core::serialization::digest_to_bytes(&digest)
}
fn hash_padded_v09(bytes: &[u8]) -> [u8; 32] {
v09::Poseidon2Core::new().hash_padded(bytes)
}
fn hash_padded_legacy(bytes: &[u8], bytes_per_felt: usize, pad_to: usize) -> [u8; 32] {
use plonky2::{
field::{goldilocks_field::GoldilocksField, types::Field},
plonk::config::{GenericHashOut, Hasher},
};
let mut felts: Vec<GoldilocksField> = bytes
.chunks(bytes_per_felt)
.map(|chunk| {
let mut word = [0u8; 8];
word[..chunk.len()].copy_from_slice(chunk);
GoldilocksField::from_noncanonical_u64(u64::from_le_bytes(word))
})
.collect();
if felts.len() < pad_to {
felts.resize(pad_to, GoldilocksField::ZERO);
}
plonky2::hash::poseidon::PoseidonHash::hash_no_pad(&felts)
.to_bytes()
.try_into()
.expect("poseidon output is 32 bytes")
}
fn hash_padded_v10(bytes: &[u8]) -> [u8; 32] {
use qp_poseidon_core::Goldilocks;
const PAD: usize = 189;
let mut felts = injective4(bytes);
if felts.len() < PAD {
felts.resize(PAD, Goldilocks::ZERO);
}
hash_felts_rate4_pad10(&felts)
}
#[cfg(test)]
mod tests {
use super::*;
fn hex32(s: &str) -> [u8; 32] {
hex::decode(s).unwrap().try_into().unwrap()
}
const TEST_MNEMONIC: &str = "abandon abandon abandon abandon abandon abandon abandon abandon \
abandon abandon abandon about";
fn test_seed() -> SensitiveBytes64 {
let mut seed = SensitiveBytes64::zeroed();
mnemonic_to_seed(TEST_MNEMONIC.into(), None, &mut seed).unwrap();
seed
}
#[test]
fn hd_scan_covers_change_branch_and_multiround_rounds() {
let secrets = derive_hd_wormhole_secrets(&test_seed(), None, 8).unwrap();
assert_eq!(secrets.len(), 2 * (9 * 17) + 1);
let path = format!("m/44'/{}/0'/2'/1'", QUANTUS_WORMHOLE_CHAIN_ID);
let direct =
qp_rusty_crystals_hdwallet::derive_wormhole_from_mnemonic(TEST_MNEMONIC, None, &path)
.unwrap();
let (secret, _) = secrets
.iter()
.find(|(_, label)| label == &format!("hd {path}"))
.expect("round-2 path in scan");
assert_eq!(secret.bytes(), direct.secret().as_bytes());
let pinned = derive_hd_wormhole_secrets(&test_seed(), Some(1), 8).unwrap();
assert_eq!(pinned.len(), 2 * 9 + 1);
assert!(pinned.iter().any(|(_, label)| label == &format!("hd {path}")));
}
#[test]
fn scan_rounds_flag_reaches_round_nine() {
let seed = test_seed();
let path = format!("m/44'/{}/0'/9'/0'", QUANTUS_WORMHOLE_CHAIN_ID);
let direct = generate_wormhole_from_seed(&seed, &path).unwrap();
let default_window = derive_hd_wormhole_secrets(&seed, None, 8).unwrap();
assert!(!default_window.iter().any(|(_, label)| label == &format!("hd {path}")));
let widened = derive_hd_wormhole_secrets(&seed, None, 9).unwrap();
let (secret, _) = widened
.iter()
.find(|(_, label)| label == &format!("hd {path}"))
.expect("round-9 path in widened scan");
assert_eq!(secret.bytes(), direct.secret().as_bytes());
}
#[test]
fn hd_scan_covers_bitcoin_seed_wormhole_entropies() {
let secrets = derive_hd_wormhole_secrets(&test_seed(), None, 8).unwrap();
let get = |label: &str| {
secrets
.iter()
.find(|(_, l)| l == label)
.unwrap_or_else(|| panic!("{label} missing"))
.0
.bytes()
.to_owned()
};
assert_eq!(
get("bitcoin-seed m"),
hex32("1837c1be8e2995ec11cda2b066151be2cfb48adf9e47b151d46adab3a21cdf67")
);
let path = format!("m/44'/{}/0'/0'/0'", QUANTUS_WORMHOLE_CHAIN_ID);
assert_eq!(
get(&format!("bitcoin-seed {path}")),
hex32("87b3000325d7058a64b01b93f199b3d54ba5d9b2e036cee672199e7da326538c")
);
}
#[test]
fn historical_dilithium_keygens_match_shipped_crate_vectors() {
use sha2::Digest;
let seed = test_seed();
let ids = dilithium_keygen_ids(8, None);
let vectors = [
("v1:seed", "77993f1dafc02c9162925807f825f611bab071d121d6a42250bc4957c9149562"),
(
"v1:bip32:m/44'/189189'/0'/0/0",
"57eafbd7c902c02686aff7f39c692beb9f3c057383dc6c954defc381e2c59f7d",
),
("fips:seed", "1819feeaba63629813f1266de3d135de22ec505f1e014e669011cd9399bacb6f"),
(
"fips:bip32:m/44'/189189'/0'/0/0",
"08fffe331b888d215c335e82712aa41ef680edd57e4634a89cca81b87e021e24",
),
(
"fips:bip32:m/44'/189189'/0'/0'/0'",
"7aeb9126559a7f750bf90941f632cf1f2835a57500cb1be74d9d3d15007d7e8d",
),
(
"fips:bip32:m/44'/189189'/0'",
"a49dac7c3537f61476626d491016abb2ca0e355be017c8cbbabc5a705d1cb5d7",
),
("fips:seed32", "2af97815f11fb93d64d0e93fecff2b0e7af88882ed9fda813b5cd6f421799ab2"),
(
"fips:hd:m/44'/189189'/0'/0'/0'",
"aa46cca1014fa42d40388298b33a7537d6a313b401f5be259cc59797cf4307e7",
),
];
for (id, expected_pk_sha) in vectors {
assert!(ids.iter().any(|i| i == id), "{id} missing from scan list");
let keypair = derive_historical_dilithium(&seed, id).unwrap();
assert_eq!(hex::encode(sha2::Sha256::digest(&keypair.public)), expected_pk_sha, "{id}");
}
let v1 = derive_historical_dilithium(&seed, "v1:seed").unwrap();
assert_eq!(
hex::encode(sha2::Sha256::digest(&v1.secret.0)),
"fbcb8f8db649111054baddc24f3eaab314c120950d76fdb8df5c1cd6a4102aa9"
);
let fips = derive_historical_dilithium(&seed, "fips:seed").unwrap();
assert_eq!(
hex::encode(sha2::Sha256::digest(&fips.secret.0)),
"fbe63db6ccf71badbb5a4aea59bead046637065fca38270b1a6361644ecf20d3"
);
}
fn snapshot_for(address: [u8; 32]) -> SnapshotFile {
let row = SnapshotRow {
address: bytes_to_quantus_ss58(&address),
account: hex::encode(address),
amount_hundredths: 150,
testnets: vec!["Resonance".into()],
kind: "dilithium".into(),
};
SnapshotFile {
version: 1,
sha256: "0".repeat(64),
rows: vec![row.clone()],
by_account: HashMap::from([(address, row)]),
}
}
fn seed_only_credentials(seed: SensitiveBytes64) -> Credentials {
Credentials {
dilithium: None,
wallet_account: None,
wormhole_secrets: Vec::new(),
hd_seed: Some(seed),
wallet_derivation_path: None,
scan_accounts: 8,
}
}
#[test]
fn matches_and_claims_historical_dilithium_address() {
let keygen = "v1:bip32:m/44'/189189'/1'/0/0";
let seed = test_seed();
let keypair = derive_historical_dilithium(&seed, keygen).unwrap();
let address = DilithiumHash::V08Padded.derive(&keypair.public);
let snapshot = snapshot_for(address);
let credentials = seed_only_credentials(seed);
let matches = find_matches(&snapshot, &credentials);
assert_eq!(matches.len(), 1);
assert_eq!(matches[0].scheme, "dilithium-v08-padded");
let RewardSource::DilithiumHistorical { keygen: found_keygen } = &matches[0].source else {
panic!("expected a historical Dilithium source");
};
assert_eq!(found_keygen, keygen);
let claim_account = [9u8; 32];
let body =
build_dilithium_claim(&keypair.public, &keypair.secret.0, address, claim_account)
.unwrap();
assert_eq!(body.scheme, "dilithium-v08-padded");
let msg = claim_message(&address, &claim_account, body.expiry_unix);
let public =
qp_rusty_crystals_dilithium::ml_dsa_87::PublicKey::from_bytes(&keypair.public).unwrap();
let sig = hex::decode(&body.signature).unwrap();
assert!(public.verify(&msg, &sig, Some(CLAIM_CONTEXT)));
}
#[test]
fn scan_accounts_flag_reaches_account_nine() {
let keygen = "fips:bip32:m/44'/189189'/9'/0'/0'";
let seed = test_seed();
let keypair = derive_historical_dilithium(&seed, keygen).unwrap();
let address = DilithiumHash::V10Padded.derive(&keypair.public);
let snapshot = snapshot_for(address);
let mut credentials = seed_only_credentials(seed);
assert!(find_matches(&snapshot, &credentials).is_empty());
credentials.scan_accounts = 9;
let matches = find_matches(&snapshot, &credentials);
assert_eq!(matches.len(), 1);
let RewardSource::DilithiumHistorical { keygen: found_keygen } = &matches[0].source else {
panic!("expected a historical Dilithium source");
};
assert_eq!(found_keygen, keygen);
}
#[test]
fn wallet_derivation_path_is_scanned_across_eras() {
let keygen = "v1:bip32:m/44'/189189'/42'/0/0";
let seed = test_seed();
let keypair = derive_historical_dilithium(&seed, keygen).unwrap();
let address = DilithiumHash::V08Padded.derive(&keypair.public);
let snapshot = snapshot_for(address);
let mut credentials = seed_only_credentials(seed);
assert!(find_matches(&snapshot, &credentials).is_empty());
credentials.wallet_derivation_path = Some("m/44'/189189'/42'/0/0".into());
let matches = find_matches(&snapshot, &credentials);
assert_eq!(matches.len(), 1);
let RewardSource::DilithiumHistorical { keygen: found_keygen } = &matches[0].source else {
panic!("expected a historical Dilithium source");
};
assert_eq!(found_keygen, keygen);
}
#[test]
fn wallet_marker_path_adds_no_candidates() {
assert_eq!(dilithium_keygen_ids(8, Some("m/")), dilithium_keygen_ids(8, None));
assert_eq!(dilithium_keygen_ids(8, Some("m")), dilithium_keygen_ids(8, None));
assert_eq!(
dilithium_keygen_ids(8, Some("m/44'/189189'/0'/0'/0'")).len(),
dilithium_keygen_ids(8, None).len() + 1
);
}
#[test]
fn airdrop_rejects_secret_cli_argument() {
use clap::Parser;
#[derive(Parser, Debug)]
#[command(name = "quantus")]
struct TestCli {
#[command(subcommand)]
command: crate::cli::Commands,
}
let secret = "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20";
for args in [
vec!["quantus", "airdrop", "check", "--wormhole-secret", secret],
vec!["quantus", "airdrop", "claim", "--wormhole-secret", secret],
vec!["quantus", "airdrop", "check", "--wormhole-secret-prompt", secret],
vec!["quantus", "airdrop", "claim", "--wormhole-secret-prompt", secret],
] {
let result = TestCli::try_parse_from(args.clone());
assert!(result.is_err(), "airdrop must not accept a secret on argv; args={args:?}");
}
for args in [
vec!["quantus", "airdrop", "check", "--wormhole-secret-prompt"],
vec!["quantus", "airdrop", "claim", "--wormhole-secret-prompt"],
] {
assert!(
TestCli::try_parse_from(args.clone()).is_ok(),
"bare --wormhole-secret-prompt must parse; args={args:?}"
);
}
}
#[test]
fn claim_message_is_address_dest_expiry() {
let address = [1u8; 32];
let dest = [2u8; 32];
let expiry = 0x0102_0304_0506_0708i64;
let msg = claim_message(&address, &dest, expiry);
assert_eq!(&msg[..32], &address);
assert_eq!(&msg[32..64], &dest);
assert_eq!(&msg[64..], &expiry.to_be_bytes());
}
#[test]
fn current_wormhole_matches_hdwallet_golden() {
let secret = hex32("30051cfa3abd462d3bc26da2d660e90ba8af6080b7fe95d9fd3f3b37c7d9ce4b");
let derived = WormholeHash::Rate8Compact.derive(&secret);
assert_eq!(
derived.first_hash,
hex32("890ff21aa4fda75dc56c6c322c164d3c21a18ca7853d368c28cf158affc8b5b1")
);
assert_eq!(
derived.address,
hex32("6a2f0d3abe4390e0b05f6dea4ba10670676cda7c00d49526ddde59f16c85269f")
);
}
#[test]
fn pre_v095_dilithium_matches_original_crate_vectors() {
assert_eq!(
DilithiumHash::V08Padded.derive(&[0u8]),
hex32("fdf0715f178bfb2381d3804961bda8c679990d6318ff53f7a6475e1bef1982ca")
);
assert_eq!(
DilithiumHash::V08Padded.derive(&[5u8; 2592]),
hex32("9c69917b10f0228a0beed1d78ce34026b4778dc04a49a401dd5e692a51f44207")
);
assert_eq!(
DilithiumHash::V091Padded.derive(&[0u8]),
hex32("c4f1020767625056e669e3653f190b7763c6c398a45f1dc20db0d7ed32b14ff7")
);
assert_eq!(
DilithiumHash::V091Padded.derive(&[5u8; 2592]),
hex32("8ba4f919664c796aa811f552eaff5975570d56ed6812728944f60fe7d28d3c74")
);
}
#[test]
fn v09_schemes_match_original_crate_vectors() {
assert_eq!(
DilithiumHash::V09Padded.derive(&[0u8]),
hex32("b17b423096da9ebd57af5038b490257d9c492e64059c0ccff23f44e6293213d4")
);
assert_eq!(
WormholeHash::V09Injective.derive(&[42u8; 32]).address,
hex32("f4e231ede747e9ca2da9528add147ee488651a0df72b00313b0a8e6b76388fea")
);
}
#[test]
fn historical_wormhole_schemes_diverge() {
let secret = [9u8; 32];
let addrs: Vec<_> = WormholeHash::ALL.iter().map(|s| s.derive(&secret).address).collect();
assert!(addrs.iter().any(|a| *a != addrs[0]));
}
#[test]
fn dilithium_hash_schemes_diverge() {
let pk = [7u8; 64];
let addrs: Vec<_> = DilithiumHash::ALL.iter().map(|s| s.derive(&pk)).collect();
for i in 0..addrs.len() {
for j in (i + 1)..addrs.len() {
assert_ne!(
addrs[i],
addrs[j],
"{} and {} collide",
DilithiumHash::ALL[i].id(),
DilithiumHash::ALL[j].id()
);
}
}
let a = DilithiumHash::V09Padded.derive(&pk);
let b = DilithiumHash::V10Padded.derive(&pk);
let c = DilithiumHash::Rate8HashBytes.derive(&pk);
assert_ne!(a, b);
assert_ne!(b, c);
}
#[test]
fn dilithium_claim_json_is_internally_tagged() {
let body = ClaimBody::Dilithium(DilithiumClaimBody {
scheme: "dilithium-v10-padded".into(),
address: "qzabc".into(),
claim_account: "qzdef".into(),
public_key: "aa".into(),
signature: "bb".into(),
expiry_unix: 42,
});
let value = serde_json::to_value(&body).unwrap();
assert_eq!(value["kind"], "dilithium");
assert_eq!(value["scheme"], "dilithium-v10-padded");
assert_eq!(value["expiry_unix"], 42);
assert!(value.get("Dilithium").is_none());
}
#[test]
fn wormhole_claim_json_is_internally_tagged() {
let body = ClaimBody::Wormhole(WormholeClaimBody {
proof_kind: "wormhole_rate8".into(),
proof: "cc".into(),
});
let value = serde_json::to_value(&body).unwrap();
assert_eq!(value["kind"], "wormhole");
assert_eq!(value["proof_kind"], "wormhole_rate8");
assert_eq!(value["proof"], "cc");
}
#[test]
fn spend_secret_debug_is_redacted() {
let secret = SpendSecret([0xAB; 32]);
let debug = format!("{secret:?}");
assert!(!debug.contains("ab"), "debug output must not leak secret bytes: {debug}");
assert!(!debug.contains("171"), "debug output must not leak secret bytes: {debug}");
assert!(debug.contains("redacted"));
}
#[test]
fn reward_source_debug_has_no_secret_material() {
let source = RewardSource::Wormhole { secret_index: 0, label: "hd m/44'".into() };
let debug = format!("{source:?}");
assert!(debug.contains("secret_index"));
}
#[test]
fn resolve_claim_account_prefers_to_over_wallet_account() {
let credentials = Credentials {
dilithium: None,
wallet_account: Some([5u8; 32]),
wormhole_secrets: Vec::new(),
hd_seed: None,
wallet_derivation_path: None,
scan_accounts: 8,
};
let dest = [7u8; 32];
let resolved =
resolve_claim_account(Some(&bytes_to_quantus_ss58(&dest)), &credentials).unwrap();
assert_eq!(resolved, dest);
}
#[test]
fn resolve_claim_account_defaults_to_unlocked_wallet_without_reopening() {
let credentials = Credentials {
dilithium: None,
wallet_account: Some([5u8; 32]),
wormhole_secrets: Vec::new(),
hd_seed: None,
wallet_derivation_path: None,
scan_accounts: 8,
};
assert_eq!(resolve_claim_account(None, &credentials).unwrap(), [5u8; 32]);
}
#[test]
fn resolve_claim_account_requires_to_without_wallet() {
let credentials = Credentials {
dilithium: None,
wallet_account: None,
wormhole_secrets: Vec::new(),
hd_seed: None,
wallet_derivation_path: None,
scan_accounts: 8,
};
assert!(resolve_claim_account(None, &credentials).is_err());
}
#[test]
fn finish_claims_fails_when_all_submissions_failed() {
assert!(finish_claims(false, 0, 0, 0, 3).is_err());
}
#[test]
fn finish_claims_fails_on_partial_failure() {
assert!(finish_claims(false, 150, 1, 1, 1).is_err());
}
#[test]
fn finish_claims_succeeds_with_skips_but_no_failures() {
assert!(finish_claims(false, 150, 1, 2, 0).is_ok());
assert!(finish_claims(true, 0, 1, 0, 0).is_ok());
}
}
#[cfg(test)]
mod heap_zeroization_tests {
use core::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::{
alloc::{GlobalAlloc, Layout, System},
sync::OnceLock,
};
use super::{injective4_secret_words, WormholeHash};
use crate::cli::wormhole::parse_secret_hex;
const SECRET_PATTERN: [u8; 32] = *b"quantus-cli-airdrop-zeroize-pat!";
static SCANNING: AtomicBool = AtomicBool::new(false);
static LEAKED_BLOCK_SIZE: AtomicUsize = AtomicUsize::new(0);
static INJECTIVE4_IMAGE: OnceLock<Vec<u8>> = OnceLock::new();
fn contains(haystack: &[u8], needle: &[u8]) -> bool {
haystack.windows(needle.len()).any(|w| w == needle)
}
struct SecretScanningAllocator;
unsafe impl GlobalAlloc for SecretScanningAllocator {
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
unsafe { System.alloc(layout) }
}
unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
if SCANNING.load(Ordering::SeqCst) && layout.size() >= SECRET_PATTERN.len() {
let block = unsafe { core::slice::from_raw_parts(ptr, layout.size()) };
let hit = contains(block, &SECRET_PATTERN) ||
INJECTIVE4_IMAGE.get().is_some_and(|img| contains(block, img));
if hit {
LEAKED_BLOCK_SIZE.store(layout.size(), Ordering::SeqCst);
}
}
unsafe { System.dealloc(ptr, layout) }
}
}
#[global_allocator]
static ALLOCATOR: SecretScanningAllocator = SecretScanningAllocator;
fn injective4_image() -> Vec<u8> {
injective4_secret_words(&SECRET_PATTERN)
.take(8) .flat_map(u64::to_le_bytes)
.collect()
}
#[test]
fn matching_never_frees_heap_memory_containing_the_secret() {
INJECTIVE4_IMAGE.set(injective4_image()).expect("set once");
LEAKED_BLOCK_SIZE.store(0, Ordering::SeqCst);
SCANNING.store(true, Ordering::SeqCst);
for scheme in WormholeHash::ALL {
let derived = scheme.derive(&SECRET_PATTERN);
core::hint::black_box(derived.address);
}
let secret_hex = hex::encode(SECRET_PATTERN);
let parsed = parse_secret_hex(&secret_hex).expect("valid secret hex");
core::hint::black_box(parsed);
let mut bad_digit = hex::encode(&SECRET_PATTERN[..31]);
bad_digit.push_str("zz");
assert!(parse_secret_hex(&bad_digit).is_err());
assert!(parse_secret_hex(&secret_hex[..62]).is_err());
assert!(parse_secret_hex(&secret_hex[..63]).is_err());
SCANNING.store(false, Ordering::SeqCst);
let leaked = LEAKED_BLOCK_SIZE.load(Ordering::SeqCst);
assert_eq!(
leaked, 0,
"a heap block of {leaked} bytes still containing the spend secret was freed \
unscrubbed; check SensitiveFelts pre-sizing and drop in cli/airdrop.rs"
);
}
}