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 && } )