From 3db8b05032dcb7d1ec91fe03274eca7709fcba1e Mon Sep 17 00:00:00 2001 From: rob thijssen Date: Wed, 16 Sep 2026 10:14:40 +0300 Subject: [PATCH] feat: the wormhole address behind mining rewards, and its shielded balance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit keys/wormhole.rs derives the wormhole keys from the seed at the wormhole coin type (m/44'/189189189'/i'/0'/0'), pinned to what quantus-node's `key quantus --scheme wormhole` prints for the public dev phrase at index 0 and 1, and derives a miner's address from its published inner hash (bob's, as the observer shows it). The nullifier that spends a leaf is mirrored on the Poseidon core alone, pinned to four vectors produced once with the circuit crate, so the wallet does not carry the prover to know what it has spent. substrate/wormhole.rs reads ZkTree::LeafCount, scans ZkTree::Leaves in pages of 500 locally computed keys for leaves paying an address, and checks Wormhole::UsedNullifiers. Nothing on chain indexes leaves by recipient, so the scan runs once from zero and then only over what was appended; the SQLite cache keeps the leaves and the frontier under a ShieldedCache port. The wormhole_summary command derives the keys from the session's seed for the call, scans, marks spent leaves, and answers with the address, the inner hash and the shielded and received totals; the accounts page shows it with copy and rescan. A raw-seed wallet with no seed is told so rather than shown a fake address. Verified on mainnet through the public endpoint: a full scan of 107,127 leaves in 16 s found 31 leaves paying bob's wormhole address for 9.34 QTC, the observer's 30-reward attribution of 9.33 plus the one since; the dev phrase restored in the app shows qzq32vN2ZtX2T9YrvCh69CMvbC2nWmEq4tCjRWKg4CyuxeShg and inner hash 0x5a5891e7…, the node's own numbers, with the tree scanned to its end. doc/threat-model.md names the assets, the boundaries and the file behind each mitigation; its review by someone else is what closes #48. Closes #45 Refs #48 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_014ftBXYuba8ARhQeF74oUgW --- crates/wallet-app/src/commands.rs | 105 ++++++++- crates/wallet-app/src/lib.rs | 1 + crates/wallet-app/src/state.rs | 14 +- crates/wallet-core/src/ports.rs | 41 ++++ crates/wallet-data/src/history/cache.rs | 149 +++++++++++- crates/wallet-data/src/keys/mod.rs | 2 + crates/wallet-data/src/keys/wormhole.rs | 201 ++++++++++++++++ crates/wallet-data/src/substrate/adapter.rs | 39 +++- crates/wallet-data/src/substrate/mod.rs | 1 + crates/wallet-data/src/substrate/wormhole.rs | 234 +++++++++++++++++++ crates/wallet-entities/src/lib.rs | 27 +++ doc/threat-model.md | 157 +++++++++++++ ui/src/api/generated/WormholeSummary.ts | 31 +++ ui/src/api/wallet.ts | 8 + ui/src/components/WormholePanel.tsx | 99 ++++++++ ui/src/routes/Accounts.tsx | 2 + 16 files changed, 1102 insertions(+), 9 deletions(-) create mode 100644 crates/wallet-data/src/keys/wormhole.rs create mode 100644 crates/wallet-data/src/substrate/wormhole.rs create mode 100644 doc/threat-model.md create mode 100644 ui/src/api/generated/WormholeSummary.ts create mode 100644 ui/src/components/WormholePanel.tsx diff --git a/crates/wallet-app/src/commands.rs b/crates/wallet-app/src/commands.rs index 9965267..5e0801a 100644 --- a/crates/wallet-app/src/commands.rs +++ b/crates/wallet-app/src/commands.rs @@ -8,7 +8,7 @@ use std::time::Instant; -use wallet_core::ports::HistoryError; +use wallet_core::ports::{HistoryError, ShieldedLeaf}; use wallet_core::{ChainAdapter, SignedBy, TransferIntent, TxStatus}; use wallet_data::substrate::SubstrateAdapter; use wallet_entities::Settings; @@ -24,7 +24,7 @@ use wallet_data::substrate::{ChainManager, account_info}; use wallet_entities::{ AccountBalance, AccountRef, Amount, AssetId, AssetKind, ChainId, ChainProfile, ChainStatus, HighSecurityInfo, HistoryCursor, HistoryPage, PendingReversible, PreparedTransferInfo, - SessionAccountInfo, SessionStatusInfo, TxStage, TxStatusInfo, + SessionAccountInfo, SessionStatusInfo, TxStage, TxStatusInfo, WormholeSummary, }; use wallet_entities::{ AppInfo, CreationChallenge, CreationStart, SignatureScheme, WalletError, WalletSummary, @@ -760,3 +760,104 @@ pub async fn history_page( /// Rows per page when serving history from the cache. const HISTORY_CACHE_PAGE: usize = 50; + +/// The open wallet's wormhole address on `chain` and its shielded balance: +/// the zk-tree scanned for leaves paying it (resumed from the cache), each +/// unspent one checked against `UsedNullifiers`. The secret is derived from +/// the session's seed for the call and dropped with it. +#[tauri::command] +pub async fn wormhole_summary( + state: State<'_, AppState>, + chain: ChainId, +) -> Result { + use wallet_data::keys::wormhole::{WormholeKeys, nullifier}; + use wallet_data::substrate::wormhole as zk; + + let profile = state + .profiles + .get(&chain) + .cloned() + .ok_or_else(|| WalletError::NotFound(format!("chain {}", chain.0)))?; + let keys = state + .session + .with_seed(|seed| WormholeKeys::derive(seed, 0)) + .map_err(session_error)? + .ok_or_else(|| { + WalletError::Refused("this wallet was opened without its seed; a cold or seedless wallet has no wormhole".into()) + })? + .map_err(|e| WalletError::Internal(e.to_string()))?; + let address = keys.address(profile.ss58_prefix); + let account = keys.account_id(); + + let manager = state + .chains + .lock() + .map_err(|_| WalletError::Internal("state poisoned".into()))? + .get(&chain) + .cloned() + .ok_or_else(|| WalletError::Internal(format!("{} not connected", chain.0)))?; + let conn = manager.connection().map_err(chain_error)?; + let cache = state.shielded_cache.clone(); + + // Scan what was appended since the last look. + let leaf_count = zk::leaf_count(&conn).await.map_err(chain_error)?; + let scanned_to = cache + .scanned_to(&chain, &address) + .map_err(|e| WalletError::Internal(e.to_string()))?; + if leaf_count > scanned_to { + let found = zk::leaves_paying(&conn, &account, scanned_to, leaf_count) + .await + .map_err(chain_error)?; + let rows: Vec = found + .iter() + .map(|l| ShieldedLeaf { + index: l.index, + transfer_count: l.transfer_count, + amount: l.amount, + spent: false, + }) + .collect(); + cache + .record(&chain, &address, &rows, leaf_count) + .map_err(|e| WalletError::Internal(e.to_string()))?; + } + + // Which of the unspent ones have been spent since. + let leaves = cache + .leaves(&chain, &address) + .map_err(|e| WalletError::Internal(e.to_string()))?; + let mut newly_spent = Vec::new(); + for l in leaves.iter().filter(|l| !l.spent) { + let n = nullifier(keys.secret(), l.transfer_count) + .map_err(|e| WalletError::Internal(e.to_string()))?; + if zk::nullifier_spent(&conn, &n).await.map_err(chain_error)? { + newly_spent.push(l.index); + } + } + if !newly_spent.is_empty() { + cache + .mark_spent(&chain, &address, &newly_spent) + .map_err(|e| WalletError::Internal(e.to_string()))?; + } + let leaves = cache + .leaves(&chain, &address) + .map_err(|e| WalletError::Internal(e.to_string()))?; + let unspent: Vec<_> = leaves.iter().filter(|l| !l.spent).collect(); + Ok(WormholeSummary { + chain, + address, + inner_hash: keys.inner_hash_hex(), + shielded: Amount::new( + unspent.iter().map(|l| l.amount).sum(), + profile.token_decimals, + ), + received: Amount::new( + leaves.iter().map(|l| l.amount).sum(), + profile.token_decimals, + ), + leaves: leaves.len() as u64, + unspent: unspent.len() as u64, + scanned_to: leaf_count, + leaf_count, + }) +} diff --git a/crates/wallet-app/src/lib.rs b/crates/wallet-app/src/lib.rs index 722496a..b207397 100644 --- a/crates/wallet-app/src/lib.rs +++ b/crates/wallet-app/src/lib.rs @@ -68,6 +68,7 @@ pub fn run() { commands::settings_get, commands::settings_set, commands::history_page, + commands::wormhole_summary, ]) .run(tauri::generate_context!()) .expect("the Tauri runtime failed to start"); diff --git a/crates/wallet-app/src/state.rs b/crates/wallet-app/src/state.rs index afa4828..055c5e7 100644 --- a/crates/wallet-app/src/state.rs +++ b/crates/wallet-app/src/state.rs @@ -9,7 +9,7 @@ use std::time::Duration; use wallet_core::PreparedTransaction; use wallet_core::backup::BackupChallenge; -use wallet_core::ports::{HistoryCache, HistorySource}; +use wallet_core::ports::{HistoryCache, HistorySource, ShieldedCache}; use wallet_core::profiles::ProfileRegistry; use wallet_core::session::Session; use wallet_data::history::{ObserverHistory, SqliteHistoryCache}; @@ -48,6 +48,8 @@ pub struct AppState { /// Where history comes from and where it is kept for offline reading. pub history_source: Arc, pub history_cache: Arc, + /// The same SQLite file, for the zk-tree scan of the wormhole address. + pub shielded_cache: Arc, } impl AppState { @@ -87,6 +89,10 @@ impl AppState { profiles = profiles.with_override(&text)?; tracing::info!(path = %override_path.display(), "applied profile overrides"); } + let cache = Arc::new( + SqliteHistoryCache::open(&data_dir.join("history.sqlite")) + .map_err(|e| anyhow::anyhow!("history cache: {e}"))?, + ); Ok(Self { keystore: FileKeystore::in_dir(dir), pending_creation: Mutex::new(None), @@ -97,10 +103,8 @@ impl AppState { prepared: Mutex::new(HashMap::new()), settings, history_source: Arc::new(ObserverHistory::new()), - history_cache: Arc::new( - SqliteHistoryCache::open(&data_dir.join("history.sqlite")) - .map_err(|e| anyhow::anyhow!("history cache: {e}"))?, - ), + history_cache: cache.clone(), + shielded_cache: cache, }) } } diff --git a/crates/wallet-core/src/ports.rs b/crates/wallet-core/src/ports.rs index 5331092..fcb2348 100644 --- a/crates/wallet-core/src/ports.rs +++ b/crates/wallet-core/src/ports.rs @@ -393,3 +393,44 @@ pub trait HistoryCache: Send + Sync { address: &str, ) -> Result)>, HistoryError>; } + +/// One zk-tree leaf that pays a wormhole address, as the cache keeps it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ShieldedLeaf { + pub index: u64, + pub transfer_count: u64, + pub amount: u128, + pub spent: bool, +} + +/// The local record of a wormhole address's leaves, so the scan resumes +/// where it stopped rather than from leaf zero on every open. +pub trait ShieldedCache: Send + Sync { + /// The leaf index the scan has reached for this address (exclusive). + fn scanned_to( + &self, + chain: &wallet_entities::ChainId, + address: &str, + ) -> Result; + /// Record leaves found and the new scan frontier, atomically. + fn record( + &self, + chain: &wallet_entities::ChainId, + address: &str, + leaves: &[ShieldedLeaf], + scanned_to: u64, + ) -> Result<(), HistoryError>; + /// Every leaf kept for the address, oldest first. + fn leaves( + &self, + chain: &wallet_entities::ChainId, + address: &str, + ) -> Result, HistoryError>; + /// Mark leaves spent once their nullifiers are seen on chain. + fn mark_spent( + &self, + chain: &wallet_entities::ChainId, + address: &str, + indexes: &[u64], + ) -> Result<(), HistoryError>; +} diff --git a/crates/wallet-data/src/history/cache.rs b/crates/wallet-data/src/history/cache.rs index a9e470c..bc704cb 100644 --- a/crates/wallet-data/src/history/cache.rs +++ b/crates/wallet-data/src/history/cache.rs @@ -8,7 +8,7 @@ use rusqlite::{Connection, OptionalExtension, params}; use std::path::Path; use std::sync::Mutex; -use wallet_core::ports::{HistoryCache, HistoryError}; +use wallet_core::ports::{HistoryCache, HistoryError, ShieldedCache, ShieldedLeaf}; use wallet_entities::{ChainId, HistoryCursor, HistoryEntry, HistorySource}; pub struct SqliteHistoryCache { @@ -27,6 +27,21 @@ CREATE TABLE IF NOT EXISTS history ( kind TEXT NOT NULL, PRIMARY KEY (chain, address, height, source, idx) ); +CREATE TABLE IF NOT EXISTS shielded_leaves ( + chain TEXT NOT NULL, + address TEXT NOT NULL, + idx INTEGER NOT NULL, + transfer_count INTEGER NOT NULL, + amount TEXT NOT NULL, + spent INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (chain, address, idx) +); +CREATE TABLE IF NOT EXISTS shielded_scans ( + chain TEXT NOT NULL, + address TEXT NOT NULL, + scanned_to INTEGER NOT NULL, + PRIMARY KEY (chain, address) +); CREATE TABLE IF NOT EXISTS fetches ( chain TEXT NOT NULL, address TEXT NOT NULL, @@ -210,6 +225,109 @@ impl HistoryCache for SqliteHistoryCache { } } +impl ShieldedCache for SqliteHistoryCache { + fn scanned_to(&self, chain: &ChainId, address: &str) -> Result { + self.conn()? + .query_row( + "SELECT scanned_to FROM shielded_scans WHERE chain = ?1 AND address = ?2", + params![chain.0, address], + |r| r.get::<_, i64>(0), + ) + .optional() + .map(|v| v.unwrap_or(0) as u64) + .map_err(db) + } + + fn record( + &self, + chain: &ChainId, + address: &str, + leaves: &[ShieldedLeaf], + scanned_to: u64, + ) -> Result<(), HistoryError> { + let mut conn = self.conn()?; + let tx = conn.transaction().map_err(db)?; + { + let mut stmt = tx + .prepare( + "INSERT OR IGNORE INTO shielded_leaves + (chain, address, idx, transfer_count, amount, spent) + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + ) + .map_err(db)?; + for l in leaves { + stmt.execute(params![ + chain.0, + address, + l.index as i64, + l.transfer_count as i64, + l.amount.to_string(), + i64::from(l.spent), + ]) + .map_err(db)?; + } + } + tx.execute( + "INSERT INTO shielded_scans (chain, address, scanned_to) VALUES (?1, ?2, ?3) + ON CONFLICT (chain, address) DO UPDATE SET scanned_to = excluded.scanned_to", + params![chain.0, address, scanned_to as i64], + ) + .map_err(db)?; + tx.commit().map_err(db) + } + + fn leaves(&self, chain: &ChainId, address: &str) -> Result, HistoryError> { + let conn = self.conn()?; + let mut stmt = conn + .prepare( + "SELECT idx, transfer_count, amount, spent FROM shielded_leaves + WHERE chain = ?1 AND address = ?2 ORDER BY idx", + ) + .map_err(db)?; + let rows = stmt + .query_map(params![chain.0, address], |r| { + Ok(( + r.get::<_, i64>(0)?, + r.get::<_, i64>(1)?, + r.get::<_, String>(2)?, + r.get::<_, i64>(3)?, + )) + }) + .map_err(db)?; + let mut out = Vec::new(); + for row in rows { + let (index, transfer_count, amount, spent) = row.map_err(db)?; + out.push(ShieldedLeaf { + index: index as u64, + transfer_count: transfer_count as u64, + amount: amount + .parse() + .map_err(|e| HistoryError::Other(format!("cached leaf amount: {e}")))?, + spent: spent != 0, + }); + } + Ok(out) + } + + fn mark_spent( + &self, + chain: &ChainId, + address: &str, + indexes: &[u64], + ) -> Result<(), HistoryError> { + let mut conn = self.conn()?; + let tx = conn.transaction().map_err(db)?; + for i in indexes { + tx.execute( + "UPDATE shielded_leaves SET spent = 1 WHERE chain = ?1 AND address = ?2 AND idx = ?3", + params![chain.0, address, *i as i64], + ) + .map_err(db)?; + } + tx.commit().map_err(db) + } +} + #[cfg(test)] mod tests { use super::*; @@ -304,4 +422,33 @@ mod tests { 1 ); } + + #[test] + fn shielded_leaves_resume_where_the_scan_stopped_and_remember_spends() { + let c = SqliteHistoryCache::in_memory().unwrap(); + let chain = ChainId("quantus".into()); + assert_eq!(c.scanned_to(&chain, "qzw").unwrap(), 0); + let found = vec![ + ShieldedLeaf { + index: 5, + transfer_count: 1, + amount: 310_000_000_000, + spent: false, + }, + ShieldedLeaf { + index: 9, + transfer_count: 2, + amount: 310_000_000_000, + spent: false, + }, + ]; + c.record(&chain, "qzw", &found, 100).unwrap(); + c.record(&chain, "qzw", &found, 120).unwrap(); + assert_eq!(c.scanned_to(&chain, "qzw").unwrap(), 120); + assert_eq!(c.leaves(&chain, "qzw").unwrap(), found); + c.mark_spent(&chain, "qzw", &[9]).unwrap(); + let after = c.leaves(&chain, "qzw").unwrap(); + assert!(!after[0].spent && after[1].spent); + assert!(c.leaves(&chain, "qzother").unwrap().is_empty()); + } } diff --git a/crates/wallet-data/src/keys/mod.rs b/crates/wallet-data/src/keys/mod.rs index 542d854..8fa823a 100644 --- a/crates/wallet-data/src/keys/mod.rs +++ b/crates/wallet-data/src/keys/mod.rs @@ -8,6 +8,8 @@ //! and the convention the other Quantus wallets follow is index `0'` for 87 //! and `1'` for 65 under the same account. +pub mod wormhole; + use qp_rusty_crystals_dilithium::{SensitiveBytes32, SensitiveBytes64, ml_dsa_65, ml_dsa_87}; use qp_rusty_crystals_hdwallet as hd; use wallet_core::{KeyError, KeyScheme, Seed, Signer}; diff --git a/crates/wallet-data/src/keys/wormhole.rs b/crates/wallet-data/src/keys/wormhole.rs new file mode 100644 index 0000000..10187e3 --- /dev/null +++ b/crates/wallet-data/src/keys/wormhole.rs @@ -0,0 +1,201 @@ +//! The wormhole keys behind a mining address (blackbeard/wallet #45). +//! +//! A miner's rewards go to a wormhole address: the double Poseidon2 hash of +//! a salted secret derived from the seed at its own coin type. The single +//! hash (the "inner hash", what `quantus-node key quantus --scheme wormhole` +//! prints and what `--rewards-inner-hash` takes) is the preimage that every +//! block the miner authors carries in its PreRuntime digest; the address is +//! its rehash. The secret never leaves this module's holder. + +use qp_rusty_crystals_dilithium::{SensitiveBytes32, SensitiveBytes64}; +use qp_rusty_crystals_hdwallet as hd; +use wallet_core::Seed; +use wallet_core::ss58; + +use super::KeyError; + +/// The wormhole coin type, mirrored from the hdwallet crate: not 189189'. +pub const WORMHOLE_COIN_TYPE: &str = hd::QUANTUS_WORMHOLE_CHAIN_ID; + +/// The path the node derives a mining address at for `--wallet-index`. +pub fn wormhole_path(index: u32) -> String { + format!("m/44'/{WORMHOLE_COIN_TYPE}/{index}'/0'/0'") +} + +/// What one derivation yields. The secret is move-only and wiped on drop. +pub struct WormholeKeys { + address: [u8; 32], + inner_hash: [u8; 32], + secret: SensitiveBytes32, +} + +impl std::fmt::Debug for WormholeKeys { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("WormholeKeys") + .field("address", &hex::encode(self.address)) + .field("inner_hash", &"") + .field("secret", &"") + .finish() + } +} + +impl WormholeKeys { + /// Derive from the wallet's seed at `wormhole_path(index)`. + pub fn derive(seed: &Seed, index: u32) -> Result { + let mut holder = SensitiveBytes64::zeroed(); + holder.as_mut_bytes().copy_from_slice(seed.as_bytes()); + let pair = hd::generate_wormhole_from_seed(&holder, &wormhole_path(index)) + .map_err(|e| KeyError::Derivation(e.to_string()))?; + let mut secret = SensitiveBytes32::zeroed(); + secret + .as_mut_bytes() + .copy_from_slice(pair.secret().as_bytes()); + Ok(Self { + address: *pair.address(), + inner_hash: *pair.first_hash(), + secret, + }) + } + + /// The 32-byte account id the rewards land on. + pub fn account_id(&self) -> [u8; 32] { + self.address + } + + pub fn address(&self, ss58_prefix: u16) -> String { + ss58::encode(ss58_prefix, &self.address) + } + + /// The preimage in block headers, `0x` hex, as the node prints it. + pub fn inner_hash_hex(&self) -> String { + format!("0x{}", hex::encode(self.inner_hash)) + } + + pub fn inner_hash(&self) -> &[u8; 32] { + &self.inner_hash + } + + /// Borrow the secret for a proof or a nullifier; the keys keep it. + pub fn secret(&self) -> &[u8; 32] { + self.secret.as_bytes() + } +} + +/// The nullifier that spends the leaf paid to this secret's address with +/// `transfer_count`: what `Wormhole::UsedNullifiers` records once a proof +/// with it has been accepted. Mirrors the circuit crate's +/// `Nullifier::from_preimage`, preimage `"~nullif~" ‖ secret ‖ count` as +/// field elements, hashed twice, using the same Poseidon2 core rather than +/// the circuit crate and its prover behind it. +pub fn nullifier(secret: &[u8; 32], transfer_count: u64) -> Result<[u8; 32], KeyError> { + use qp_poseidon_core::serialization::{bytes_to_digest, string_to_felts, u64_to_felts}; + let mut felts = string_to_felts("~nullif~"); + felts.extend(bytes_to_digest(secret).map_err(|e| KeyError::Derivation(e.to_owned()))?); + felts.extend(u64_to_felts(transfer_count)); + Ok(qp_poseidon_core::hash_twice(&felts)) +} + +/// The address behind an inner hash seen in a block header, as the chain +/// and the observer derive it: one more Poseidon2 over the 32 bytes. +pub fn address_of_inner_hash(inner_hash: &[u8; 32]) -> Result<[u8; 32], KeyError> { + qp_poseidon_core::rehash_to_bytes(inner_hash).map_err(|e| KeyError::Derivation(e.to_owned())) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::keys::seed_from_mnemonic; + + const DEV_PHRASE: &str = + "bottom drive obey lake curtain smoke basket hold race lonely fit walk"; + + /// From `quantus-node key quantus --scheme wormhole --words` (v1.0.1) + /// with the public dev phrase. + #[test] + fn matches_the_node_at_index_zero_and_one() { + let seed = seed_from_mnemonic(DEV_PHRASE, None).unwrap(); + let k0 = WormholeKeys::derive(&seed, 0).unwrap(); + assert_eq!(wormhole_path(0), "m/44'/189189189'/0'/0'/0'"); + assert_eq!( + k0.address(189), + "qzq32vN2ZtX2T9YrvCh69CMvbC2nWmEq4tCjRWKg4CyuxeShg" + ); + assert_eq!( + k0.inner_hash_hex(), + "0x5a5891e7aa059a60dc2de31ac6f239c6c6a93b625e59864cd78b73d6dd019454" + ); + let k1 = WormholeKeys::derive(&seed, 1).unwrap(); + assert_eq!( + k1.address(189), + "qzjhN4GxuWAGrFvHK1Yy7YEYKJzGUKsPCo2VYceTzQGLeXKD2" + ); + assert_eq!( + k1.inner_hash_hex(), + "0x1628dba9f5099c6c30813a3c9629f0b96ec89da30a8417bfbcbe781e0255d605" + ); + // The address is the rehash of the inner hash, as the chain derives + // it from a block header. + assert_eq!( + address_of_inner_hash(k0.inner_hash()).unwrap(), + k0.account_id() + ); + assert!( + !format!("{k0:?}").contains("5a5891e7"), + "debug leaks the inner hash" + ); + } + + /// Vectors from qp-wormhole-circuit 4.3.0's `Nullifier::from_preimage`, + /// produced once with the circuit crate so this Poseidon-only mirror is + /// pinned to what the chain checks against `UsedNullifiers`. + #[test] + fn the_nullifier_matches_the_circuit_crate() { + let vectors = [ + ( + "0000000000000000000000000000000000000000000000000000000000000001", + 0u64, + "5f17023a0f12652c193ecdacb23a5d0bd88e5f44f090d25f71f7701b89d292b0", + ), + ( + "0000000000000000000000000000000000000000000000000000000000000001", + 1, + "7fcd0ad813ef1ab524c1033254caef66f123087133aee3963125eb3508d20ec6", + ), + ( + "5a5891e7aa059a60dc2de31ac6f239c6c6a93b625e59864cd78b73d6dd019454", + 30, + "d47fe304021ba7bf7ec3e4a3e8067111b3f0a7aad84f17820f348685ed92da99", + ), + ( + "0102030405060708091011121314151617181920212223242526272829303132", + 4_294_967_297, + "fdfd1a545e6dc2352913dd6647a2504b3a2259946eb9947c82ea0d19504e6010", + ), + ]; + for (secret_hex, count, expected) in vectors { + let mut secret = [0u8; 32]; + hex::decode_to_slice(secret_hex, &mut secret).unwrap(); + assert_eq!( + hex::encode(nullifier(&secret, count).unwrap()), + expected, + "secret {secret_hex} count {count}" + ); + } + } + + /// bob's inner hash from lair/quantus's deploy and its address as the + /// observer shows it at /v1/chains/quantus/miners/{inner_hash}. + #[test] + fn derives_a_known_miners_address_from_its_published_inner_hash() { + let mut inner = [0u8; 32]; + hex::decode_to_slice( + "134e73f06fa9bdb1dbfa909e149c563f5860ceb71a0e7307918f7033970edf59", + &mut inner, + ) + .unwrap(); + assert_eq!( + ss58::encode(189, &address_of_inner_hash(&inner).unwrap()), + "qzp2AxZw8szXc5qDe1aU8P5kt1dkf1JFYktvV7N6cVbFZrs8L" + ); + } +} diff --git a/crates/wallet-data/src/substrate/adapter.rs b/crates/wallet-data/src/substrate/adapter.rs index 7183e0b..a03ae01 100644 --- a/crates/wallet-data/src/substrate/adapter.rs +++ b/crates/wallet-data/src/substrate/adapter.rs @@ -382,7 +382,7 @@ fn collect_32_byte_arrays(v: &Value, out: &mut Vec<[u8; 32]>) { } /// A 32-byte account id shows up as a composite of 32 small integers. -fn as_account_bytes(v: &Value) -> Option<[u8; 32]> { +pub(crate) fn as_account_bytes(v: &Value) -> Option<[u8; 32]> { let ValueDef::Composite(c) = &v.value else { return None; }; @@ -728,6 +728,43 @@ mod tests { ); } + /// Prints the zk-tree and wormhole storage shapes from mainnet + /// metadata; a scratch probe for #45, kept because the shapes matter. + #[test] + fn zk_tree_and_wormhole_storage_shapes() { + let state = mainnet_state(); + let md = &state.metadata; + for (pallet, entry) in [ + ("ZkTree", "Leaves"), + ("ZkTree", "LeafCount"), + ("Wormhole", "UsedNullifiers"), + ("Wormhole", "TransferCount"), + ] { + let p = md.pallet_by_name(pallet).unwrap(); + let e = p.storage().unwrap().entry_by_name(entry).unwrap(); + let describe = |id: u32| { + let t = md.types().resolve(id).unwrap(); + format!("{:?} {:?}", t.path.segments, t.type_def) + }; + match e.entry_type() { + subxt::metadata::types::StorageEntryType::Plain(v) => { + eprintln!("{pallet}::{entry}: plain -> {}", describe(*v)); + } + subxt::metadata::types::StorageEntryType::Map { + hashers, + key_ty, + value_ty, + } => { + eprintln!( + "{pallet}::{entry}: map hashers={hashers:?} key={} value={}", + describe(*key_ty), + describe(*value_ty) + ); + } + } + } + } + #[tokio::test] async fn decodes_a_transfer_call_into_a_summary_a_person_can_check() { let a = adapter_for("quantus"); diff --git a/crates/wallet-data/src/substrate/mod.rs b/crates/wallet-data/src/substrate/mod.rs index a311c71..985c3e4 100644 --- a/crates/wallet-data/src/substrate/mod.rs +++ b/crates/wallet-data/src/substrate/mod.rs @@ -8,6 +8,7 @@ pub mod adapter; pub mod config; pub mod connection; pub mod offline; +pub mod wormhole; pub use accounts::{AccountInfo, Balance, HighSecurity, account_info, next_nonce}; pub use adapter::SubstrateAdapter; diff --git a/crates/wallet-data/src/substrate/wormhole.rs b/crates/wallet-data/src/substrate/wormhole.rs new file mode 100644 index 0000000..bf84cd8 --- /dev/null +++ b/crates/wallet-data/src/substrate/wormhole.rs @@ -0,0 +1,234 @@ +//! The shielded side of an account: the zk-tree leaves that pay a wormhole +//! address, and whether each has been spent (blackbeard/wallet #45). +//! +//! Every deposit into the wormhole, mining rewards included, is a leaf in +//! `ZkTree::Leaves`: a map from leaf index to `{to, transfer_count, +//! asset_id, amount}`, in the clear. Nothing on chain indexes leaves by +//! recipient, so the wallet scans the map once from index 0 to +//! `LeafCount`, keeps what pays its address, and afterwards scans only what +//! was appended. A leaf is spent when its nullifier, which only the holder +//! of the secret can compute, is in `Wormhole::UsedNullifiers`. + +use subxt::dynamic; +use subxt::ext::scale_value::{self, At, Value}; +use wallet_core::ChainError; +use wallet_entities::Amount; + +use super::adapter::as_account_bytes; +use super::connection::ChainConnection; + +/// One leaf that pays the address being scanned. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Leaf { + pub index: u64, + pub transfer_count: u64, + pub amount: u128, +} + +/// How many leaves to read per RPC round trip. Keys are computed locally +/// (Identity hasher over a u64), so a page is one `state_queryStorageAt`. +pub const LEAF_PAGE: u64 = 500; + +/// `ZkTree::LeafCount` at the best block. +pub async fn leaf_count(conn: &ChainConnection) -> Result { + let addr = dynamic::storage("ZkTree", "LeafCount", Vec::::new()); + let v = conn + .client + .storage() + .at_latest() + .await + .map_err(|e| ChainError::Rpc(e.to_string()))? + .fetch(&addr) + .await + .map_err(|e| ChainError::Rpc(e.to_string()))?; + let Some(v) = v else { return Ok(0) }; + let v = v.to_value().map_err(|e| ChainError::Other(e.to_string()))?; + v.as_u128() + .map(|n| n as u64) + .ok_or_else(|| ChainError::Other("LeafCount is not a number".into())) +} + +/// The leaves in `[from, to)` that pay `account`, read in pages. +pub async fn leaves_paying( + conn: &ChainConnection, + account: &[u8; 32], + from: u64, + to: u64, +) -> Result, ChainError> { + let mut out = Vec::new(); + let mut start = from; + while start < to { + let end = (start + LEAF_PAGE).min(to); + let keys: Vec> = (start..end) + .map(|i| { + let addr = dynamic::storage("ZkTree", "Leaves", vec![Value::u128(u128::from(i))]); + conn.client + .storage() + .address_bytes(&addr) + .map_err(|e| ChainError::Other(e.to_string())) + }) + .collect::>()?; + let sets = conn + .rpc + .state_query_storage_at(keys.iter().map(Vec::as_slice), None) + .await + .map_err(|e| ChainError::Rpc(e.to_string()))?; + let leaf_ty = leaf_type_id(conn)?; + for set in sets { + for (key, data) in set.changes { + let Some(data) = data else { continue }; + let index = leaf_index_from_key(&key.0) + .ok_or_else(|| ChainError::Other("a zk-tree key without an index".into()))?; + let value = scale_value::scale::decode_as_type( + &mut &data.0[..], + leaf_ty, + conn.client.metadata().types(), + ) + .map_err(|e| ChainError::Other(format!("leaf {index}: {e}")))?; + let to = value + .at("to") + .and_then(as_account_bytes) + .ok_or_else(|| ChainError::Other(format!("leaf {index}: no recipient")))?; + if &to != account { + continue; + } + let transfer_count = value + .at("transfer_count") + .and_then(|v| v.as_u128()) + .ok_or_else(|| ChainError::Other(format!("leaf {index}: no count")))? + as u64; + let amount = value + .at("amount") + .and_then(|v| v.as_u128()) + .ok_or_else(|| ChainError::Other(format!("leaf {index}: no amount")))?; + out.push(Leaf { + index, + transfer_count, + amount, + }); + } + } + start = end; + } + out.sort_by_key(|l| l.index); + Ok(out) +} + +/// Whether a nullifier is in `Wormhole::UsedNullifiers`: the leaf it +/// belongs to has been spent. +pub async fn nullifier_spent( + conn: &ChainConnection, + nullifier: &[u8; 32], +) -> Result { + let addr = dynamic::storage( + "Wormhole", + "UsedNullifiers", + vec![Value::from_bytes(nullifier)], + ); + let v = conn + .client + .storage() + .at_latest() + .await + .map_err(|e| ChainError::Rpc(e.to_string()))? + .fetch(&addr) + .await + .map_err(|e| ChainError::Rpc(e.to_string()))?; + Ok(match v { + None => false, + Some(v) => v + .to_value() + .map_err(|e| ChainError::Other(e.to_string()))? + .as_bool() + .unwrap_or(true), + }) +} + +/// The shielded balance: unspent leaves summed, as base units. +pub fn sum_unspent<'a>(leaves: impl Iterator, decimals: u8) -> Amount { + Amount::new(leaves.map(|l| l.amount).sum(), decimals) +} + +fn leaf_type_id(conn: &ChainConnection) -> Result { + let md = conn.client.metadata(); + let pallet = md + .pallet_by_name("ZkTree") + .ok_or_else(|| ChainError::Unsupported("no ZkTree pallet".into()))?; + let entry = pallet + .storage() + .and_then(|s| s.entry_by_name("Leaves")) + .ok_or_else(|| ChainError::Unsupported("no ZkTree::Leaves".into()))?; + match entry.entry_type() { + subxt::metadata::types::StorageEntryType::Map { value_ty, .. } => Ok(*value_ty), + _ => Err(ChainError::Unsupported( + "ZkTree::Leaves is not a map".into(), + )), + } +} + +/// The Identity-hashed u64 at the end of a `ZkTree::Leaves` key. +fn leaf_index_from_key(key: &[u8]) -> Option { + let tail = key.get(key.len().checked_sub(8)?..)?; + Some(u64::from_le_bytes(tail.try_into().ok()?)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_leaf_index_is_the_last_eight_bytes_of_the_key() { + let mut key = vec![0xaa; 32]; + key.extend_from_slice(&91928u64.to_le_bytes()); + assert_eq!(leaf_index_from_key(&key), Some(91928)); + assert_eq!(leaf_index_from_key(&[1, 2, 3]), None); + } + + /// bob's wormhole account on mainnet, through the public endpoint: leaf + /// 91928 is its thirtieth reward (observer: transfer_count 30, 0.31 QTC). + #[tokio::test] + #[ignore = "reaches wss://quantus.blackbeard.observer"] + async fn reads_a_known_miners_leaf_from_mainnet() { + let profiles = wallet_core::profiles::ProfileRegistry::shipped().unwrap(); + let quantus = profiles + .get(&wallet_entities::ChainId("quantus".into())) + .unwrap() + .clone(); + let manager = super::super::ChainManager::start(quantus); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60); + while !manager.status().connected && std::time::Instant::now() < deadline { + tokio::time::sleep(std::time::Duration::from_millis(250)).await; + } + let conn = manager.connection().expect("connected"); + let count = leaf_count(&conn).await.unwrap(); + assert!(count > 91928, "leaf count {count}"); + let (_, bob) = + wallet_core::ss58::decode("qzp2AxZw8szXc5qDe1aU8P5kt1dkf1JFYktvV7N6cVbFZrs8L").unwrap(); + let leaves = leaves_paying(&conn, &bob, 91900, 91930).await.unwrap(); + let l = leaves + .iter() + .find(|l| l.index == 91928) + .expect("leaf 91928 pays bob"); + assert_eq!((l.transfer_count, l.amount), (30, 310_000_000_000)); + // A nullifier nobody has used. + assert!(!nullifier_spent(&conn, &[7u8; 32]).await.unwrap()); + eprintln!("leaf count {count}; bob's leaves in the window: {leaves:?}"); + + // The whole tree: every reward bob's miner earned is a leaf, so the + // sum is the observer's attribution for the preimage + // (/v1/chains/quantus/accounts/qzp2AxZw…: rewards.total on + // 2026-09-16 was 9330000000000 over 30 rewards) plus anything sent + // to the address since. Timed, since it is what the first open pays. + let t0 = std::time::Instant::now(); + let all = leaves_paying(&conn, &bob, 0, count).await.unwrap(); + let total: u128 = all.iter().map(|l| l.amount).sum(); + eprintln!( + "full scan of {count} leaves in {:?}: {} leaves pay bob, {} base units", + t0.elapsed(), + all.len(), + total + ); + assert!(all.len() >= 30, "{} leaves", all.len()); + assert!(total >= 9_330_000_000_000, "{total}"); + } +} diff --git a/crates/wallet-entities/src/lib.rs b/crates/wallet-entities/src/lib.rs index 4fd625e..e11fdd2 100644 --- a/crates/wallet-entities/src/lib.rs +++ b/crates/wallet-entities/src/lib.rs @@ -640,3 +640,30 @@ pub struct HistoryPage { #[ts(type = "number | null")] pub fetched_at_ms: Option, } + +/// The shielded side of the open wallet on one chain: the wormhole address +/// its mining rewards land on, the preimage a miner configures, and what +/// the zk-tree holds for it (blackbeard/wallet #45). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[ts(export)] +pub struct WormholeSummary { + pub chain: ChainId, + /// The wormhole address, SS58: where `MiningRewards` pays this wallet. + pub address: String, + /// The preimage in block headers, `0x` hex; what `--rewards-inner-hash` + /// takes and what blackbeard.observer keys a miner by. + pub inner_hash: String, + /// Unspent leaves summed: the shielded balance. + pub shielded: Amount, + /// Every leaf that paid the address, spent or not, summed. + pub received: Amount, + #[ts(type = "number")] + pub leaves: u64, + #[ts(type = "number")] + pub unspent: u64, + /// How far the zk-tree scan has reached, and how long it is. + #[ts(type = "number")] + pub scanned_to: u64, + #[ts(type = "number")] + pub leaf_count: u64, +} diff --git a/doc/threat-model.md b/doc/threat-model.md new file mode 100644 index 0000000..4c68e12 --- /dev/null +++ b/doc/threat-model.md @@ -0,0 +1,157 @@ +# threat model + +What the wallet protects, where the boundaries are, what a compromise on +each side of a boundary can and cannot do, and the mitigation for each, +named by the file that implements it. A mitigation that names a file that +no longer does that thing is a review failure (blackbeard/wallet #48), so +this document is edited with the code, not after it. + +## Assets + +| asset | where it lives | loss means | +|---|---|---| +| the seed (64 bytes from the mnemonic) | `wallet_core::Seed`, a heap holder wiped on drop, inside the session while unlocked | every account on every chain, and the wormhole secret | +| the mnemonic | shown once at creation, then only inside the keystore file's ciphertext | the seed | +| ML-DSA secret keys | derived from the seed per account; in the keystore ciphertext; in memory while unlocked | that account | +| the wormhole secret and inner hash | derived from the seed for a call, dropped with it | the shielded balance; the inner hash alone is public and spends nothing | +| the keystore password | typed on the lock screen, passed to Rust, never stored | the keystore file, if also stolen | +| balances, addresses, history | chain state and the local SQLite cache | privacy, not funds | +| what the person is about to sign | bytes in Rust, a summary in the webview | funds, if the two disagree | + +## Boundaries + +``` + person ─ webview (React, no secrets) ─ Tauri IPC ─ Rust (keys, chain, cache) + │ + ┌─────────────────────────────────────────┼───────────────┐ + our nodes over wss blackbeard.observer NEAR Intents (1Click) + (*.blackbeard.observer) (history, /v1) via the relay's JWT +``` + +### The webview + +Renders. It never holds a seed, a mnemonic, a secret key or a password +beyond the input it was typed into, and never decodes a transaction. + +- **Isolation pattern and CSP**: `crates/wallet-app/tauri.conf.json` sets + `security.pattern = isolation` (`crates/wallet-app/isolation/index.html`) + and a `default-src 'self'` CSP with no remote script source; the dev CSP + is separate and only for Vite. +- **One IPC file**: every command goes through `ui/src/api/wallet.ts`; the + CI gate in `.gitea/workflows/ci.yaml` ("rust talks to the webview through + one file") fails on any other import of `@tauri-apps/api/core`. +- **Capabilities with reasons**: `crates/wallet-app/capabilities/default.toml` + grants `core:default`, `dialog:allow-open`, `clipboard-manager:allow-write-text` + and `opener:allow-open-url` for two explorer hosts, each with a comment; + `crates/wallet-app/tests/capabilities.rs` fails the build on a permission + without one, a window other than `main`, or a capability without a + description. No `allow-all`, no read of the clipboard, no file system. +- **The phrase is shown once**: `wallet_create_begin` in + `crates/wallet-app/src/commands.rs` returns the words with a challenge; + `wallet_create_confirm` writes the keystore only when the challenge + passes and drops the phrase either way; no other command returns it. + +A compromised webview (an XSS through a chain-supplied string, say) can +call any registered command with any arguments: it can prepare and submit +a transfer the person did not ask for while the session is unlocked. It +cannot read the seed, a key or the password, cannot change what a prepared +transaction signs (submit takes an id), and cannot open a URL outside the +two allow-listed hosts. + +### Rust + +- **Seeds and keys are wiped**: `wallet_core::Seed` is + `Zeroizing>` (`crates/wallet-core/src/ports.rs`); ML-DSA + keypairs and the wormhole secret sit in the hdwallet crate's + `SensitiveBytes` holders (`crates/wallet-data/src/keys/mod.rs`, + `crates/wallet-data/src/keys/wormhole.rs`). The session test in + `crates/wallet-core/src/session.rs` scans this process's memory for the + seed pattern after lock. +- **Debug redacts**: `Signer` requires `Debug` and every implementation + prints scheme and address only (`crates/wallet-data/src/keys/mod.rs`, + `WormholeKeys` in `keys/wormhole.rs`). +- **Idle lock**: `Session::expire_if_idle` (`crates/wallet-core/src/session.rs`) + drops the seed and signers after `Settings.idle_lock_seconds`; the ticker + in `crates/wallet-app/src/lib.rs` fires it without a command. +- **Context-bound signing**: every extrinsic is signed under the FIPS 204 + context the chain expects from its spec version + (`wallet_core::profiles::signing_context_for`, + `crates/wallet-data/src/keys/mod.rs`); a signature under the empty + context is valid ML-DSA and refused by the chain, which the live test + `planck_accepts_the_signature_and_rejects_the_empty_context` in + `crates/wallet-data/src/substrate/offline.rs` pins. +- **What is signed is what is reviewed**: `transfer_prepare` builds the + bytes, decodes them through runtime metadata into a `CallSummary` + (`SubstrateAdapter::decode_call`, `crates/wallet-data/src/substrate/adapter.rs`) + and remembers them by id; `transfer_submit` signs the remembered bytes + and takes only the id (`crates/wallet-app/src/commands.rs`). Nothing the + form holds reaches the signature. +- **Addresses refused by name**: `parse_address` in the adapter and + `wallet_core::ss58::foreign_address_message` refuse a foreign SS58 + prefix naming its chain. +- **Keystore**: quantus-cli's format, Argon2id (m=19456, t=2, p=1) to an + AES-256-GCM key, files written atomically with mode 0600, symlinks + refused, and the address re-derived from the decrypted key on every + open and compared with the file's (`crates/wallet-data/src/keystore.rs`), + so a file whose ciphertext and address disagree is rejected rather than + opened as the wrong account. + +A compromised Rust process is the wallet: it has the seed while unlocked. +The mitigations above are about not leaving it around afterwards, and +about a person always seeing what they sign. + +### Our nodes + +`wss://quantus.blackbeard.observer` and `wss://planck.blackbeard.observer`, +proxied by nginx on oolon to our node on bob and to Quantus's Planck +hosts (Quantus-Network/blackbeard.observer `asset/nginx/`). Unauthenticated +by decision (#20): a node answers reads and accepts signed extrinsics, +nothing more. + +- **TLS**: rustls in subxt; the profile's endpoint list is `wss://` only + except the dev node (`crates/wallet-core/src/profiles.toml`); a + connection to the wrong chain is refused by genesis hash + (`wallet_core::profiles::genesis_matches`, + `crates/wallet-data/src/substrate/connection.rs`). +- **Mortality**: every extrinsic is mortal for 64 blocks from the best + block (`crates/wallet-data/src/substrate/adapter.rs`), so a captured + signature cannot be replayed later. + +A malicious or compromised node can lie about balances, delay or drop +transactions, and front-run what it sees. It cannot sign, cannot change +what was signed (the review is over local bytes), and cannot take funds: +the worst is a stale or false view, and the UI shows the finalized +height beside the best so the lag is visible. + +### The indexer + +`blackbeard.observer`'s `/v1` (`crates/wallet-data/src/history/observer.rs`) +supplies history and nothing else: no balance, no address the wallet +would send to, no amount it would sign. A compromised observer can show a +false history. It is cached locally (`crates/wallet-data/src/history/cache.rs`) +so the wallet reads with it down, and the cache is marked as such. + +### NEAR Intents + +`crates/wallet-data/src/swap/oneclick.rs` talks to 1Click for quotes and +status; the JWT comes from the relay at run time (#40), never from the +binary. A quote names a deposit address the person pays; the wallet maps +assets only through `crates/wallet-core/src/assets.toml` and refuses what +it cannot map, so a typo cannot become a wrong-chain deposit. A +compromised 1Click could give a bad deposit address; the swap screen (#41) +must show the quote's amounts and address for the person to compare, and +the relay is where a partner-signed quote would be verified. + +### The updater (planned, #35) + +`tauri-plugin-updater` with a minisign public key in `tauri.conf.json` +and the private key only in Gitea secrets; a manifest signed with any +other key is refused by the plugin, and an update is offered, never +applied silently. Until it lands there is no updater. + +## Out of scope + +A compromised operating system or a keylogger; a person who types their +phrase into something else; physical access to an unlocked machine within +the idle window. The idle lock and the once-only phrase narrow those, they +do not close them. diff --git a/ui/src/api/generated/WormholeSummary.ts b/ui/src/api/generated/WormholeSummary.ts new file mode 100644 index 0000000..c96cbe2 --- /dev/null +++ b/ui/src/api/generated/WormholeSummary.ts @@ -0,0 +1,31 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { Amount } from "./Amount"; +import type { ChainId } from "./ChainId"; + +/** + * The shielded side of the open wallet on one chain: the wormhole address + * its mining rewards land on, the preimage a miner configures, and what + * the zk-tree holds for it (blackbeard/wallet #45). + */ +export type WormholeSummary = { chain: ChainId, +/** + * The wormhole address, SS58: where `MiningRewards` pays this wallet. + */ +address: string, +/** + * The preimage in block headers, `0x` hex; what `--rewards-inner-hash` + * takes and what blackbeard.observer keys a miner by. + */ +inner_hash: string, +/** + * Unspent leaves summed: the shielded balance. + */ +shielded: Amount, +/** + * Every leaf that paid the address, spent or not, summed. + */ +received: Amount, leaves: number, unspent: number, +/** + * How far the zk-tree scan has reached, and how long it is. + */ +scanned_to: number, leaf_count: number, }; diff --git a/ui/src/api/wallet.ts b/ui/src/api/wallet.ts index 9b945fc..00d43d2 100644 --- a/ui/src/api/wallet.ts +++ b/ui/src/api/wallet.ts @@ -18,6 +18,7 @@ import type { HistoryCursor } from './generated/HistoryCursor' import type { HistoryPage } from './generated/HistoryPage' import type { SignatureScheme } from './generated/SignatureScheme' import type { WalletSummary } from './generated/WalletSummary' +import type { WormholeSummary } from './generated/WormholeSummary' import type { WalletError as WalletErrorShape } from './generated/WalletError' export class WalletError extends Error { @@ -190,3 +191,10 @@ export function historyPage( ): Promise { return command('history_page', { chain, address, before: before ?? null }) } + +// The wormhole address behind the wallet's mining rewards and what the +// zk-tree holds for it. Scans on the Rust side; can take a while the first +// time on a long tree. +export function wormholeSummary(chain: ChainId): Promise { + return command('wormhole_summary', { chain }) +} diff --git a/ui/src/components/WormholePanel.tsx b/ui/src/components/WormholePanel.tsx new file mode 100644 index 0000000..baea1bc --- /dev/null +++ b/ui/src/components/WormholePanel.tsx @@ -0,0 +1,99 @@ +import { useQuery } from '@tanstack/react-query' +import { copyText } from '../api/clipboard' +import type { ChainId } from '../api/generated/ChainId' +import type { ChainProfile } from '../api/generated/ChainProfile' +import { useChainStatus } from '../api/hooks' +import { wormholeSummary } from '../api/wallet' +import { formatAmount } from '../lib/format' + +/** + * The shielded side of the wallet on this chain: the wormhole address its + * mining rewards land on, the inner hash a miner configures, and the + * unspent leaves the zk-tree holds for it. Read from chain state through + * a scan that resumes from where it last stopped. + */ +export function WormholePanel({ chain, profile }: { chain: ChainId; profile: ChainProfile }) { + const status = useChainStatus(chain) + const summary = useQuery({ + queryKey: ['wormhole', chain], + queryFn: () => wormholeSummary(chain), + enabled: !!status.data?.connected, + staleTime: 60_000, + retry: 1, + }) + return ( +
+
+

Mining rewards (wormhole)

+ {summary.data && ( + + zk-tree scanned to leaf {summary.data.scanned_to} of{' '} + {summary.data.leaf_count} + + )} +
+
+ {!status.data?.connected &&

Waiting for the chain…

} + {summary.isPending && status.data?.connected && ( +

Scanning the zk-tree for leaves that pay this wallet…

+ )} + {summary.isError &&

{summary.error.message}

} + {summary.data && ( +
+
+ +
{summary.data.address}
+ + Where a miner run with this wallet's inner hash is paid. The same seed gives the + same address on every Quantus-family chain. + +
+
+ +
{summary.data.inner_hash}
+ + Hand this to the node as --rewards-inner-hash. It is public in every block the miner + authors; it cannot spend anything. + +
+ + + + + + + + + + + +
Shielded + {formatAmount(summary.data.shielded)} {profile.token_symbol} + in {summary.data.unspent} unspent leaves +
Received + {formatAmount(summary.data.received)} {profile.token_symbol} + in {summary.data.leaves} leaves +
+
+ + +
+
+ )} +
+
+ ) +} diff --git a/ui/src/routes/Accounts.tsx b/ui/src/routes/Accounts.tsx index 173bc3a..1d7ae1c 100644 --- a/ui/src/routes/Accounts.tsx +++ b/ui/src/routes/Accounts.tsx @@ -3,6 +3,7 @@ import { AccountName } from '../components/AccountName' import { ChainCaveat } from '../components/ChainCaveat' import { NetworkOverview } from '../components/NetworkOverview' import { PendingReversibleList } from '../components/PendingReversible' +import { WormholePanel } from '../components/WormholePanel' import { formatAmount } from '../lib/format' /** @@ -81,6 +82,7 @@ export function Accounts() { {chain && profile && unlocked && ( )} + {chain && profile && unlocked && } )