Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -439,6 +439,44 @@ 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.

#### Paying out claims (operators)

Four commands turn recorded claims into one on-chain payment each. Every step
is separate so it can be checked by hand, and a manifest file carries the state
between them (`pulled` → `approved` → `paying` → `paid` → `marked`).

```bash
# 1. Pull unpaid recorded claims into a manifest (one transfer per payout account)
quantus airdrop pull --out payout.json

# 2. Show it, re-check it against the server and the chain, approve it
quantus airdrop review --manifest payout.json --approve

# 3. Pay it as ONE utility.batch_all — every transfer lands or none does.
# A cold wallet signs over QR; the command waits for finalization.
quantus airdrop pay --manifest payout.json --from treasury_cold

# 4. Mark every reward in the manifest paid on the claim server
quantus airdrop mark-paid --manifest payout.json --admin-token-file ./admin-token
```

- `pull` skips unclaimed rows, aggregates rewards by `claim_account`, and refuses
to run while another manifest in the same directory is not yet `marked`, so
no reward can be pulled into two payments. `--limit N` keeps only the first
N destinations when a batch must be split (a cold-wallet QR payload holds
about 180 transfers).
- `review` fails if any reward was paid, re-claimed or changed on the server
since the pull, reports the encoded batch size, and `--approve` pins the
transfers with a hash that `pay` verifies.
- `pay` re-checks the server right before signing, records the signer, nonce
and anchor block in the manifest, then submits. If it is interrupted, run it
again with `--recover`: it scans the finalized blocks the batch could have
landed in and either records the payment or, once the transaction can no
longer be included, returns the manifest to `approved`.
- `mark-paid` is idempotent and re-runnable; a `409 already marked paid` counts
as done. The admin token comes from `--admin-token-file` (owner-only file) or
`AIRDROP_ADMIN_TOKEN`, never from argv.

---

### Developer Tools
Expand Down
11 changes: 11 additions & 0 deletions src/chain/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,17 @@ impl QuantusClient {
Ok(latest_hash)
}

/// Hash of the block at `number`; an error if the chain has no such block.
pub async fn get_block_hash(&self, number: u64) -> crate::error::Result<H256> {
let hash: Option<H256> =
self.rpc_client.request("chain_getBlockHash", [number]).await.map_err(|e| {
QuantusError::NetworkError(format!(
"Failed to get block hash for block {number}: {e:?}"
))
})?;
hash.ok_or_else(|| QuantusError::NetworkError(format!("Block {number} not found")))
}

/// Interpret a System::Account nonce lookup without collapsing absence into a silent zero.
///
/// Returns `(nonce, account_exists)`. Missing accounts use nonce `0` (correct for the first
Expand Down
109 changes: 92 additions & 17 deletions src/cli/airdrop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,11 @@ use serde::{Deserialize, Serialize};
use sp_core::crypto::{AccountId32, Ss58Codec};
use std::{collections::HashMap, path::PathBuf, time::Duration};

mod payout;

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";
pub(super) const DEFAULT_SERVER: &str = "https://airdrop-claim.quantus.com";
const HD_WORMHOLE_INDEXES: std::ops::RangeInclusive<usize> = 0..=16;
const CLAIMABLE_WORMHOLE_SCHEME: &str = "wormhole-rate8-compact";
/// BIP44 coin type for Dilithium keys (the wormhole coin type is 189189189').
Expand Down Expand Up @@ -130,9 +132,68 @@ pub enum AirdropCommands {
#[arg(long)]
dry_run: bool,
},
/// Operator: pull unpaid recorded claims into a payout manifest
Pull {
/// Claim server base URL
#[arg(long, default_value = DEFAULT_SERVER)]
server: String,

/// Manifest file to write (default: airdrop-payout-<unix time>.json)
#[arg(long)]
out: Option<PathBuf>,

/// Only include the first N payout destinations (one batch per manifest)
#[arg(long)]
limit: Option<usize>,
},

/// Operator: show a manifest, check it against the server and chain, optionally approve it
Review {
/// Payout manifest written by `airdrop pull`
#[arg(long)]
manifest: PathBuf,

/// Approve the manifest for payment
#[arg(long)]
approve: bool,
},

/// Operator: pay an approved manifest as one atomic batch (all transfers or none)
Pay {
/// Approved payout manifest
#[arg(long)]
manifest: PathBuf,

/// Wallet that pays (a cold wallet signs over QR)
#[arg(long, short)]
from: String,

/// Password for the wallet (unsupported on argv; use --password-file or prompt)
#[arg(short, long, hide = true)]
password: Option<String>,

/// Read password from file (for scripting)
#[arg(long)]
password_file: Option<String>,

/// After an interrupted payment: find out whether the batch landed and update the manifest
#[arg(long)]
recover: bool,
},

/// Operator: mark every reward in a paid manifest as paid on the claim server
MarkPaid {
/// Paid payout manifest
#[arg(long)]
manifest: PathBuf,

/// File holding the server admin token (chmod 600); otherwise AIRDROP_ADMIN_TOKEN
#[arg(long)]
admin_token_file: Option<String>,
},
}

pub async fn handle_airdrop_command(command: AirdropCommands) -> Result<()> {
pub async fn handle_airdrop_command(command: AirdropCommands, node_url: &str) -> Result<()> {
match command {
AirdropCommands::Check {
server,
Expand Down Expand Up @@ -180,6 +241,14 @@ pub async fn handle_airdrop_command(command: AirdropCommands) -> Result<()> {
dry_run,
)
.await,
AirdropCommands::Pull { server, out, limit } =>
payout::handle_pull(server, out, limit).await,
AirdropCommands::Review { manifest, approve } =>
payout::handle_review(node_url, manifest, approve).await,
AirdropCommands::Pay { manifest, from, password, password_file, recover } =>
payout::handle_pay(node_url, manifest, from, password, password_file, recover).await,
AirdropCommands::MarkPaid { manifest, admin_token_file } =>
payout::handle_mark_paid(manifest, admin_token_file).await,
}
}

Expand Down Expand Up @@ -1032,25 +1101,31 @@ struct ErrorBody {
}

async fn fetch_snapshot(server: &str) -> Result<SnapshotFile> {
let url = format!("{}/snapshot", server.trim_end_matches('/'));
let wire: SnapshotWire = get_json(server, "snapshot").await?;
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 })
}

pub(super) async fn get_json<T: serde::de::DeserializeOwned>(
server: &str,
path: &str,
) -> Result<T> {
let url = format!("{}/{path}", 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 })
serde_json::from_str(&text).map_err(|e| QuantusError::Generic(format!("{path} JSON: {e}")))
}

fn parse_account_id(s: &str) -> Result<[u8; 32]> {
pub(super) 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)
Expand All @@ -1065,30 +1140,30 @@ fn parse_account_id(s: &str) -> Result<[u8; 32]> {
})
}

fn http_client() -> Result<reqwest::Client> {
pub(super) 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 {
pub(super) fn http_err(e: reqwest::Error) -> QuantusError {
QuantusError::NetworkError(e.to_string())
}

fn format_server_error(status: reqwest::StatusCode, body: &str) -> String {
pub(super) 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 {
pub(super) fn format_hundredths(amount: u64) -> String {
format!("{}.{:02}", amount / 100, amount % 100)
}

fn now_unix() -> Result<i64> {
pub(super) fn now_unix() -> Result<i64> {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
Expand Down
Loading
Loading