diff --git a/consensus/src/block/validity/block_program.rs b/consensus/src/block/validity/block_program.rs index 40998d9..55af9b7 100644 --- a/consensus/src/block/validity/block_program.rs +++ b/consensus/src/block/validity/block_program.rs @@ -334,6 +334,6 @@ pub mod tests { test_program_snapshot!( BlockProgram, - "2d3fe8ddca93ac8be7f92a53c541f6dbb971ab2817cbc1743c8b5a3b3c99b6caf0102385258a828d" + "f74b4d364a4ec880057f0ca1cde0beff5904ff67d6e0530aa844f92112c6d0cad5ba6097f6732766" ); } diff --git a/consensus/src/mutator_set/removal_record/chunk.rs b/consensus/src/mutator_set/removal_record/chunk.rs index 56875a8..8a2568c 100644 --- a/consensus/src/mutator_set/removal_record/chunk.rs +++ b/consensus/src/mutator_set/removal_record/chunk.rs @@ -30,9 +30,7 @@ const LONG_LENGTH_FLAG: u32 = 1 << 11; #[derive(Debug, Clone, Copy, Error, PartialEq, Eq)] pub(crate) enum ChunkUnpackError { - #[error( - "payload is too large -- packed chunk can never be more than {MAX_PACKED_LENGTH} u32s" - )] + #[error("payload is too large -- packed chunk can never be more than {MAX_PACKED_LENGTH} u32s")] PayloadTooBig, #[error("actual length is inconsistent relative to length indicator")] diff --git a/wallet/cli/src/core/storage.rs b/wallet/cli/src/core/storage.rs index d9ec07d..9849f39 100644 --- a/wallet/cli/src/core/storage.rs +++ b/wallet/cli/src/core/storage.rs @@ -110,20 +110,19 @@ impl KeysStore { write_json(&self.path, data); } - /// Returns the next derivation index (stored index + 1) for display. - pub fn next_index(&self, key: KeyType) -> u64 { + pub fn last_index(&self, key: KeyType) -> u64 { let data = self.read(); match key { - KeyType::Generation => data.generation_index + 1, - KeyType::Symmetric => data.symmetric_index + 1, + KeyType::Generation => data.generation_index, + KeyType::Symmetric => data.symmetric_index, } } - pub fn increment(&self, key: KeyType) { + pub fn set_index(&self, key: KeyType, index: u64) { let mut data = self.read(); match key { - KeyType::Generation => data.generation_index += 1, - KeyType::Symmetric => data.symmetric_index += 1, + KeyType::Generation => data.generation_index = index, + KeyType::Symmetric => data.symmetric_index = index, } self.write(&data); } diff --git a/wallet/cli/src/core/sync.rs b/wallet/cli/src/core/sync.rs index 0eae9ba..76f9d14 100644 --- a/wallet/cli/src/core/sync.rs +++ b/wallet/cli/src/core/sync.rs @@ -54,6 +54,14 @@ pub async fn run(wallet: Wallet, storage: Storage) { WalletEvent::UtxosOutgoing { id, utxos } => { info!("{} UTXOs being spent on transaction {}.", utxos.len(), id); } + WalletEvent::AddressGenerated { + key_type, + index, + address, + } => { + info!("Generated {key_type:?} wallet address: {address:?}."); + storage.keys.set_index(key_type, index); + } } } diff --git a/wallet/cli/src/main.rs b/wallet/cli/src/main.rs index 46a6796..e16e6e6 100644 --- a/wallet/cli/src/main.rs +++ b/wallet/cli/src/main.rs @@ -7,6 +7,7 @@ use anyhow::Result; use clap::Parser; use nyks_consensus::network::Network; use nyks_rpc_client::http::HttpClient; +use nyks_standards::wallet::keys::key::KeyType; use nyks_wallet_core::entropy::wallet_entropy::WalletEntropy; use nyks_wallet_sdk::wallet::Wallet; @@ -54,7 +55,14 @@ async fn main() -> Result<()> { None => setup::run(&storage)?, }; - let wallet = Wallet::new(rpc, entropy, storage.chain.get_height(), args.network); + let wallet = Wallet::new_with_indexes( + rpc, + entropy, + storage.chain.get_height(), + args.network, + storage.keys.last_index(KeyType::Generation), + storage.keys.last_index(KeyType::Symmetric), + ); // Import initial state of wallet. wallet.import_utxos(storage.utxos.iter().collect()).await; diff --git a/wallet/sdk/src/state/address_book.rs b/wallet/sdk/src/state/address_book.rs index 605262c..e395aaf 100644 --- a/wallet/sdk/src/state/address_book.rs +++ b/wallet/sdk/src/state/address_book.rs @@ -1,3 +1,4 @@ +use std::collections::BTreeMap; use std::collections::HashMap; use nyks_standards::wallet::keys::address::Address; @@ -5,118 +6,91 @@ use nyks_standards::wallet::keys::key::Key; use nyks_standards::wallet::keys::key::KeyType; use nyks_standards::wallet::keys::key::Spender; use nyks_standards::wallet::keys::viewing_key::ViewingKey; - use nyks_wallet_core::entropy::wallet_entropy::WalletEntropy; /// Owns the wallet's entropy and derives/tracks its addresses and keys. -/// -/// Addresses are grouped by key type in derivation order - index `i` is -/// the `i`-th derived key of that type, with index 0 being the special key. -/// `view_keys` mirrors that same order and stays in sync as addresses are -/// added. #[derive(Debug)] -pub(crate) struct AddressBook { +pub struct AddressBook { entropy: WalletEntropy, - addresses: HashMap>, - view_keys: Vec, + addresses: HashMap>, } impl AddressBook { - /// Seeds each key type with the special key and index-1 address. - pub(crate) fn new(entropy: WalletEntropy) -> Self { - let mut addresses: HashMap> = HashMap::new(); - - addresses.insert( - KeyType::Generation, - vec![ - entropy.special_generation_key().to_address().into(), - entropy.nth_generation_address(1).into(), - ], - ); - addresses.insert( - KeyType::Symmetric, - vec![ - entropy.special_symmetric_key().to_address().into(), - entropy.nth_symmetric_address(1).into(), - ], - ); - - let view_keys = vec![ - entropy.special_generation_key().to_viewing_key().into(), - entropy.nth_generation_key(1).to_viewing_key().into(), - entropy.special_symmetric_key().to_viewing_key().into(), - entropy.nth_symmetric_key(1).to_viewing_key().into(), - ]; - - AddressBook { - entropy, - addresses, - view_keys, + /// Tracks every used derivation index, plus the next unused address for + /// each key type. Index 0 is reserved for the special key and is included. + pub fn with_address_indexes( + entropy: WalletEntropy, + generation_index: u64, + symmetric_index: u64, + ) -> Self { + let mut addresses = HashMap::new(); + + for (key_type, index) in [ + (KeyType::Generation, generation_index), + (KeyType::Symmetric, symmetric_index), + ] { + let mut by_index = BTreeMap::new(); + + for index in 0..=index + 1 { + by_index.insert(index, derive(&entropy, key_type, index)); + } + + addresses.insert(key_type, by_index); } + + AddressBook { entropy, addresses } } - pub(crate) fn view_keys(&self) -> &[ViewingKey] { - &self.view_keys + pub fn viewing_keys(&self) -> impl Iterator { + self.addresses + .values() + .flat_map(|by_index| by_index.values().map(|(_, viewing_key)| viewing_key)) } - /// Escape hatch for entropy-derived stuff that isn't really about - /// addresses (e.g. sender randomness), so this struct doesn't need a - /// proxy method per use. pub(crate) fn entropy(&self) -> &WalletEntropy { &self.entropy } - pub(crate) fn latest(&self, key_type: KeyType) -> Address { + /// Returns the highest tracked address, which is the wallet's current + /// unused address. + pub fn latest_address(&self, key_type: KeyType) -> Address { self.addresses .get(&key_type) - .and_then(|v| v.last()) - .cloned() + .and_then(|by_index| by_index.last_key_value()) + .map(|(_, (address, _))| address.clone()) .unwrap() } - /// Derives the next address for a key type, registers it, and returns - /// it along with its viewing key. - pub(crate) fn next_address(&mut self, key_type: KeyType) -> (Address, ViewingKey) { - let next_index = self.next_index(key_type); - - let (address, view_key): (Address, ViewingKey) = match key_type { - KeyType::Generation => { - let key = self.entropy.nth_generation_key(next_index); - (key.to_address().into(), key.to_viewing_key().into()) - } - KeyType::Symmetric => { - let key = self.entropy.nth_symmetric_key(next_index); - (key.to_address().into(), key.to_viewing_key().into()) - } - }; - + /// Derives and records a new address, then returns it with its index and + /// viewing key. + pub fn next_address(&mut self, key_type: KeyType) -> (Address, u64, ViewingKey) { + let index = self.addresses[&key_type] + .last_key_value() + .map(|(&index, _)| index + 1) + .unwrap_or(1); + let (address, viewing_key) = derive(&self.entropy, key_type, index); self.addresses .entry(key_type) .or_default() - .push(address.clone()); - self.view_keys.push(view_key.clone()); + .insert(index, (address.clone(), viewing_key.clone())); - (address, view_key) + (address, index, viewing_key) } /// Derives the spending key for whichever address matches, if any. - pub(crate) fn spending_key(&self, matches: impl Fn(&Address) -> bool) -> Option { + pub fn spending_key(&self, matches: impl Fn(&Address) -> bool) -> Option { let (key_type, index) = self.find(matches)?; Some(match key_type { - KeyType::Generation => self.entropy.nth_generation_key(index as u64).into(), - KeyType::Symmetric => self.entropy.nth_symmetric_key(index as u64).into(), + KeyType::Generation => self.entropy.nth_generation_key(index).into(), + KeyType::Symmetric => self.entropy.nth_symmetric_key(index).into(), }) } - fn next_index(&self, key_type: KeyType) -> u64 { - self.addresses.get(&key_type).map(|v| v.len()).unwrap_or(0) as u64 - } - - fn find(&self, matches: impl Fn(&Address) -> bool) -> Option<(KeyType, usize)> { + fn find(&self, matches: impl Fn(&Address) -> bool) -> Option<(KeyType, u64)> { for (key_type, addrs) in self.addresses.iter() { - for (index, address) in addrs.iter().enumerate() { - if matches(address) { + for (&index, address) in addrs.iter() { + if matches(&address.0) { return Some((*key_type, index)); } } @@ -124,3 +98,16 @@ impl AddressBook { None } } + +fn derive(entropy: &WalletEntropy, key_type: KeyType, index: u64) -> (Address, ViewingKey) { + match key_type { + KeyType::Generation => { + let key = entropy.nth_generation_key(index); + (key.to_address().into(), key.to_viewing_key().into()) + } + KeyType::Symmetric => { + let key = entropy.nth_symmetric_key(index); + (key.to_address().into(), key.to_viewing_key().into()) + } + } +} diff --git a/wallet/sdk/src/wallet/event.rs b/wallet/sdk/src/wallet/event.rs new file mode 100644 index 0000000..72bad0f --- /dev/null +++ b/wallet/sdk/src/wallet/event.rs @@ -0,0 +1,47 @@ +use nyks_consensus::transaction::transaction_kernel_id::TransactionKernelId; +use nyks_standards::wallet::keys::address::Address; +use nyks_standards::wallet::keys::key::KeyType; + +use crate::state::utxos::MonitoredUtxo; +use crate::state::utxos::UtxoKey; + +/// Events emitted by the wallet as a result of sync and scan activity. +#[derive(Debug, Clone)] +pub enum WalletEvent { + /// A new UTXO was discovered and added to the wallet's UTXO pool. + UtxoReceived { key: UtxoKey, utxo: MonitoredUtxo }, + + /// A previously-tracked UTXO was found to be spent (or otherwise + /// invalid) while syncing membership proofs, and was evicted from the + /// pool. + UtxoInvalidated { key: UtxoKey, utxo: MonitoredUtxo }, + + /// A mempool transaction was found to spend one or more of the + /// wallet's UTXOs. Emitted once per transaction, the first time it's + /// observed as relevant. + UtxosOutgoing { + id: TransactionKernelId, + utxos: Vec, + }, + + /// A new address was derived by the wallet. + AddressGenerated { + key_type: KeyType, + index: u64, + address: Address, + }, +} + +impl WalletEvent { + pub fn utxo_received(key: UtxoKey, utxo: MonitoredUtxo) -> Self { + WalletEvent::UtxoReceived { key, utxo } + } + + pub fn utxo_invalidated(key: UtxoKey, utxo: MonitoredUtxo) -> Self { + WalletEvent::UtxoInvalidated { key, utxo } + } + + pub fn utxos_outgoing(id: TransactionKernelId, utxos: Vec) -> Self { + WalletEvent::UtxosOutgoing { id, utxos } + } +} diff --git a/wallet/sdk/src/wallet.rs b/wallet/sdk/src/wallet/mod.rs similarity index 80% rename from wallet/sdk/src/wallet.rs rename to wallet/sdk/src/wallet/mod.rs index aaa95f2..064d391 100644 --- a/wallet/sdk/src/wallet.rs +++ b/wallet/sdk/src/wallet/mod.rs @@ -1,3 +1,5 @@ +pub mod event; + use std::sync::Arc; use num_traits::CheckedSub; @@ -15,6 +17,7 @@ use nyks_standards::wallet::keys::address::Recipient; use nyks_standards::wallet::keys::key::KeyType; use nyks_standards::wallet::keys::key::Spender; use nyks_wallet_core::entropy::wallet_entropy::WalletEntropy; +use nyks_wallet_core::transaction::BuilderTransaction; use nyks_wallet_core::transaction::builder::TransactionBuilder; use nyks_wallet_core::transaction::builder::output::TxOutput; pub use nyks_wallet_core::transaction::primitive_witness::ProvingStage; @@ -28,8 +31,9 @@ use crate::scanners::chain::ChainScanner; use crate::scanners::mempool::MempoolScanner; use crate::state::address_book::AddressBook; use crate::state::utxos::MonitoredUtxo; -use crate::state::utxos::UtxoKey; use crate::state::utxos::pool::UtxoPool; +use crate::state::utxos::pool::UtxosSelection; +pub use event::WalletEvent; const BATCH_SIZE: usize = 100; @@ -42,40 +46,6 @@ pub enum SyncError { Advance(#[from] AdvanceError), } -/// Events emitted by the wallet as a result of sync and scan activity. -#[derive(Debug, Clone)] -pub enum WalletEvent { - /// A new UTXO was discovered and added to the wallet's UTXO pool. - UtxoReceived { key: UtxoKey, utxo: MonitoredUtxo }, - - /// A previously-tracked UTXO was found to be spent (or otherwise - /// invalid) while syncing membership proofs, and was evicted from the - /// pool. - UtxoInvalidated { key: UtxoKey, utxo: MonitoredUtxo }, - - /// A mempool transaction was found to spend one or more of the - /// wallet's UTXOs. Emitted once per transaction, the first time it's - /// observed as relevant. - UtxosOutgoing { - id: TransactionKernelId, - utxos: Vec, - }, -} - -impl WalletEvent { - pub fn utxo_received(key: UtxoKey, utxo: MonitoredUtxo) -> Self { - WalletEvent::UtxoReceived { key, utxo } - } - - pub fn utxo_invalidated(key: UtxoKey, utxo: MonitoredUtxo) -> Self { - WalletEvent::UtxoInvalidated { key, utxo } - } - - pub fn utxos_outgoing(id: TransactionKernelId, utxos: Vec) -> Self { - WalletEvent::UtxosOutgoing { id, utxos } - } -} - #[derive(Clone)] pub struct Wallet { rpc: HttpClient, @@ -84,10 +54,7 @@ pub struct Wallet { mempool_scanner: Arc>, utxos: Arc>, pub network: Network, - - /// Events raised outside of `sync` (e.g. by `send`, when it evicts spent - /// UTXOs) that haven't been handed to a caller yet. Drained and merged - /// into the next `sync()` call's returned events. + /// Events queued for delivery by the next `sync()` call. pending_events: Arc>>, } @@ -98,17 +65,33 @@ impl Wallet { height: Option, network: Network, ) -> Self { - let addresses = AddressBook::new(entropy); - let utxos = UtxoPool::new(rpc.clone()); + Self::new_with_indexes(rpc, entropy, height, network, 0, 0) + } - let view_keys = addresses.view_keys().to_vec(); + /// Creates a wallet that scans every index through the last used index + /// and the current unused address. + pub fn new_with_indexes( + rpc: HttpClient, + entropy: WalletEntropy, + height: Option, + network: Network, + generation_index: u64, + symmetric_index: u64, + ) -> Self { + let addresses = + AddressBook::with_address_indexes(entropy, generation_index, symmetric_index); + let scanner = ChainScanner::new( + height, + None, + addresses.viewing_keys().cloned().collect(), + network, + ); + let utxos = UtxoPool::new(rpc.clone()); Wallet { rpc, addresses: Arc::new(RwLock::new(addresses)), - scanner: Arc::new(RwLock::new(ChainScanner::new( - height, None, view_keys, network, - ))), + scanner: Arc::new(RwLock::new(scanner)), mempool_scanner: Arc::new(RwLock::new(MempoolScanner::new(utxos.index()))), utxos: Arc::new(RwLock::new(utxos)), network, @@ -137,15 +120,24 @@ impl Wallet { } pub async fn address(&self, key_type: KeyType) -> Address { - self.addresses.read().await.latest(key_type) + self.addresses.read().await.latest_address(key_type) } pub async fn next_address(&self, key_type: KeyType) -> Address { let mut addresses = self.addresses.write().await; let mut scanner = self.scanner.write().await; - let (address, view_key) = addresses.next_address(key_type); - scanner.add_key(view_key); + let (address, index, viewing_key) = addresses.next_address(key_type); + scanner.add_key(viewing_key); + + self.pending_events + .write() + .await + .push(WalletEvent::AddressGenerated { + key_type, + index, + address: address.clone(), + }); address } @@ -308,9 +300,8 @@ impl Wallet { /// proof-proving stage begins; the channel closes on its own once /// proving completes. Pass `None` to skip progress reporting. /// - /// Any `UtxoInvalidated` events raised while selecting inputs (UTXOs - /// found spent during proof-syncing) are queued and surfaced on the - /// next call to [`Wallet::sync`], rather than returned here directly. + /// Events raised while sending are queued and surfaced on the next call + /// to [`Wallet::sync`]. pub async fn send( &self, recipient: Address, @@ -318,10 +309,25 @@ impl Wallet { fee: NativeCurrencyAmount, progress: Option>, ) -> Result { - let height = self.tip_height().await; let timestamp = Timestamp::now(); + let selection = self.select_inputs(amount + fee, timestamp).await; + let height = self.tip_height().await; + let transaction = self + .build_transaction(recipient, amount, fee, timestamp, height, selection) + .await; + let transaction = Self::prove_transaction(transaction, progress).await; + let transaction_kernel_id = transaction.txid(); + + self.rpc.submit_transaction(transaction.into()).await?; + + Ok(transaction_kernel_id) + } - let mut utxos = self.utxos.write().await; + async fn select_inputs( + &self, + target: NativeCurrencyAmount, + timestamp: Timestamp, + ) -> UtxosSelection { let excluded_utxos = self .mempool_scanner .read() @@ -329,34 +335,47 @@ impl Wallet { .pending_spend_utxos() .copied() .collect(); - let selection = utxos - .select_utxos(amount + fee, timestamp, Some(excluded_utxos)) + let selection = self + .utxos + .write() + .await + .select_utxos(target, timestamp, Some(excluded_utxos)) .await; if !selection.invalidated_utxos.is_empty() { - let mut pending = self.pending_events.write().await; - pending.extend( + self.pending_events.write().await.extend( selection .invalidated_utxos - .into_iter() - .map(|(key, utxo)| WalletEvent::utxo_invalidated(key, utxo)), + .iter() + .map(|(key, utxo)| WalletEvent::utxo_invalidated(*key, utxo.clone())), ); } + selection + } + async fn build_transaction( + &self, + recipient: Address, + amount: NativeCurrencyAmount, + fee: NativeCurrencyAmount, + timestamp: Timestamp, + height: BlockHeight, + selection: UtxosSelection, + ) -> BuilderTransaction { let inputs = self.unlock_utxos(selection.utxos).await; + let change_address = self.address(KeyType::Symmetric).await; + self.next_address(KeyType::Symmetric).await; - let change_address = self.address(KeyType::Symmetric).await; // TODO: increment symmetric address count let (sender_randomness, change_sender_randomness) = { let addresses = self.addresses.read().await; let entropy = addresses.entropy(); - ( entropy.generate_sender_randomness(height, recipient.privacy_digest()), entropy.generate_sender_randomness(height, change_address.privacy_digest()), ) }; - let transaction = TransactionBuilder::new() + TransactionBuilder::new() .inputs(inputs.into()) .outputs( vec![ @@ -364,7 +383,7 @@ impl Wallet { TxOutput::onchain_native_currency_as_change( selection.change, change_sender_randomness, - self.address(KeyType::Symmetric).await, + change_address, ), ] .into(), @@ -373,10 +392,13 @@ impl Wallet { .timestamp(timestamp) .mutator_set_accumulator(selection.msa) .build() - .unwrap(); + .unwrap() + } - // Proving is CPU-heavy; do it off the async executor and stream - // stage updates back through `progress`, if supplied. + async fn prove_transaction( + transaction: BuilderTransaction, + progress: Option>, + ) -> Transaction { let transaction = tokio::task::spawn_blocking(move || { transaction.upgrade_with_progress(move |stage| { if let Some(tx) = &progress { @@ -386,13 +408,7 @@ impl Wallet { }) .await .expect("spawned task for proving transaction should not panic"); - - let transaction: Transaction = transaction.try_into().unwrap(); - let transaction_kernel_id = transaction.txid(); - - self.rpc.submit_transaction(transaction.into()).await?; - - Ok(transaction_kernel_id) + transaction.try_into().unwrap() } async fn unlock_utxos(&self, utxos: Vec) -> Vec {