diff --git a/.github/workflows/build-lint-test.yml b/.github/workflows/build-lint-test.yml index c217c2b..4ce7a4c 100644 --- a/.github/workflows/build-lint-test.yml +++ b/.github/workflows/build-lint-test.yml @@ -60,7 +60,7 @@ jobs: run: yarn lint - name: Test working-directory: tests/node - run: yarn build && yarn jest --testPathIgnorePatterns='integration/esplora' + run: yarn build && yarn jest --testPathIgnorePatterns='integration/(esplora|events)' esplora-integration: name: Esplora integration tests @@ -106,7 +106,7 @@ jobs: run: sleep 10 - name: Run Esplora integration tests working-directory: tests/node - run: NETWORK=regtest ESPLORA_URL=http://localhost:8094/regtest/api yarn jest --testPathPattern='integration/esplora' + run: NETWORK=regtest ESPLORA_URL=http://localhost:8094/regtest/api yarn jest --runInBand --testPathPattern='integration/(esplora|events)' - name: Stop Esplora if: always() run: docker compose -f tests/docker-compose.yml down diff --git a/CHANGELOG.md b/CHANGELOG.md index d7b8cb2..f9306de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- `WalletEvent` type and `Wallet::apply_update_events` for reacting to wallet state changes ([#19](https://github.com/bitcoindevkit/bdk-wasm/issues/19)) - Upgrade BDK to 2.3.0 with new API wrappers ([#14](https://github.com/bitcoindevkit/bdk-wasm/pull/14)): - `Wallet::create_from_two_path_descriptor` (BIP-389 multipath descriptors) - `TxBuilder::exclude_unconfirmed` and `TxBuilder::exclude_below_confirmations` diff --git a/src/bitcoin/wallet.rs b/src/bitcoin/wallet.rs index 9e26ec9..4002f3a 100644 --- a/src/bitcoin/wallet.rs +++ b/src/bitcoin/wallet.rs @@ -12,7 +12,7 @@ use crate::{ types::{ AddressInfo, Amount, Balance, ChangeSet, CheckPoint, FeeRate, FullScanRequest, KeychainKind, LocalOutput, Network, NetworkKind, OutPoint, Psbt, ScriptBuf, SentAndReceived, SpkIndexed, SyncRequest, Transaction, Txid, - Update, + Update, WalletEvent, }, }; @@ -97,6 +97,14 @@ impl Wallet { Ok(()) } + /// Apply an update and return wallet events describing what changed. + /// + /// Returns a list of `WalletEvent`s such as new transactions, confirmations, replacements, etc. + pub fn apply_update_events(&self, update: Update) -> JsResult> { + let events = self.0.borrow_mut().apply_update_events(update)?; + Ok(events.into_iter().map(WalletEvent::from).collect()) + } + #[wasm_bindgen(getter)] pub fn network(&self) -> Network { self.0.borrow().network().into() diff --git a/src/types/event.rs b/src/types/event.rs new file mode 100644 index 0000000..3d26c8d --- /dev/null +++ b/src/types/event.rs @@ -0,0 +1,121 @@ +use bdk_wallet::event::WalletEvent as BdkWalletEvent; +use wasm_bindgen::prelude::wasm_bindgen; + +use super::{BlockId, ConfirmationBlockTime, Transaction, Txid}; + +/// The kind of wallet event. +#[wasm_bindgen] +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum WalletEventKind { + /// The chain tip changed. + ChainTipChanged = "chain_tip_changed", + /// A transaction was confirmed. + TxConfirmed = "tx_confirmed", + /// A transaction was unconfirmed (reorg). + TxUnconfirmed = "tx_unconfirmed", + /// A transaction was replaced. + TxReplaced = "tx_replaced", + /// A transaction was dropped. + TxDropped = "tx_dropped", +} + +/// An event representing a change to the wallet state. +/// +/// Returned by `Wallet::apply_update_events`. +#[wasm_bindgen] +pub struct WalletEvent(BdkWalletEvent); + +#[wasm_bindgen] +impl WalletEvent { + /// The kind of event. + #[wasm_bindgen(getter)] + pub fn kind(&self) -> WalletEventKind { + match &self.0 { + BdkWalletEvent::ChainTipChanged { .. } => WalletEventKind::ChainTipChanged, + BdkWalletEvent::TxConfirmed { .. } => WalletEventKind::TxConfirmed, + BdkWalletEvent::TxUnconfirmed { .. } => WalletEventKind::TxUnconfirmed, + BdkWalletEvent::TxReplaced { .. } => WalletEventKind::TxReplaced, + BdkWalletEvent::TxDropped { .. } => WalletEventKind::TxDropped, + _ => WalletEventKind::ChainTipChanged, // non_exhaustive fallback + } + } + + /// The transaction id, if applicable. + /// + /// Available for: `tx_confirmed`, `tx_unconfirmed`, `tx_replaced`, `tx_dropped`. + #[wasm_bindgen(getter)] + pub fn txid(&self) -> Option { + match &self.0 { + BdkWalletEvent::TxConfirmed { txid, .. } + | BdkWalletEvent::TxUnconfirmed { txid, .. } + | BdkWalletEvent::TxReplaced { txid, .. } + | BdkWalletEvent::TxDropped { txid, .. } => Some((*txid).into()), + _ => None, + } + } + + /// The transaction, if applicable. + /// + /// Available for: `tx_confirmed`, `tx_unconfirmed`, `tx_replaced`, `tx_dropped`. + #[wasm_bindgen(getter)] + pub fn tx(&self) -> Option { + match &self.0 { + BdkWalletEvent::TxConfirmed { tx, .. } + | BdkWalletEvent::TxUnconfirmed { tx, .. } + | BdkWalletEvent::TxReplaced { tx, .. } + | BdkWalletEvent::TxDropped { tx, .. } => Some(tx.as_ref().clone().into()), + _ => None, + } + } + + /// The old chain tip, for `chain_tip_changed` events. + #[wasm_bindgen(getter)] + pub fn old_tip(&self) -> Option { + match &self.0 { + BdkWalletEvent::ChainTipChanged { old_tip, .. } => Some((*old_tip).into()), + _ => None, + } + } + + /// The new chain tip, for `chain_tip_changed` events. + #[wasm_bindgen(getter)] + pub fn new_tip(&self) -> Option { + match &self.0 { + BdkWalletEvent::ChainTipChanged { new_tip, .. } => Some((*new_tip).into()), + _ => None, + } + } + + /// The confirmation block time, for `tx_confirmed` events. + #[wasm_bindgen(getter)] + pub fn block_time(&self) -> Option { + match &self.0 { + BdkWalletEvent::TxConfirmed { block_time, .. } => Some(block_time.into()), + _ => None, + } + } + + /// The previous confirmation block time, if the transaction was previously confirmed. + /// + /// Available for: `tx_confirmed` (reorg), `tx_unconfirmed` (reorg). + #[wasm_bindgen(getter)] + pub fn old_block_time(&self) -> Option { + match &self.0 { + BdkWalletEvent::TxConfirmed { + old_block_time: Some(bt), + .. + } + | BdkWalletEvent::TxUnconfirmed { + old_block_time: Some(bt), + .. + } => Some(ConfirmationBlockTime::from(bt)), + _ => None, + } + } +} + +impl From for WalletEvent { + fn from(inner: BdkWalletEvent) -> Self { + WalletEvent(inner) + } +} diff --git a/src/types/mod.rs b/src/types/mod.rs index 2df2338..492e1f2 100644 --- a/src/types/mod.rs +++ b/src/types/mod.rs @@ -6,6 +6,7 @@ mod chain; mod changeset; mod checkpoint; mod error; +mod event; mod fee; mod input; mod keychain; @@ -24,6 +25,7 @@ pub use chain::*; pub use changeset::*; pub use checkpoint::*; pub use error::*; +pub use event::*; pub use fee::*; pub use input::*; pub use keychain::*; diff --git a/tests/node/integration/events.test.ts b/tests/node/integration/events.test.ts new file mode 100644 index 0000000..5d1d1e4 --- /dev/null +++ b/tests/node/integration/events.test.ts @@ -0,0 +1,229 @@ +import { execSync } from "child_process"; +import { + Amount, + EsploraClient, + FeeRate, + Network, + Recipient, + SignOptions, + Wallet, + WalletEvent, + WalletEventKind, +} from "../../../pkg/bitcoindevkit"; + +const network: Network = (process.env.NETWORK as Network) || "regtest"; +const esploraUrl = + process.env.ESPLORA_URL || "http://localhost:8094/regtest/api"; + +// Skip unless running against regtest (needs docker for mining) +const describeRegtest = network === "regtest" ? describe : describe.skip; + +// Use a separate descriptor from esplora.test.ts to avoid UTXO conflicts when running in parallel +const externalDescriptor = + "wpkh(tprv8ZgxMBicQKsPe2qpAuh1K1Hig72LCoP4JgNxZM2ZRWHZYnpuw5oHoGBsQm7Qb8mLgPpRJVn3hceWgGQRNbPD6x1pp2Qme2YFRAPeYh7vmvE/84'/1'/0'/0/*)#a6kgzlgq"; +const internalDescriptor = + "wpkh(tprv8ZgxMBicQKsPe2qpAuh1K1Hig72LCoP4JgNxZM2ZRWHZYnpuw5oHoGBsQm7Qb8mLgPpRJVn3hceWgGQRNbPD6x1pp2Qme2YFRAPeYh7vmvE/84'/1'/0'/1/*)#vwnfl2cc"; + +// WalletEventKind is a string literal union in TS, so use string constants +const EventKind = { + ChainTipChanged: "chain_tip_changed" as WalletEventKind, + TxConfirmed: "tx_confirmed" as WalletEventKind, + TxUnconfirmed: "tx_unconfirmed" as WalletEventKind, + TxReplaced: "tx_replaced" as WalletEventKind, + TxDropped: "tx_dropped" as WalletEventKind, +}; + +/** + * Mine blocks on the regtest node via docker exec. + */ +function mineBlocks(count: number): void { + const address = execSync( + `docker exec esplora-regtest cli -regtest getnewaddress`, + { encoding: "utf-8" } + ).trim(); + execSync( + `docker exec esplora-regtest cli -regtest generatetoaddress ${count} ${address}`, + { encoding: "utf-8" } + ); +} + +/** + * Wait for Esplora to index up to a given block height. + */ +async function waitForEsploraHeight( + minHeight: number, + timeoutMs = 30000 +): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + try { + const res = await fetch(`${esploraUrl}/blocks/tip/height`); + const height = parseInt(await res.text(), 10); + if (height >= minHeight) return; + } catch { + // Esplora not ready yet + } + await new Promise((r) => setTimeout(r, 1000)); + } + throw new Error( + `Esplora did not reach height ${minHeight} within ${timeoutMs}ms` + ); +} + +describeRegtest("Wallet events (regtest)", () => { + const stopGap = 5; + const esploraClient = new EsploraClient(esploraUrl, 0); + let wallet: Wallet; + + beforeAll(async () => { + wallet = Wallet.create(network, externalDescriptor, internalDescriptor); + + // Fund this wallet via regtest faucet (separate from esplora test wallet) + const address = wallet.peek_address("external", 0).address.toString(); + execSync( + `docker exec esplora-regtest cli -regtest -rpcwallet=default sendtoaddress ${address} 1.0`, + { encoding: "utf-8" } + ); + // Mine to confirm the funding tx + mineBlocks(1); + // Wait for Esplora to index + const res = await fetch(`${esploraUrl}/blocks/tip/height`); + const currentHeight = parseInt(await res.text(), 10); + await waitForEsploraHeight(currentHeight); + }); + + it("returns events on initial full scan", async () => { + const request = wallet.start_full_scan(); + const update = await esploraClient.full_scan(request, stopGap, 1); + + const events: WalletEvent[] = wallet.apply_update_events(update); + + // Should have at least a ChainTipChanged event (wallet goes from genesis to tip) + const chainTipEvents = events.filter( + (e) => e.kind === EventKind.ChainTipChanged + ); + expect(chainTipEvents.length).toBeGreaterThanOrEqual(1); + + // The chain tip event should have old_tip at height 0 (wallet was fresh) + const tipEvent = chainTipEvents[0]; + expect(tipEvent.old_tip).toBeDefined(); + expect(tipEvent.old_tip!.height).toBe(0); + expect(tipEvent.new_tip).toBeDefined(); + expect(tipEvent.new_tip!.height).toBeGreaterThan(0); + + // Should have TxConfirmed for the pre-funded transaction + const confirmedEvents = events.filter( + (e) => e.kind === EventKind.TxConfirmed + ); + expect(confirmedEvents.length).toBeGreaterThanOrEqual(1); + + // Each confirmed event should have a txid and block_time + for (const event of confirmedEvents) { + expect(event.txid).toBeDefined(); + expect(event.tx).toBeDefined(); + expect(event.block_time).toBeDefined(); + expect(event.block_time!.block_id.height).toBeGreaterThan(0); + } + + // Wallet should have balance after applying events + expect(wallet.balance.trusted_spendable.to_sat()).toBeGreaterThan(0); + }, 30000); + + it("returns TxConfirmed event after sending and mining", async () => { + // Get current tip height + const tipBefore = wallet.latest_checkpoint.height; + + // Send a transaction to ourselves + const recipientAddress = wallet.peek_address("external", 5); + const sendAmount = Amount.from_sat(BigInt(5000)); + const feeRate = new FeeRate(BigInt(1)); + + const psbt = wallet + .build_tx() + .fee_rate(feeRate) + .add_recipient( + new Recipient(recipientAddress.address.script_pubkey, sendAmount) + ) + .finish(); + + wallet.sign(psbt, new SignOptions()); + const tx = psbt.extract_tx(); + const txid = tx.compute_txid(); + await esploraClient.broadcast(tx); + + // Mine blocks to confirm + mineBlocks(1); + + // Wait for Esplora to index the new block + await waitForEsploraHeight(tipBefore + 1); + + // Sync and get events + const syncRequest = wallet.start_sync_with_revealed_spks(); + const update = await esploraClient.sync(syncRequest, 1); + const events: WalletEvent[] = wallet.apply_update_events(update); + + // Should have a ChainTipChanged event + const chainTipEvents = events.filter( + (e) => e.kind === EventKind.ChainTipChanged + ); + expect(chainTipEvents.length).toBeGreaterThanOrEqual(1); + + // Should have a TxConfirmed event for our transaction + const confirmedEvents = events.filter( + (e) => e.kind === EventKind.TxConfirmed + ); + const ourTxEvent = confirmedEvents.find( + (e) => e.txid?.toString() === txid.toString() + ); + expect(ourTxEvent).toBeDefined(); + expect(ourTxEvent!.block_time).toBeDefined(); + expect(ourTxEvent!.block_time!.block_id.height).toBeGreaterThan(tipBefore); + expect(ourTxEvent!.tx).toBeDefined(); + }, 30000); + + it("event kind returns valid string enum values", async () => { + // Mine a block and sync to get fresh events + mineBlocks(1); + const tip = wallet.latest_checkpoint.height; + await waitForEsploraHeight(tip + 1); + + const syncRequest = wallet.start_sync_with_revealed_spks(); + const update = await esploraClient.sync(syncRequest, 1); + const events: WalletEvent[] = wallet.apply_update_events(update); + + // All events should have a valid WalletEventKind string + const validKinds = new Set([ + "chain_tip_changed", + "tx_confirmed", + "tx_unconfirmed", + "tx_replaced", + "tx_dropped", + ]); + + for (const event of events) { + expect(validKinds.has(event.kind)).toBe(true); + } + + // Should at least get a chain tip changed event from the mined block + expect( + events.some((e) => e.kind === EventKind.ChainTipChanged) + ).toBe(true); + }, 30000); + + it("returns no tx events when nothing changed", async () => { + // Sync without any new transactions or blocks + const syncRequest = wallet.start_sync_with_revealed_spks(); + const update = await esploraClient.sync(syncRequest, 1); + const events: WalletEvent[] = wallet.apply_update_events(update); + + // No new tx events expected (wallet is already up to date) + const txEvents = events.filter( + (e) => + e.kind === EventKind.TxConfirmed || + e.kind === EventKind.TxUnconfirmed || + e.kind === EventKind.TxReplaced || + e.kind === EventKind.TxDropped + ); + expect(txEvents.length).toBe(0); + }, 30000); +});