diff --git a/.cargo/config.toml b/.cargo/config.toml index 8676e22..4feb031 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -1,8 +1,11 @@ [target.wasm32-unknown-unknown] rustflags = ["-C", "target-feature=+atomics,+bulk-memory,+mutable-globals"] -[unstable] -build-std = ["panic_abort", "std"] +# These are commented out and instead set in the justfile because we can't enable per-target unstable +# features which are needed for WASM but not compatible with native builds. -[build] -target = "wasm32-unknown-unknown" \ No newline at end of file +# [unstable] +# build-std = ["panic_abort", "std"] + +# [build] +# target = "wasm32-unknown-unknown" \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index deadc5d..ba99b40 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -29,6 +29,12 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" +[[package]] +name = "adler2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" + [[package]] name = "aead" version = "0.5.2" @@ -88,6 +94,12 @@ dependencies = [ "syn", ] +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + [[package]] name = "autocfg" version = "1.3.0" @@ -104,7 +116,7 @@ dependencies = [ "cc", "cfg-if", "libc", - "miniz_oxide", + "miniz_oxide 0.7.4", "object", "rustc-demangle", ] @@ -355,6 +367,15 @@ dependencies = [ "libc", ] +[[package]] +name = "crc32fast" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossbeam-channel" version = "0.5.13" @@ -520,6 +541,16 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" +[[package]] +name = "flate2" +version = "1.0.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "324a1be68054ef05ad64b861cc9eaf1d623d2d8cb25b4bf2cb9cdd902b4bf253" +dependencies = [ + "crc32fast", + "miniz_oxide 0.8.0", +] + [[package]] name = "fnv" version = "1.0.7" @@ -546,6 +577,15 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" +[[package]] +name = "futures-channel" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eac8f7d7865dcb88bd4373ab671c8cf4508703796caa2b1985a9ca867b3fcb78" +dependencies = [ + "futures-core", +] + [[package]] name = "futures-core" version = "0.3.30" @@ -639,6 +679,25 @@ dependencies = [ "subtle", ] +[[package]] +name = "h2" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524e8ac6999421f49a846c2d4411f337e53497d8ec55d67753beffa43c5d9205" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap 2.3.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "halo2_gadgets" version = "0.3.0" @@ -679,6 +738,12 @@ dependencies = [ "tracing", ] +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + [[package]] name = "hashbrown" version = "0.14.5" @@ -768,9 +833,30 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "50dfd22e0e76d0f662d429a5f80fcaf3855009297eab6a0a9f8543834744ba05" dependencies = [ "bytes", + "futures-channel", + "futures-util", + "h2", "http", "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-timeout" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3203a961e5c83b6f5498933e78b6b263e208c197b63e9c6c53cc82ffd3f63793" +dependencies = [ + "hyper", + "hyper-util", + "pin-project-lite", "tokio", + "tower-service", ] [[package]] @@ -780,12 +866,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cde7055719c54e36e95e8719f95883f22072a48ede39db7fc17a4e1d5281e9b9" dependencies = [ "bytes", + "futures-channel", "futures-util", "http", "http-body", "hyper", "pin-project-lite", + "socket2", "tokio", + "tower", + "tower-service", + "tracing", ] [[package]] @@ -814,6 +905,16 @@ dependencies = [ "web-sys", ] +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", +] + [[package]] name = "indexmap" version = "2.3.0" @@ -821,7 +922,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "de3fc2e30ba82dd1b3911c8de1ffc143c74a914a14e99514d7637e3099df5ea0" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.14.5", ] [[package]] @@ -997,6 +1098,15 @@ dependencies = [ "adler", ] +[[package]] +name = "miniz_oxide" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2d80299ef12ff69b16a84bb182e3b9df68b5a91574d3d4fa6e41b65deec4df1" +dependencies = [ + "adler2", +] + [[package]] name = "mio" version = "1.0.2" @@ -1201,7 +1311,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" dependencies = [ "fixedbitset", - "indexmap", + "indexmap 2.3.0", ] [[package]] @@ -1468,6 +1578,21 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a66a03ae7c801facd77a29370b4faec201768915ac14a721ba36f20bc9c209b" +[[package]] +name = "ring" +version = "0.17.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c17fa4cb658e3583423e915b9f3acc01cceaee1860e33d59ebae66adc3a2dc0d" +dependencies = [ + "cc", + "cfg-if", + "getrandom", + "libc", + "spin", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "ripemd" version = "0.1.3" @@ -1496,6 +1621,48 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rustls" +version = "0.23.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2dabaac7466917e566adb06783a81ca48944c6898a1b08b9374106dd671f4c8" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pemfile" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "196fe16b00e106300d3e45ecfcb764fa292a535d7326a29a5875c579c7417425" +dependencies = [ + "base64 0.22.1", + "rustls-pki-types", +] + +[[package]] +name = "rustls-pki-types" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0a2ce646f8655401bb81e7927b812614bd5d91dbc968696be50603510fcaf0" + +[[package]] +name = "rustls-webpki" +version = "0.102.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "sapling-crypto" version = "0.2.0" @@ -1758,13 +1925,37 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2b070231665d27ad9ec9b8df639893f46727666c6767db40317fbe920a5d998" dependencies = [ "backtrace", + "bytes", "libc", "mio", "pin-project-lite", "socket2", + "tokio-macros", "windows-sys 0.52.0", ] +[[package]] +name = "tokio-macros" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "693d596312e88961bc67d7f1f97af8a70227d9f90c31bba5806eec004978d752" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c7bc40d0e5a97695bb96e27995cd3a08538541b0a846f65bba7a359f36700d4" +dependencies = [ + "rustls", + "rustls-pki-types", + "tokio", +] + [[package]] name = "tokio-stream" version = "0.1.15" @@ -1776,6 +1967,19 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-util" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61e7c3654c13bcd040d4a03abee2c75b1d14a37b423cf5a813ceae1cc903ec6a" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + [[package]] name = "tonic" version = "0.12.2" @@ -1785,16 +1989,25 @@ dependencies = [ "async-trait", "base64 0.22.1", "bytes", + "flate2", "http", "http-body", "http-body-util", + "hyper", + "hyper-timeout", + "hyper-util", "percent-encoding", "pin-project", "prost 0.13.2", + "rustls-pemfile", + "tokio", + "tokio-rustls", "tokio-stream", + "tower", "tower-layer", "tower-service", "tracing", + "webpki-roots", ] [[package]] @@ -1835,6 +2048,26 @@ dependencies = [ "web-sys", ] +[[package]] +name = "tower" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +dependencies = [ + "futures-core", + "futures-util", + "indexmap 1.9.3", + "pin-project", + "pin-project-lite", + "rand", + "slab", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "tower-layer" version = "0.3.3" @@ -1917,6 +2150,12 @@ dependencies = [ "web-sys", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "typenum" version = "1.17.0" @@ -1960,6 +2199,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "uuid" version = "1.10.0" @@ -2043,6 +2288,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7b6d5a78adc3e8f198e9cd730f219a695431467f7ec29dcfc63ade885feebe1" +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.0+wasi-snapshot-preview1" @@ -2163,6 +2417,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-roots" +version = "0.26.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bd24728e5af82c6c4ec1b66ac4844bdf8156257fccda846ec58b42cd0cdbe6a" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webz-core" version = "0.1.0" @@ -2180,6 +2443,7 @@ dependencies = [ "secrecy", "sha2", "thiserror", + "tokio", "tonic", "tonic-web-wasm-client", "tracing", diff --git a/Cargo.toml b/Cargo.toml index 3d4bd16..ed33e5c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,8 +20,10 @@ codegen-units = 1 wasm-opt = ["-O4", "-O4"] [features] -default = ["console_error_panic_hook"] +default = ["native"] +wasm = ["console_error_panic_hook"] console_error_panic_hook = ["dep:console_error_panic_hook"] +native = ["dep:tokio", "tonic/channel", "tonic/gzip", "tonic/tls-webpki-roots"] [dependencies] ## Web dependencies @@ -59,6 +61,8 @@ tracing-subscriber = "0.3.18" tracing = "0.1.40" nonempty = "0.7" hex = "0.4.3" +tokio = { version = "1.0", features = ["rt", "macros"], optional = true } + [dev-dependencies] wasm-bindgen-test = "0.3.42" diff --git a/justfile b/justfile index 56b8dbf..7b4d8bc 100644 --- a/justfile +++ b/justfile @@ -2,10 +2,13 @@ default: just --list build: - wasm-pack build -t web --release --out-dir ./packages/webz-core + wasm-pack build -t web --release --out-dir ./packages/webz-core -Z --no-default-features --features="wasm" build-std="panic_abort,std" test-web: - WASM_BINDGEN_TEST_TIMEOUT=99999 wasm-pack test --release --firefox + WASM_BINDGEN_TEST_TIMEOUT=99999 wasm-pack test --release --firefox --no-default-features --features="wasm" -Z build-std="panic_abort,std" + +test-native: + cargo test -r -- --nocapture check: - cargo check + cargo check diff --git a/src/bindgen/wallet.rs b/src/bindgen/wallet.rs index 21cb6fe..9c1530c 100644 --- a/src/bindgen/wallet.rs +++ b/src/bindgen/wallet.rs @@ -1,45 +1,15 @@ use std::collections::HashMap; use std::num::NonZeroU32; -use nonempty::NonEmpty; use wasm_bindgen::prelude::*; -use bip0039::{English, Mnemonic}; -use futures_util::{StreamExt, TryStreamExt}; -use secrecy::{ExposeSecret, SecretVec, Zeroize}; use tonic_web_wasm_client::Client; use zcash_address::ZcashAddress; -use zcash_client_backend::data_api::scanning::ScanRange; -use zcash_client_backend::data_api::wallet::input_selection::GreedyInputSelector; -use zcash_client_backend::data_api::wallet::{create_proposed_transactions, propose_transfer}; -use zcash_client_backend::data_api::{ - AccountBirthday, AccountPurpose, InputSource, NullifierQuery, WalletRead, WalletWrite, -}; -use zcash_client_backend::fees::zip317::SingleOutputChangeStrategy; -use zcash_client_backend::proto::service; -use zcash_client_backend::proto::service::compact_tx_streamer_client::CompactTxStreamerClient; -use zcash_client_backend::scanning::{scan_block, Nullifiers, ScanningKeys}; -use zcash_client_backend::wallet::OvkPolicy; -use zcash_client_backend::zip321::{Payment, TransactionRequest}; -use zcash_client_backend::ShieldedProtocol; -use zcash_client_memory::MemoryWalletDb; -use zcash_keys::keys::UnifiedSpendingKey; use zcash_primitives::consensus::{self, BlockHeight}; -use zcash_primitives::transaction::components::amount::NonNegativeAmount; -use zcash_primitives::transaction::fees::zip317::FeeRule; -use zcash_primitives::transaction::TxId; -use zcash_proofs::prover::LocalTxProver; use crate::error::Error; - -const BATCH_SIZE: u32 = 10000; - -/// The maximum number of checkpoints to store in each shard-tree -const PRUNING_DEPTH: usize = 100; - -type Proposal = - zcash_client_backend::proposal::Proposal; +use crate::{BlockRange, Wallet}; /// # A Zcash wallet /// @@ -61,24 +31,19 @@ type Proposal = /// TODO /// #[wasm_bindgen] -pub struct Wallet { - /// Internal database used to maintain wallet data (e.g. accounts, transactions, cached blocks) - db: MemoryWalletDb, - // gRPC client used to connect to a lightwalletd instance for network data - client: CompactTxStreamerClient, - network: consensus::Network, - min_confirmations: NonZeroU32, +pub struct WebWallet { + inner: Wallet, } #[wasm_bindgen] -impl Wallet { +impl WebWallet { /// Create a new instance of a Zcash wallet for a given network #[wasm_bindgen(constructor)] pub fn new( network: &str, lightwalletd_url: &str, min_confirmations: u32, - ) -> Result { + ) -> Result { let network = match network { "main" => consensus::Network::MainNetwork, "test" => consensus::Network::TestNetwork, @@ -88,12 +53,10 @@ impl Wallet { }; let min_confirmations = NonZeroU32::try_from(min_confirmations) .map_err(|_| Error::InvalidMinConformations(min_confirmations))?; + let client = Client::new(lightwalletd_url.to_string()); - Ok(Wallet { - db: MemoryWalletDb::new(network, PRUNING_DEPTH), - client: CompactTxStreamerClient::new(Client::new(lightwalletd_url.to_string())), - network, - min_confirmations, + Ok(Self { + inner: Wallet::new(client, network, min_confirmations)?, }) } @@ -110,70 +73,19 @@ impl Wallet { account_index: u32, birthday_height: Option, ) -> Result { - // decode the mnemonic and derive the first account - let usk = usk_from_seed_str(seed_phrase, account_index, &self.network)?; - let ufvk = usk.to_unified_full_viewing_key(); - - let birthday = match birthday_height { - Some(height) => height, - None => { - let chain_tip: u32 = self - .client - .get_latest_block(service::ChainSpec::default()) - .await? - .into_inner() - .height - .try_into() - .expect("block heights must fit into u32"); - chain_tip - 100 - } - }; - // Construct an `AccountBirthday` for the account's birthday. - let birthday = { - // Fetch the tree state corresponding to the last block prior to the wallet's - // birthday height. NOTE: THIS APPROACH LEAKS THE BIRTHDAY TO THE SERVER! - let request = service::BlockId { - height: (birthday - 1).into(), - ..Default::default() - }; - let treestate = self.client.get_tree_state(request).await?.into_inner(); - AccountBirthday::from_treestate(treestate, None).map_err(|_| Error::BirthdayError)? - }; - - let _account = self - .db - .import_account_ufvk(&ufvk, &birthday, AccountPurpose::Spending)?; - // TOOD: Make this public on account Ok(account.account_id().to_string()) - Ok("0".to_string()) + self.inner + .create_account(seed_phrase, account_index, birthday_height) + .await } pub fn suggest_scan_ranges(&self) -> Result, Error> { - Ok(self.db.suggest_scan_ranges().map(|ranges| { - ranges - .iter() - .map(|scan_range| { - BlockRange( - scan_range.block_range().start.into(), - scan_range.block_range().end.into(), - ) - }) - .collect() - })?) + self.inner.suggest_scan_ranges() } /// Synchronize the wallet with the blockchain up to the tip /// The passed callback will be called for every batch of blocks processed with the current progress pub async fn sync(&mut self, callback: &js_sys::Function) -> Result<(), Error> { - let tip = self.update_chain_tip().await?; - - tracing::info!("Retrieving suggested scan ranges from wallet"); - let scan_ranges = self.db.suggest_scan_ranges()?; - tracing::info!("Suggested scan ranges: {:?}", scan_ranges); - - // TODO: Ensure wallet's view of the chain tip as of the previous wallet session is valid. - // See https://github.com/Electric-Coin-Company/zec-sqlite-cli/blob/8c2e49f6d3067ec6cc85248488915278c3cb1c5a/src/commands/sync.rs#L157 - - let callback = move |scanned_to: BlockHeight| { + let callback = move |scanned_to: BlockHeight, tip: BlockHeight| { let this = JsValue::null(); let _ = callback.call2( &this, @@ -182,196 +94,13 @@ impl Wallet { ); }; - // Download and process all blocks in the requested ranges - // Split each range into BATCH_SIZE chunks to avoid requesting too many blocks at once - for scan_range in scan_ranges.into_iter().flat_map(|r| { - // Limit the number of blocks we download and scan at any one time. - (0..).scan(r, |acc, _| { - if acc.is_empty() { - None - } else if let Some((cur, next)) = acc.split_at(acc.block_range().start + BATCH_SIZE) - { - *acc = next; - Some(cur) - } else { - let cur = acc.clone(); - let end = acc.block_range().end; - *acc = ScanRange::from_parts(end..end, acc.priority()); - Some(cur) - } - }) - }) { - self.fetch_and_scan_range( - scan_range.block_range().start.into(), - scan_range.block_range().end.into(), - ) - .await?; - callback(scan_range.block_range().end); - } - - Ok(()) - } - - /// Download and process all blocks in the given range - async fn fetch_and_scan_range(&mut self, start: u32, end: u32) -> Result<(), Error> { - // get the chainstate prior to the range - let tree_state = self - .client - .get_tree_state(service::BlockId { - height: (start - 1).into(), - ..Default::default() - }) - .await?; - let chainstate = tree_state.into_inner().to_chain_state()?; - - // Get the scanning keys from the DB - let account_ufvks = self.db.get_unified_full_viewing_keys()?; - let scanning_keys = ScanningKeys::from_account_ufvks(account_ufvks); - - // Get the nullifiers for the unspent notes we are tracking - let nullifiers = Nullifiers::new( - self.db.get_sapling_nullifiers(NullifierQuery::Unspent)?, - self.db.get_orchard_nullifiers(NullifierQuery::Unspent)?, - ); - - let range = service::BlockRange { - start: Some(service::BlockId { - height: start.into(), - ..Default::default() - }), - end: Some(service::BlockId { - height: (end - 1).into(), - ..Default::default() - }), - }; - - tracing::info!("Scanning block range: {:?} to {:?}", start, end); - - let scanned_blocks = self - .client - .get_block_range(range) - .await? - .into_inner() - .map(|compact_block| { - scan_block( - &self.network, - compact_block.unwrap(), - &scanning_keys, - &nullifiers, - None, - ) - }) - .try_collect() - .await?; - - self.db.put_blocks(&chainstate, scanned_blocks)?; + self.inner.sync(callback).await?; Ok(()) } pub fn get_wallet_summary(&self) -> Result, Error> { - Ok(self - .db - .get_wallet_summary(self.min_confirmations.into())? - .map(Into::into)) - } - - async fn update_chain_tip(&mut self) -> Result { - tracing::info!("Retrieving chain tip from lightwalletd"); - - let tip_height = self - .client - .get_latest_block(service::ChainSpec::default()) - .await? - .get_ref() - .height - .try_into() - .unwrap(); - - tracing::info!("Latest block height is {}", tip_height); - self.db.update_chain_tip(tip_height)?; - - Ok(tip_height) - } - - /// - /// Create a transaction proposal to send funds from the wallet to a given address - /// - fn propose_transfer( - &mut self, - account_index: usize, - to_address: String, - value: u64, - ) -> Result { - let account_id = self.db.get_account_ids()?[account_index]; - - let input_selector = GreedyInputSelector::new( - SingleOutputChangeStrategy::new(FeeRule::standard(), None, ShieldedProtocol::Orchard), - Default::default(), - ); - - let request = TransactionRequest::new(vec![Payment::without_memo( - ZcashAddress::try_from_encoded(&to_address)?, - NonNegativeAmount::from_u64(value)?, - )]) - .unwrap(); - - tracing::info!("Chain height: {:?}", self.db.chain_height()?); - tracing::info!( - "target and anchor heights: {:?}", - self.db - .get_target_and_anchor_heights(self.min_confirmations)? - ); - - let proposal = propose_transfer::< - _, - _, - _, - as InputSource>::Error, - >( - &mut self.db, - &self.network, - account_id, - &input_selector, - request, - self.min_confirmations, - ) - .unwrap(); - tracing::info!("Proposal: {:#?}", proposal); - Ok(proposal) - } - - /// - /// Do the proving and signing required to create one or more transaction from the proposal. Created transactions are stored in the wallet database. - /// - /// Note: At the moment this requires a USK but ideally we want to be able to hand the signing off to a separate service - /// e.g. browser plugin, hardware wallet, etc. Will need to look into refactoring librustzcash create_proposed_transactions to allow for this - /// - fn create_proposed_transactions( - &mut self, - proposal: Proposal, - usk: &UnifiedSpendingKey, - ) -> Result, Error> { - let prover = LocalTxProver::bundled(); - - let transactions = create_proposed_transactions::< - _, - _, - as InputSource>::Error, - _, - _, - >( - &mut self.db, - &self.network, - &prover, - &prover, - usk, - OvkPolicy::Sender, - &proposal, - ) - .unwrap(); - - Ok(transactions) + Ok(self.inner.get_wallet_summary()?.map(Into::into)) } /// @@ -389,43 +118,13 @@ impl Wallet { to_address: String, value: u64, ) -> Result<(), Error> { - let usk = usk_from_seed_str(seed_phrase, 0, &self.network)?; - let proposal = self.propose_transfer(from_account_index, to_address, value)?; - // TODO: Add callback for approving the transaction here - let txids = self.create_proposed_transactions(proposal, &usk)?; - - // send the transactions to the network!! - tracing::info!("Sending transaction..."); - let txid = *txids.first(); - let (txid, raw_tx) = self - .db - .get_transaction(txid)? - .map(|tx| { - let mut raw_tx = service::RawTransaction::default(); - tx.write(&mut raw_tx.data).unwrap(); - (tx.txid(), raw_tx) - }) - .unwrap(); - - tracing::info!("Transaction hex: 0x{}", hex::encode(&raw_tx.data)); - - let response = self.client.send_transaction(raw_tx).await?.into_inner(); - - if response.error_code != 0 { - Err(Error::SendFailed { - code: response.error_code, - reason: response.error_message, - }) - } else { - tracing::info!("Transaction {} send successfully :)", txid); - Ok(()) - } + let to_address = ZcashAddress::try_from_encoded(&to_address)?; + self.inner + .transfer(seed_phrase, from_account_index, to_address, value) + .await } } -#[wasm_bindgen] -pub struct BlockRange(pub u32, pub u32); - #[wasm_bindgen] #[allow(dead_code)] #[derive(Debug)] @@ -477,20 +176,3 @@ where } } } - -fn usk_from_seed_str( - seed: &str, - account_index: u32, - network: &consensus::Network, -) -> Result { - let mnemonic = >::from_phrase(seed).unwrap(); - let seed = { - let mut seed = mnemonic.to_seed(""); - let secret = seed.to_vec(); - seed.zeroize(); - SecretVec::new(secret) - }; - let usk = - UnifiedSpendingKey::from_seed(network, seed.expose_secret(), account_index.try_into()?)?; - Ok(usk) -} diff --git a/src/lib.rs b/src/lib.rs index 56de117..85be778 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -7,4 +7,12 @@ pub mod bindgen; pub mod error; pub mod init; -pub use bindgen::wallet::Wallet; +pub use bindgen::wallet::WebWallet; + +pub mod wallet; +pub use wallet::Wallet; + +use wasm_bindgen::prelude::*; + +#[wasm_bindgen] +pub struct BlockRange(pub u32, pub u32); diff --git a/src/wallet.rs b/src/wallet.rs new file mode 100644 index 0000000..6a5f4e4 --- /dev/null +++ b/src/wallet.rs @@ -0,0 +1,435 @@ +use std::num::NonZeroU32; + +use bip0039::{English, Mnemonic}; +use futures_util::{StreamExt, TryStreamExt}; +use nonempty::NonEmpty; +use secrecy::{ExposeSecret, SecretVec, Zeroize}; +use tonic::{ + client::GrpcService, + codegen::{Body, Bytes, StdError}, +}; + +use zcash_address::ZcashAddress; +use zcash_client_backend::data_api::scanning::ScanRange; +use zcash_client_backend::data_api::wallet::{ + create_proposed_transactions, input_selection::GreedyInputSelector, propose_transfer, +}; +use zcash_client_backend::data_api::{ + AccountBirthday, AccountPurpose, InputSource, NullifierQuery, WalletRead, WalletSummary, + WalletWrite, +}; +use zcash_client_backend::fees::zip317::SingleOutputChangeStrategy; +use zcash_client_backend::proto::service::{ + self, compact_tx_streamer_client::CompactTxStreamerClient, +}; +use zcash_client_backend::scanning::{scan_block, Nullifiers, ScanningKeys}; +use zcash_client_backend::wallet::OvkPolicy; +use zcash_client_backend::zip321::{Payment, TransactionRequest}; +use zcash_client_backend::ShieldedProtocol; +use zcash_client_memory::MemoryWalletDb; +use zcash_keys::keys::UnifiedSpendingKey; +use zcash_primitives::consensus::{self, BlockHeight, Network}; +use zcash_primitives::transaction::components::amount::NonNegativeAmount; +use zcash_primitives::transaction::fees::zip317::FeeRule; +use zcash_primitives::transaction::TxId; +use zcash_proofs::prover::LocalTxProver; + +use crate::error::Error; +use crate::BlockRange; +const BATCH_SIZE: u32 = 10000; + +/// The maximum number of checkpoints to store in each shard-tree +const PRUNING_DEPTH: usize = 100; + +type Proposal = + zcash_client_backend::proposal::Proposal; + +/// # A Zcash wallet +/// +/// A wallet is a set of accounts that can be synchronized together with the blockchain. +/// Once synchronized these can be used to build transactions that spend notes +/// +/// ## Adding Accounts +/// +/// TODO +/// +/// ## Synchronizing +/// +/// A wallet can be syncced with the blockchain by feeding it blocks. The accounts currently managed by the wallet will be used to +/// scan the blocks and retrieve relevant transactions. The wallet itself keeps track of blocks it has seen and can be queried for +/// the suggest range of blocks that should be retrieved for it to process next. +/// +/// ## Building Transactions +/// +/// TODO +/// + +pub struct Wallet { + /// Internal database used to maintain wallet data (e.g. accounts, transactions, cached blocks) + pub(crate) db: MemoryWalletDb, + // gRPC client used to connect to a lightwalletd instance for network data + pub(crate) client: CompactTxStreamerClient, + pub(crate) network: consensus::Network, + pub(crate) min_confirmations: NonZeroU32, +} + +impl Wallet +where + T: GrpcService, + T::Error: Into, + T::ResponseBody: Body + std::marker::Send + 'static, + ::Error: Into + std::marker::Send, +{ + /// Create a new instance of a Zcash wallet for a given network + pub fn new( + client: T, + network: Network, + min_confirmations: NonZeroU32, + ) -> Result, Error> { + Ok(Wallet { + db: MemoryWalletDb::new(network, PRUNING_DEPTH), + client: CompactTxStreamerClient::new(client), + network, + min_confirmations, + }) + } + + /// Add a new account to the wallet + /// + /// # Arguments + /// seed_phrase - mnemonic phrase to initialise the wallet + /// account_index - The HD derivation index to use. Can be any integer + /// birthday_height - The block height at which the account was created, optionally None and the current height is used + /// + pub async fn create_account( + &mut self, + seed_phrase: &str, + account_index: u32, + birthday_height: Option, + ) -> Result { + // decode the mnemonic and derive the first account + let usk = usk_from_seed_str(seed_phrase, account_index, &self.network)?; + let ufvk = usk.to_unified_full_viewing_key(); + + let birthday = match birthday_height { + Some(height) => height, + None => { + let chain_tip: u32 = self + .client + .get_latest_block(service::ChainSpec::default()) + .await? + .into_inner() + .height + .try_into() + .expect("block heights must fit into u32"); + chain_tip - 100 + } + }; + // Construct an `AccountBirthday` for the account's birthday. + let birthday = { + // Fetch the tree state corresponding to the last block prior to the wallet's + // birthday height. NOTE: THIS APPROACH LEAKS THE BIRTHDAY TO THE SERVER! + let request = service::BlockId { + height: (birthday - 1).into(), + ..Default::default() + }; + let treestate = self.client.get_tree_state(request).await?.into_inner(); + AccountBirthday::from_treestate(treestate, None).map_err(|_| Error::BirthdayError)? + }; + + let _account = self + .db + .import_account_ufvk(&ufvk, &birthday, AccountPurpose::Spending)?; + // TOOD: Make this public on account Ok(account.account_id().to_string()) + Ok("0".to_string()) + } + + pub fn suggest_scan_ranges(&self) -> Result, Error> { + Ok(self.db.suggest_scan_ranges().map(|ranges| { + ranges + .iter() + .map(|scan_range| { + BlockRange( + scan_range.block_range().start.into(), + scan_range.block_range().end.into(), + ) + }) + .collect() + })?) + } + + /// Synchronize the wallet with the blockchain up to the tip + /// The passed callback will be called for every batch of blocks processed with the current progress + pub async fn sync(&mut self, callback: impl Fn(BlockHeight, BlockHeight)) -> Result<(), Error> { + let tip = self.update_chain_tip().await?; + + tracing::info!("Retrieving suggested scan ranges from wallet"); + let scan_ranges = self.db.suggest_scan_ranges()?; + tracing::info!("Suggested scan ranges: {:?}", scan_ranges); + + // TODO: Ensure wallet's view of the chain tip as of the previous wallet session is valid. + // See https://github.com/Electric-Coin-Company/zec-sqlite-cli/blob/8c2e49f6d3067ec6cc85248488915278c3cb1c5a/src/commands/sync.rs#L157 + + // Download and process all blocks in the requested ranges + // Split each range into BATCH_SIZE chunks to avoid requesting too many blocks at once + for scan_range in scan_ranges.into_iter().flat_map(|r| { + // Limit the number of blocks we download and scan at any one time. + (0..).scan(r, |acc, _| { + if acc.is_empty() { + None + } else if let Some((cur, next)) = acc.split_at(acc.block_range().start + BATCH_SIZE) + { + *acc = next; + Some(cur) + } else { + let cur = acc.clone(); + let end = acc.block_range().end; + *acc = ScanRange::from_parts(end..end, acc.priority()); + Some(cur) + } + }) + }) { + self.fetch_and_scan_range( + scan_range.block_range().start.into(), + scan_range.block_range().end.into(), + ) + .await?; + callback(scan_range.block_range().end, tip); + } + + Ok(()) + } + + /// Download and process all blocks in the given range + async fn fetch_and_scan_range(&mut self, start: u32, end: u32) -> Result<(), Error> { + // get the chainstate prior to the range + let tree_state = self + .client + .get_tree_state(service::BlockId { + height: (start - 1).into(), + ..Default::default() + }) + .await?; + let chainstate = tree_state.into_inner().to_chain_state()?; + + // Get the scanning keys from the DB + let account_ufvks = self.db.get_unified_full_viewing_keys()?; + let scanning_keys = ScanningKeys::from_account_ufvks(account_ufvks); + + // Get the nullifiers for the unspent notes we are tracking + let nullifiers = Nullifiers::new( + self.db.get_sapling_nullifiers(NullifierQuery::Unspent)?, + self.db.get_orchard_nullifiers(NullifierQuery::Unspent)?, + ); + + let range = service::BlockRange { + start: Some(service::BlockId { + height: start.into(), + ..Default::default() + }), + end: Some(service::BlockId { + height: (end - 1).into(), + ..Default::default() + }), + }; + + tracing::info!("Scanning block range: {:?} to {:?}", start, end); + + let scanned_blocks = self + .client + .get_block_range(range) + .await? + .into_inner() + .map(|compact_block| { + scan_block( + &self.network, + compact_block.unwrap(), + &scanning_keys, + &nullifiers, + None, + ) + }) + .try_collect() + .await?; + + self.db.put_blocks(&chainstate, scanned_blocks)?; + + Ok(()) + } + + pub fn get_wallet_summary( + &self, + ) -> Result< + Option< + WalletSummary< + as WalletRead>::AccountId, + >, + >, + Error, + > { + Ok(self.db.get_wallet_summary(self.min_confirmations.into())?) + } + + pub(crate) async fn update_chain_tip(&mut self) -> Result { + tracing::info!("Retrieving chain tip from lightwalletd"); + + let tip_height = self + .client + .get_latest_block(service::ChainSpec::default()) + .await? + .get_ref() + .height + .try_into() + .unwrap(); + + tracing::info!("Latest block height is {}", tip_height); + self.db.update_chain_tip(tip_height)?; + + Ok(tip_height) + } + + /// + /// Create a transaction proposal to send funds from the wallet to a given address + /// + fn propose_transfer( + &mut self, + account_index: usize, + to_address: ZcashAddress, + value: u64, + ) -> Result { + let account_id = self.db.get_account_ids()?[account_index]; + + let input_selector = GreedyInputSelector::new( + SingleOutputChangeStrategy::new(FeeRule::standard(), None, ShieldedProtocol::Orchard), + Default::default(), + ); + + let request = TransactionRequest::new(vec![Payment::without_memo( + to_address, + NonNegativeAmount::from_u64(value)?, + )]) + .unwrap(); + + tracing::info!("Chain height: {:?}", self.db.chain_height()?); + tracing::info!( + "target and anchor heights: {:?}", + self.db + .get_target_and_anchor_heights(self.min_confirmations)? + ); + + let proposal = propose_transfer::< + _, + _, + _, + as InputSource>::Error, + >( + &mut self.db, + &self.network, + account_id, + &input_selector, + request, + self.min_confirmations, + ) + .unwrap(); + tracing::info!("Proposal: {:#?}", proposal); + Ok(proposal) + } + + /// + /// Do the proving and signing required to create one or more transaction from the proposal. Created transactions are stored in the wallet database. + /// + /// Note: At the moment this requires a USK but ideally we want to be able to hand the signing off to a separate service + /// e.g. browser plugin, hardware wallet, etc. Will need to look into refactoring librustzcash create_proposed_transactions to allow for this + /// + pub(crate) fn create_proposed_transactions( + &mut self, + proposal: Proposal, + usk: &UnifiedSpendingKey, + ) -> Result, Error> { + let prover = LocalTxProver::bundled(); + + let transactions = create_proposed_transactions::< + _, + _, + as InputSource>::Error, + _, + _, + >( + &mut self.db, + &self.network, + &prover, + &prover, + usk, + OvkPolicy::Sender, + &proposal, + ) + .unwrap(); + + Ok(transactions) + } + + /// + /// Create a transaction proposal to send funds from the wallet to a given address and if approved will sign it and send the proposed transaction(s) to the network + /// + /// First a proposal is created by selecting inputs and outputs to cover the requested amount. This proposal is then sent to the approval callback. + /// This allows wallet developers to display a confirmation dialog to the user before continuing. + /// + /// # Arguments + /// + pub async fn transfer( + &mut self, + seed_phrase: &str, + from_account_index: usize, + to_address: ZcashAddress, + value: u64, + ) -> Result<(), Error> { + let usk = usk_from_seed_str(seed_phrase, 0, &self.network)?; + let proposal = self.propose_transfer(from_account_index, to_address, value)?; + // TODO: Add callback for approving the transaction here + let txids = self.create_proposed_transactions(proposal, &usk)?; + + // send the transactions to the network!! + tracing::info!("Sending transaction..."); + let txid = *txids.first(); + let (txid, raw_tx) = self + .db + .get_transaction(txid)? + .map(|tx| { + let mut raw_tx = service::RawTransaction::default(); + tx.write(&mut raw_tx.data).unwrap(); + (tx.txid(), raw_tx) + }) + .unwrap(); + + tracing::info!("Transaction hex: 0x{}", hex::encode(&raw_tx.data)); + + let response = self.client.send_transaction(raw_tx).await?.into_inner(); + + if response.error_code != 0 { + Err(Error::SendFailed { + code: response.error_code, + reason: response.error_message, + }) + } else { + tracing::info!("Transaction {} send successfully :)", txid); + Ok(()) + } + } +} + +fn usk_from_seed_str( + seed: &str, + account_index: u32, + network: &consensus::Network, +) -> Result { + let mnemonic = >::from_phrase(seed).unwrap(); + let seed = { + let mut seed = mnemonic.to_seed(""); + let secret = seed.to_vec(); + seed.zeroize(); + SecretVec::new(secret) + }; + let usk = + UnifiedSpendingKey::from_seed(network, seed.expose_secret(), account_index.try_into()?)?; + Ok(usk) +} diff --git a/tests/tests.rs b/tests/tests.rs index a67a73c..45dbaf4 100644 --- a/tests/tests.rs +++ b/tests/tests.rs @@ -1,14 +1,16 @@ use wasm_bindgen_test::*; wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_browser); -use webz_core::bindgen::wallet::Wallet; +use webz_core::{bindgen::wallet::WebWallet, Wallet}; +use zcash_address::ZcashAddress; +use zcash_primitives::consensus::Network; const SEED: &str = "visit armed kite pen cradle toward reward clay marble oil write dove blind oyster silk oyster original message skate bench tone enable stadium element"; const HD_INDEX: u32 = 0; const BIRTHDAY: Option = Some(2577329); // Required to initialize the logger and panic hooks only once -use std::sync::Once; +use std::{num::NonZeroU32, sync::Once}; static INIT: Once = Once::new(); pub fn initialize() { INIT.call_once(|| { @@ -20,7 +22,7 @@ pub fn initialize() { async fn test_get_and_scan_range() { initialize(); - let mut w = Wallet::new("test", "https://zcash-testnet.chainsafe.dev", 1).unwrap(); + let mut w = WebWallet::new("test", "https://zcash-testnet.chainsafe.dev", 1).unwrap(); let id = w.create_account(SEED, HD_INDEX, BIRTHDAY).await.unwrap(); tracing::info!("Created account with id: {}", id); @@ -44,3 +46,45 @@ async fn test_get_and_scan_range() { let summary = w.get_wallet_summary().unwrap(); tracing::info!("Wallet summary: {:?}", summary); } + +#[cfg(feature = "native")] +#[tokio::test] +async fn test_get_and_scan_range_native() { + let url = "https://testnet.zec.rocks:443"; + let c = tonic::transport::Channel::from_shared(url).unwrap(); + + let tls = tonic::transport::ClientTlsConfig::new() + .domain_name("testnet.zec.rocks") + .with_webpki_roots(); + let channel = c.tls_config(tls).unwrap(); + let mut w = Wallet::new( + channel.connect().await.unwrap(), + Network::TestNetwork, + NonZeroU32::try_from(1).unwrap(), + ) + .unwrap(); + + let id = w.create_account(SEED, HD_INDEX, BIRTHDAY).await.unwrap(); + tracing::info!("Created account with id: {}", id); + + tracing::info!("Syncing wallet"); + w.sync(|scanned_to, tip| { + println!("Scanned: {}/{}", scanned_to, tip); + }) + .await + .unwrap(); + + tracing::info!("Syncing complete :)"); + + let summary = w.get_wallet_summary().unwrap(); + tracing::info!("Wallet summary: {:?}", summary); + + tracing::info!("Proposing a transaction"); + let addr = ZcashAddress::try_from_encoded("utest1z00xn09t4eyeqw9zmjss75sf460423dymgyfjn8rtlj26cffy0yad3eea82xekk24s00wnm38cvyrm2c6x7fxlc0ns4a5j7utgl6lchvglfvl9g9p56fqwzvzvj9d3z6r6ft88j654d7dj0ep6myq5duz9s8x78fdzmtx04d2qn8ydkxr4lfdhlkx9ktrw98gd97dateegrr68vl8xu"); + + w.transfer(SEED, 0, addr.unwrap(), 1000).await.unwrap(); + tracing::info!("Transaction proposed"); + + let summary = w.get_wallet_summary().unwrap(); + tracing::info!("Wallet summary: {:?}", summary); +}