diff --git a/docs/cookbook/wasm.md b/docs/cookbook/wasm.md index 9b74942..8c289b3 100644 --- a/docs/cookbook/wasm.md +++ b/docs/cookbook/wasm.md @@ -1,6 +1,6 @@ # WASM Example -Because rust can compile to WASM, it is possible to use BDK in the browser. However, there are some limitations to keep in mind which will be highlighted in this example. +Because rust can compile to WASM, it is possible to use BDK in the browser. However, there are a few limitations to keep in mind which will be highlighted in this example. That being said, there are perfectly viable work-arounds for these limitations that should suffice for most use cases. !!! warning There are several limitations to using BDK in WASM. Basically any functionality that requires OS access is not directly available in WASM and must therefore be handled in JavaScript. Some key limitations include: @@ -9,7 +9,7 @@ Because rust can compile to WASM, it is possible to use BDK in the browser. Howe - No access to the system time - Network access is limited to http(s) -## WASM Limitations Overview +## WASM Considerations Overview ### No access to the file system With no direct access to the file system, persistence cannot be handled by BDK directly. Instead, an in memory wallet must be used in the WASM environment, and the data must be exported through a binding to the JavaScript environment to be persisted. @@ -23,44 +23,55 @@ This effectively means that the blockchain client must be an Esplora instance. B ## Troubleshooting WASM errors can be quite cryptic, so it's important to understand the limitations of the WASM environment. One common error you might see while running a BDK function through a WASM binding in the browser is `unreachable`. This error likely will not point you to the actual BDK function that is causing the error. Instead you need to be able to assess whether you are calling a function that uses a rust feature that is unsupported in the WASM environment. For example, if you do a scan and then try to use `.apply_update()` you will get an `unreachable` error. This is because `.apply_update()` requires system time, which is not available in the WASM environment. Instead you need to use `.apply_update_at()` which takes an explicit timestamp as an argument (see below). -## Quickstart WASM +## WASM App Example -In this example we will cover basic BDK functionality in a WASM environment, similar to the [Quick Start Example](./quickstart.md). We will show code snippets for both the rust and JavaScript necessary, and we will highlight the key differenced from the rust quickstart example (due to WASM limitations). +In this example we will cover basic BDK functionality in a WASM environment. We will show code snippets for both the rust and JavaScript necessary to create a custom WASM package, and we will highlight the key differences from the plain rust examples (due to WASM limitations). !!! info - The WASM example code is split into two project folders: a rust project that uses wasm-pack to compile rust code to WASM files, and a JavaScript project that pulls the WASM project as a dependency. The JS project represents the web app and the rust project is used to generate an npm module. For simple use cases the `bdk-wasm` package can be added as a dependency as is, but for more advanced use cases it may be necessary to build a custom WASM module. + The WASM example code is split into two project folders: a rust project that uses wasm-pack to compile rust code to WASM files, and a JavaScript project that pulls the WASM project as a dependency. The JS project represents the web app and the rust project is used to generate an npm module. ### Initializing a Wallet -From JS running in our browser, we initialize a wallet like so: +From JS running in our browser, first we need our descriptors: ```javascript ---8<-- "examples/wasm/js/index.js:new" +--8<-- "examples/wasm/js/index.js:descriptors" ``` -Notice we are including blockchain client details here (Signet, and the esplora url). This is because we are forced to use esplora, so we may as well initialize the client at the same time as the wallet. Here is the rust code that gets called: +Then we can initialize the wallet, we'll use some conditional logic here to either 1) create a new wallet and perform a full scan, or 2) load a wallet from stored data and sync it to get recent updates. + +```javascript +--8<-- "examples/wasm/js/index.js:wallet" +``` + +#### Network Consideration +Notice we are including blockchain client details in wallet initialization (Signet, and the esplora url). This is because we are forced to use esplora, so we may as well initialize the client at the same time as the wallet. + +Here is the relevant rust code: ```rust ---8<-- "examples/wasm/rust/src/lib.rs:new" +--8<-- "examples/wasm/rust/src/lib.rs:wallet" ``` -Notice we are using an in-memory wallet with `.create_wallet_no_persist()`. If you try to use persistence through file or database you will get an error becuase those features require OS access. Instead we have to create a binding to pass the wallet data to the JavaScript environment where we can handle persistence. +The first time you load the page in your browser, you should see info in the console confirming that a new wallet was created and a full scan was performed. If you reload the page you should see that the wallet was loaded from the previously saved data and a sync was performed instead of a scan. -### Scan and Apply Update +#### System Time Consideration +Notice we are using a JS binding to access system time with `js_sys::Date::now()`, then passing that timestamp to the `apply_update_at()` function, rather than attempting to use the `.apply_update()` function which would throw an error. -We can now scan the blockchain client for data relevant to our wallet. Here is the JS code: +#### Persistence Consideration +Also notice we are using an in-memory wallet with `.create_wallet_no_persist()`. If you try to use persistence through file or database you will get an error becuase those features require OS access. Instead we have to create a binding to pass the wallet data to the JavaScript environment where we can handle persistence. The rust side methods to extract the wallet data are: -```javascript ---8<-- "examples/wasm/js/index.js:scan" +```rust +--8<-- "examples/wasm/rust/src/lib.rs:store" ``` -Notice we've set the binding up to have a variable stop-gap, so we can modify this value directly in our webapp if desired. Here is the rust code that gets called: +And they are called from our minimal custom browser store: -```rust ---8<-- "examples/wasm/rust/src/lib.rs:scan" +```javascript +--8<-- "examples/wasm/js/index.js:store" ``` -Notice we are using a JS binding to access system time with `js_sys::Date::now()`, then passing that timestamp to the `apply_update_at()` function, rather than attempting to use the `.apply_update()` function which would throw an error. +This is just to show an example of how the wallet data can be persisted. We're using local storage here and stringifying the data (which takes some special logic to handle the Map values in the data). In practice a wallet app should probably use cloud storage of some sort since browser local storage is relatively temporary. ### Balance and Addresses @@ -75,3 +86,5 @@ Here is the rust code that gets called: ```rust --8<-- "examples/wasm/rust/src/lib.rs:utils" ``` + +Notice we call `take_merged()` and `Store.save()` after generating a new address so our wallet keeps track of generated addresses (so we don't re-use them). If you reload the browser you can see the generated address value updated along with the index. \ No newline at end of file diff --git a/examples/wasm/js/index.js b/examples/wasm/js/index.js index 3884a86..d83fb42 100644 --- a/examples/wasm/js/index.js +++ b/examples/wasm/js/index.js @@ -1,26 +1,81 @@ -import { __wbg_set_wasm, WalletWrapper, greet } from '../rust/pkg/bdk_wasm_bg.js'; -import * as wasm from '../rust/pkg/bdk_wasm_bg.wasm'; +import { WalletWrapper, greet } from '../rust/pkg'; -async function run() { - // Initialize WASM - __wbg_set_wasm(wasm); - +// --8<-- [start:store] +// needed to handle js Map serialization +const Store = { + save: data => { + if (!data) { + console.log("No data to save"); + return; + } + const serializedStaged = JSON.stringify(data, (key, value) => { + if (value instanceof Map) { + return { + dataType: 'Map', + value: Array.from(value.entries()) + }; + } + return value; + }); + localStorage.setItem("walletData", serializedStaged); + }, + load: () => { + const walletDataString = localStorage.getItem("walletData"); + // Convert serialized Maps back to Map objects when loading + const walletData = JSON.parse(walletDataString, (key, value) => { + if (value?.dataType === 'Map') { + return new Map(value.value); + } + return value; + }); + return walletData; + } +} +// --8<-- [end:store] + +// --8<-- [start:descriptors] +const externalDescriptor = "tr([12071a7c/86'/1'/0']tpubDCaLkqfh67Qr7ZuRrUNrCYQ54sMjHfsJ4yQSGb3aBr1yqt3yXpamRBUwnGSnyNnxQYu7rqeBiPfw3mjBcFNX4ky2vhjj9bDrGstkfUbLB9T/0/*)#z3x5097m"; +const internalDescriptor = "tr([12071a7c/86'/1'/0']tpubDCaLkqfh67Qr7ZuRrUNrCYQ54sMjHfsJ4yQSGb3aBr1yqt3yXpamRBUwnGSnyNnxQYu7rqeBiPfw3mjBcFNX4ky2vhjj9bDrGstkfUbLB9T/1/*)#n9r4jswr"; +// --8<-- [end:descriptors] + +async function run() { console.log(greet()); // Should print "Hello, bdk-wasm!" - // Test wallet creation - // --8<-- [start:new] - const wallet = new WalletWrapper( - "signet", - "tr([12071a7c/86'/1'/0']tpubDCaLkqfh67Qr7ZuRrUNrCYQ54sMjHfsJ4yQSGb3aBr1yqt3yXpamRBUwnGSnyNnxQYu7rqeBiPfw3mjBcFNX4ky2vhjj9bDrGstkfUbLB9T/0/*)#z3x5097m", - "tr([12071a7c/86'/1'/0']tpubDCaLkqfh67Qr7ZuRrUNrCYQ54sMjHfsJ4yQSGb3aBr1yqt3yXpamRBUwnGSnyNnxQYu7rqeBiPfw3mjBcFNX4ky2vhjj9bDrGstkfUbLB9T/1/*)#n9r4jswr", - "https://mutinynet.com/api" - ); - // --8<-- [end:new] - - // --8<-- [start:scan] - // Test sync - await wallet.sync(2); - // --8<-- [end:scan] + // --8<-- [start:wallet] + const walletData = Store.load(); + console.log("Wallet data:", walletData); + + let wallet; + if (!walletData) { + console.log("Creating new wallet"); + wallet = new WalletWrapper( + "signet", + externalDescriptor, + internalDescriptor, + "https://mutinynet.com/api" + ); + + console.log("Performing Full Scan..."); + await wallet.scan(2); + + const stagedData = wallet.take_staged(); + console.log("Staged:", stagedData); + + Store.save(stagedData); + console.log("Wallet data saved to local storage"); + } else { + console.log("Loading wallet"); + wallet = WalletWrapper.load( + walletData, + "https://mutinynet.com/api", + externalDescriptor, + internalDescriptor + ); + + console.log("Syncing..."); + await wallet.sync(2); + } + // --8<-- [end:wallet] // --8<-- [start:utils] // Test balance @@ -28,7 +83,16 @@ async function run() { // Test address generation console.log("New address:", wallet.get_new_address()); + + const mergedData = wallet.take_merged(walletData); + console.log("Merged:", mergedData); + + Store.save(mergedData); + console.log("new address saved"); // --8<-- [end:utils] } -run().catch(console.error); \ No newline at end of file +run().catch(console.error); + +// to clear local storage: +// localStorage.removeItem("walletData"); diff --git a/examples/wasm/js/public/index.html b/examples/wasm/js/public/index.html new file mode 100644 index 0000000..1fb699e --- /dev/null +++ b/examples/wasm/js/public/index.html @@ -0,0 +1,10 @@ + + + + + BDK WASM Test + + + + + \ No newline at end of file diff --git a/examples/wasm/js/webpack.config.js b/examples/wasm/js/webpack.config.js index 13ec508..2658eaf 100644 --- a/examples/wasm/js/webpack.config.js +++ b/examples/wasm/js/webpack.config.js @@ -3,7 +3,7 @@ const path = require('path'); module.exports = { entry: './index.js', output: { - path: path.resolve(__dirname, 'dist'), + path: path.resolve(__dirname, 'public'), filename: 'bundle.js', }, mode: 'development', @@ -12,7 +12,7 @@ module.exports = { }, devServer: { static: { - directory: path.join(__dirname, 'dist'), + directory: path.join(__dirname, 'public'), }, compress: true, port: 8080, diff --git a/examples/wasm/rust/Cargo.toml b/examples/wasm/rust/Cargo.toml index 7ddb32a..3b6c267 100644 --- a/examples/wasm/rust/Cargo.toml +++ b/examples/wasm/rust/Cargo.toml @@ -15,6 +15,7 @@ wasm-bindgen = "0.2.95" wasm-bindgen-futures = "0.4.45" js-sys = "0.3.72" web-sys = { version = "0.3.72", features = ["console"] } +serde-wasm-bindgen = "0.6.5" # The `console_error_panic_hook` crate provides better debugging of panics by # logging them with `console.error`. This is great for development, but requires diff --git a/examples/wasm/rust/src/lib.rs b/examples/wasm/rust/src/lib.rs index fdd0d13..d8cba83 100644 --- a/examples/wasm/rust/src/lib.rs +++ b/examples/wasm/rust/src/lib.rs @@ -1,18 +1,18 @@ mod utils; -use std::{cell::RefCell, collections::BTreeSet, io::Write, rc::Rc}; - use bdk_esplora::{ esplora_client::{self, AsyncClient}, EsploraAsyncExt, }; -use bdk_wallet::{bitcoin::Network, ChangeSet, KeychainKind, Wallet}; +use bdk_wallet::{chain::Merge, bitcoin::Network, ChangeSet, KeychainKind, Wallet}; use js_sys::Date; use wasm_bindgen::prelude::*; -use web_sys::console; +use serde_wasm_bindgen::{from_value, to_value}; const PARALLEL_REQUESTS: usize = 1; +pub type JsResult = Result; + #[wasm_bindgen] extern "C" {} @@ -23,13 +23,13 @@ pub fn greet() -> String { #[wasm_bindgen] pub struct WalletWrapper { - wallet: Rc>, - client: Rc>, + wallet: Wallet, + client: AsyncClient, } #[wasm_bindgen] impl WalletWrapper { - // --8<-- [start:new] + // --8<-- [start:wallet] #[wasm_bindgen(constructor)] pub fn new( network: String, @@ -68,73 +68,102 @@ impl WalletWrapper { .map_err(|e| format!("{:?}", e))?; Ok(WalletWrapper { - wallet: Rc::new(RefCell::new(wallet)), - client: Rc::new(RefCell::new(client)), + wallet: wallet, + client: client, }) } - // --8<-- [end:new] - - // --8<-- [start:scan] - #[wasm_bindgen] - pub async fn sync(&self, stop_gap: usize) -> Result<(), String> { - let wallet = Rc::clone(&self.wallet); - let client = Rc::clone(&self.client); - - let request = wallet.borrow().start_full_scan().inspect({ - let mut stdout = std::io::stdout(); - let mut once = BTreeSet::::new(); - move |keychain, spk_i, _| { - if once.insert(keychain) { - console::log_1(&format!("\nScanning keychain [{:?}]", keychain).into()); - } - console::log_1(&format!(" {:<3}", spk_i).into()); - stdout.flush().expect("must flush") - } - }); + + pub fn load(changeset: JsValue, url: &str, external_descriptor: &str, internal_descriptor: &str) -> JsResult { + let changeset = from_value(changeset)?; + let wallet_opt = Wallet::load() + .descriptor(KeychainKind::External, Some(external_descriptor.to_string())) + .descriptor(KeychainKind::Internal, Some(internal_descriptor.to_string())) + .extract_keys() + .load_wallet_no_persist(changeset)?; + + + let wallet = match wallet_opt { + Some(wallet) => wallet, + None => return Err(JsError::new("Failed to load wallet, check the changeset")), + }; + + let client = esplora_client::Builder::new(&url).build_async()?; + + Ok(WalletWrapper { wallet, client }) + } + + pub async fn scan(&mut self, stop_gap: usize) -> Result<(), String> { + let wallet = &mut self.wallet; + let client = &self.client; + + let request = wallet.start_full_scan(); let update = client - .borrow() .full_scan(request, stop_gap, PARALLEL_REQUESTS) .await .map_err(|e| format!("{:?}", e))?; let now = (Date::now() / 1000.0) as u64; wallet - .borrow_mut() .apply_update_at(update, Some(now)) .map_err(|e| format!("{:?}", e))?; - console::log_1(&"after apply".into()); + Ok(()) + } + + pub async fn sync(&mut self, parallel_requests: usize) -> JsResult<()> { + let request = self.wallet.start_sync_with_revealed_spks(); + let update = self.client.sync(request, parallel_requests).await?; + + let now = (Date::now() / 1000.0) as u64; + self.wallet.apply_update_at(update, Some(now))?; Ok(()) } - // --8<-- [end:scan] + // --8<-- [end:wallet] // --8<-- [start:utils] - #[wasm_bindgen] pub fn balance(&self) -> u64 { - let balance = self.wallet.borrow().balance(); + let balance = self.wallet.balance(); balance.total().to_sat() } - #[wasm_bindgen] - pub fn get_new_address(&self) -> String { + pub fn get_new_address(&mut self) -> String { let address = self .wallet - .borrow_mut() - .next_unused_address(KeychainKind::External); + .reveal_next_address(KeychainKind::External); address.to_string() } // --8<-- [end:utils] - #[wasm_bindgen] - pub fn peek_address(&self, index: u32) -> String { + pub fn peek_address(&mut self, index: u32) -> String { let address = self .wallet - .borrow_mut() .peek_address(KeychainKind::External, index); address.to_string() } + + // --8<-- [start:store] + pub fn take_staged(&mut self) -> JsResult { + match self.wallet.take_staged() { + Some(changeset) => { + Ok(to_value(&changeset)?) + } + None => Ok(JsValue::null()), + } + } + + pub fn take_merged(&mut self, previous: JsValue) -> JsResult { + match self.wallet.take_staged() { + Some(curr_changeset) => { + let mut changeset: ChangeSet = from_value(previous)?; + changeset.merge(curr_changeset); + Ok(to_value(&changeset)?) + } + None => Ok(JsValue::null()), + } + } + // --8<-- [end:store] } diff --git a/examples/wasm/rust/tests/web.rs b/examples/wasm/rust/tests/web.rs index 97ea49c..2996801 100644 --- a/examples/wasm/rust/tests/web.rs +++ b/examples/wasm/rust/tests/web.rs @@ -43,6 +43,15 @@ async fn test_wallet() { let wallet = new_test_wallet().expect("wallet"); wallet.sync(5).await.expect("sync"); + let staged = wallet.take_staged().expect("staged"); + console::log_1(&format!("staged: {}", staged).into()); + + let staged2 = wallet.take_staged().expect("staged"); + console::log_1(&format!("staged2: {}", staged2).into()); + + let merged = wallet.take_merged(staged).expect("merged"); + console::log_1(&format!("merged: {}", merged).into()); + let first_address = wallet.peek_address(0); assert_eq!( first_address,