From 0965d2afa713c4392f38a7de1969aeb17f9c13ae Mon Sep 17 00:00:00 2001 From: Dario Anongba Varela Date: Wed, 25 Feb 2026 21:50:32 +0000 Subject: [PATCH 1/6] feat: wrap wallet events API (apply_update_events) Add WalletEvent type wrapping BDK's WalletEvent enum with JS-friendly getters: - kind: event type string (chain_tip_changed, tx_confirmed, tx_unconfirmed, tx_replaced, tx_dropped) - txid, tx: transaction data (where applicable) - old_tip, new_tip: chain tip changes - block_time, old_block_time: confirmation data Add Wallet::apply_update_events method that returns Vec describing what changed after applying an update. Closes #19 --- CHANGELOG.md | 1 + src/bitcoin/wallet.rs | 10 +++- src/types/event.rs | 109 ++++++++++++++++++++++++++++++++++++++++++ src/types/mod.rs | 2 + 4 files changed, 121 insertions(+), 1 deletion(-) create mode 100644 src/types/event.rs 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..e9bc789 --- /dev/null +++ b/src/types/event.rs @@ -0,0 +1,109 @@ +use bdk_wallet::wallet::event::WalletEvent as BdkWalletEvent; +use wasm_bindgen::prelude::wasm_bindgen; + +use super::{BlockId, ConfirmationBlockTime, Transaction, Txid}; + +/// 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. + /// + /// One of: `"chain_tip_changed"`, `"tx_confirmed"`, `"tx_unconfirmed"`, `"tx_replaced"`, `"tx_dropped"`. + #[wasm_bindgen(getter)] + pub fn kind(&self) -> String { + match &self.0 { + BdkWalletEvent::ChainTipChanged { .. } => "chain_tip_changed".to_string(), + BdkWalletEvent::TxConfirmed { .. } => "tx_confirmed".to_string(), + BdkWalletEvent::TxUnconfirmed { .. } => "tx_unconfirmed".to_string(), + BdkWalletEvent::TxReplaced { .. } => "tx_replaced".to_string(), + BdkWalletEvent::TxDropped { .. } => "tx_dropped".to_string(), + _ => "unknown".to_string(), + } + } + + /// 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(bt.into()), + _ => 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::*; From 16117908d77ff869dc7e4e4abddeb6613dc5df54 Mon Sep 17 00:00:00 2001 From: Dario Anongba Varela Date: Thu, 26 Feb 2026 12:35:23 +0000 Subject: [PATCH 2/6] fix: use public bdk_wallet::event path, add WalletEventKind enum - Fix E0603: use bdk_wallet::event::WalletEvent (public) instead of bdk_wallet::wallet::event::WalletEvent (private) - Fix E0282: explicit type in old_block_time getter to resolve inference - Add WalletEventKind TS enum per review feedback, replacing String return type on kind() getter (follows KeychainKind pattern) --- src/types/event.rs | 36 +++++++++++++++++++++++++----------- 1 file changed, 25 insertions(+), 11 deletions(-) diff --git a/src/types/event.rs b/src/types/event.rs index e9bc789..7ed7249 100644 --- a/src/types/event.rs +++ b/src/types/event.rs @@ -1,8 +1,24 @@ -use bdk_wallet::wallet::event::WalletEvent as BdkWalletEvent; +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`. @@ -12,17 +28,15 @@ pub struct WalletEvent(BdkWalletEvent); #[wasm_bindgen] impl WalletEvent { /// The kind of event. - /// - /// One of: `"chain_tip_changed"`, `"tx_confirmed"`, `"tx_unconfirmed"`, `"tx_replaced"`, `"tx_dropped"`. #[wasm_bindgen(getter)] - pub fn kind(&self) -> String { + pub fn kind(&self) -> WalletEventKind { match &self.0 { - BdkWalletEvent::ChainTipChanged { .. } => "chain_tip_changed".to_string(), - BdkWalletEvent::TxConfirmed { .. } => "tx_confirmed".to_string(), - BdkWalletEvent::TxUnconfirmed { .. } => "tx_unconfirmed".to_string(), - BdkWalletEvent::TxReplaced { .. } => "tx_replaced".to_string(), - BdkWalletEvent::TxDropped { .. } => "tx_dropped".to_string(), - _ => "unknown".to_string(), + 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 } } @@ -96,7 +110,7 @@ impl WalletEvent { | BdkWalletEvent::TxUnconfirmed { old_block_time: Some(bt), .. - } => Some(bt.into()), + } => Some(ConfirmationBlockTime::from(bt)), _ => None, } } From 2ae89790d86b511d896c24b2a331408bc52e87b6 Mon Sep 17 00:00:00 2001 From: Dario Anongba Varela Date: Thu, 26 Feb 2026 12:41:13 +0000 Subject: [PATCH 3/6] style: fix rustfmt formatting in tx getter --- src/types/event.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/types/event.rs b/src/types/event.rs index 7ed7249..3d26c8d 100644 --- a/src/types/event.rs +++ b/src/types/event.rs @@ -63,9 +63,7 @@ impl WalletEvent { BdkWalletEvent::TxConfirmed { tx, .. } | BdkWalletEvent::TxUnconfirmed { tx, .. } | BdkWalletEvent::TxReplaced { tx, .. } - | BdkWalletEvent::TxDropped { tx, .. } => { - Some(tx.as_ref().clone().into()) - } + | BdkWalletEvent::TxDropped { tx, .. } => Some(tx.as_ref().clone().into()), _ => None, } } From fe9e0ebed9c942740bb0d6c351117793943fb864 Mon Sep 17 00:00:00 2001 From: Dario Anongba Varela Date: Thu, 26 Feb 2026 15:37:15 +0000 Subject: [PATCH 4/6] test: add wallet events integration tests - Test apply_update_events on initial full scan (ChainTipChanged, TxConfirmed) - Test TxConfirmed event after sending TX and mining blocks - Test WalletEventKind enum values - Test no spurious events on re-sync - Uses docker exec for regtest block mining - Only runs in regtest environment (skipped for signet) - Update CI to include events test pattern --- .github/workflows/build-lint-test.yml | 2 +- tests/node/integration/events.test.ts | 207 ++++++++++++++++++++++++++ 2 files changed, 208 insertions(+), 1 deletion(-) create mode 100644 tests/node/integration/events.test.ts diff --git a/.github/workflows/build-lint-test.yml b/.github/workflows/build-lint-test.yml index c217c2b..879e94c 100644 --- a/.github/workflows/build-lint-test.yml +++ b/.github/workflows/build-lint-test.yml @@ -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 --testPathPattern='integration/(esplora|events)' - name: Stop Esplora if: always() run: docker compose -f tests/docker-compose.yml down diff --git a/tests/node/integration/events.test.ts b/tests/node/integration/events.test.ts new file mode 100644 index 0000000..b092cca --- /dev/null +++ b/tests/node/integration/events.test.ts @@ -0,0 +1,207 @@ +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; + +const externalDescriptor = + "wpkh(tprv8ZgxMBicQKsPd5puBG1xsJ5V53vVPfCy2gnZfsqzmDSDjaQx8LEW4REFvrj6PQMuer7NqZeBiy9iP9ucqJZiveeEGqQ5CvcfV6SPcy8LQR7/84'/1'/0'/0/*)#jjcsy5wd"; +const internalDescriptor = + "wpkh(tprv8ZgxMBicQKsPd5puBG1xsJ5V53vVPfCy2gnZfsqzmDSDjaQx8LEW4REFvrj6PQMuer7NqZeBiy9iP9ucqJZiveeEGqQ5CvcfV6SPcy8LQR7/84'/1'/0'/1/*)#rxa3ep74"; + +/** + * Mine blocks on the regtest node via docker exec. + * Returns the miner address used. + */ +function mineBlocks(count: number): string { + 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" } + ); + return address; +} + +/** + * 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(() => { + wallet = Wallet.create(network, externalDescriptor, internalDescriptor); + }); + + 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 === WalletEventKind.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 === WalletEventKind.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!.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 === WalletEventKind.ChainTipChanged + ); + expect(chainTipEvents.length).toBeGreaterThanOrEqual(1); + + // Should have a TxConfirmed event for our transaction + const confirmedEvents = events.filter( + (e) => e.kind === WalletEventKind.TxConfirmed + ); + const ourTxEvent = confirmedEvents.find( + (e) => e.txid?.toString() === txid.toString() + ); + expect(ourTxEvent).toBeDefined(); + expect(ourTxEvent!.block_time).toBeDefined(); + expect(ourTxEvent!.block_time!.height).toBeGreaterThan(tipBefore); + expect(ourTxEvent!.tx).toBeDefined(); + }, 30000); + + it("event kind returns WalletEventKind 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 + const validKinds = new Set([ + WalletEventKind.ChainTipChanged, + WalletEventKind.TxConfirmed, + WalletEventKind.TxUnconfirmed, + WalletEventKind.TxReplaced, + WalletEventKind.TxDropped, + ]); + + 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 === WalletEventKind.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 === WalletEventKind.TxConfirmed || + e.kind === WalletEventKind.TxUnconfirmed || + e.kind === WalletEventKind.TxReplaced || + e.kind === WalletEventKind.TxDropped + ); + expect(txEvents.length).toBe(0); + }, 30000); +}); From 94949584600a68cd345d62e54127de90e80f3d80 Mon Sep 17 00:00:00 2001 From: Dario Anongba Varela Date: Thu, 26 Feb 2026 15:43:01 +0000 Subject: [PATCH 5/6] fix: correct TS types in events test, exclude from Node-only CI - WalletEventKind is a string literal union in TS, use string constants - Use block_id.height instead of height on ConfirmationBlockTime - Exclude events test from Node CI (requires regtest docker) --- .github/workflows/build-lint-test.yml | 2 +- tests/node/integration/events.test.ts | 58 +++++++++++++++------------ 2 files changed, 34 insertions(+), 26 deletions(-) diff --git a/.github/workflows/build-lint-test.yml b/.github/workflows/build-lint-test.yml index 879e94c..e8e2fb1 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 diff --git a/tests/node/integration/events.test.ts b/tests/node/integration/events.test.ts index b092cca..b4c9b26 100644 --- a/tests/node/integration/events.test.ts +++ b/tests/node/integration/events.test.ts @@ -12,7 +12,8 @@ import { } from "../../../pkg/bitcoindevkit"; const network: Network = (process.env.NETWORK as Network) || "regtest"; -const esploraUrl = process.env.ESPLORA_URL || "http://localhost:8094/regtest/api"; +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; @@ -22,11 +23,19 @@ const externalDescriptor = const internalDescriptor = "wpkh(tprv8ZgxMBicQKsPd5puBG1xsJ5V53vVPfCy2gnZfsqzmDSDjaQx8LEW4REFvrj6PQMuer7NqZeBiy9iP9ucqJZiveeEGqQ5CvcfV6SPcy8LQR7/84'/1'/0'/1/*)#rxa3ep74"; +// 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. - * Returns the miner address used. */ -function mineBlocks(count: number): string { +function mineBlocks(count: number): void { const address = execSync( `docker exec esplora-regtest cli -regtest getnewaddress`, { encoding: "utf-8" } @@ -35,7 +44,6 @@ function mineBlocks(count: number): string { `docker exec esplora-regtest cli -regtest generatetoaddress ${count} ${address}`, { encoding: "utf-8" } ); - return address; } /** @@ -78,7 +86,7 @@ describeRegtest("Wallet events (regtest)", () => { // Should have at least a ChainTipChanged event (wallet goes from genesis to tip) const chainTipEvents = events.filter( - (e) => e.kind === WalletEventKind.ChainTipChanged + (e) => e.kind === EventKind.ChainTipChanged ); expect(chainTipEvents.length).toBeGreaterThanOrEqual(1); @@ -91,7 +99,7 @@ describeRegtest("Wallet events (regtest)", () => { // Should have TxConfirmed for the pre-funded transaction const confirmedEvents = events.filter( - (e) => e.kind === WalletEventKind.TxConfirmed + (e) => e.kind === EventKind.TxConfirmed ); expect(confirmedEvents.length).toBeGreaterThanOrEqual(1); @@ -100,7 +108,7 @@ describeRegtest("Wallet events (regtest)", () => { expect(event.txid).toBeDefined(); expect(event.tx).toBeDefined(); expect(event.block_time).toBeDefined(); - expect(event.block_time!.height).toBeGreaterThan(0); + expect(event.block_time!.block_id.height).toBeGreaterThan(0); } // Wallet should have balance after applying events @@ -142,24 +150,24 @@ describeRegtest("Wallet events (regtest)", () => { // Should have a ChainTipChanged event const chainTipEvents = events.filter( - (e) => e.kind === WalletEventKind.ChainTipChanged + (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 === WalletEventKind.TxConfirmed + (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!.height).toBeGreaterThan(tipBefore); + expect(ourTxEvent!.block_time!.block_id.height).toBeGreaterThan(tipBefore); expect(ourTxEvent!.tx).toBeDefined(); }, 30000); - it("event kind returns WalletEventKind enum values", async () => { + 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; @@ -169,13 +177,13 @@ describeRegtest("Wallet events (regtest)", () => { const update = await esploraClient.sync(syncRequest, 1); const events: WalletEvent[] = wallet.apply_update_events(update); - // All events should have a valid WalletEventKind - const validKinds = new Set([ - WalletEventKind.ChainTipChanged, - WalletEventKind.TxConfirmed, - WalletEventKind.TxUnconfirmed, - WalletEventKind.TxReplaced, - WalletEventKind.TxDropped, + // 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) { @@ -183,9 +191,9 @@ describeRegtest("Wallet events (regtest)", () => { } // Should at least get a chain tip changed event from the mined block - expect(events.some((e) => e.kind === WalletEventKind.ChainTipChanged)).toBe( - true - ); + expect( + events.some((e) => e.kind === EventKind.ChainTipChanged) + ).toBe(true); }, 30000); it("returns no tx events when nothing changed", async () => { @@ -197,10 +205,10 @@ describeRegtest("Wallet events (regtest)", () => { // No new tx events expected (wallet is already up to date) const txEvents = events.filter( (e) => - e.kind === WalletEventKind.TxConfirmed || - e.kind === WalletEventKind.TxUnconfirmed || - e.kind === WalletEventKind.TxReplaced || - e.kind === WalletEventKind.TxDropped + e.kind === EventKind.TxConfirmed || + e.kind === EventKind.TxUnconfirmed || + e.kind === EventKind.TxReplaced || + e.kind === EventKind.TxDropped ); expect(txEvents.length).toBe(0); }, 30000); From 28da58e1bf39ae839902463d1693f0d44a2aa49e Mon Sep 17 00:00:00 2001 From: Dario Anongba Varela Date: Thu, 26 Feb 2026 16:00:32 +0000 Subject: [PATCH 6/6] fix: use separate wallet for events tests, run regtest tests sequentially - Events test uses different descriptor to avoid UTXO conflicts with esplora test - Self-funds wallet via docker exec in beforeAll - Run regtest integration tests with --runInBand to prevent block mining side effects between test suites --- .github/workflows/build-lint-test.yml | 2 +- tests/node/integration/events.test.ts | 20 +++++++++++++++++--- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build-lint-test.yml b/.github/workflows/build-lint-test.yml index e8e2fb1..4ce7a4c 100644 --- a/.github/workflows/build-lint-test.yml +++ b/.github/workflows/build-lint-test.yml @@ -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|events)' + 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/tests/node/integration/events.test.ts b/tests/node/integration/events.test.ts index b4c9b26..5d1d1e4 100644 --- a/tests/node/integration/events.test.ts +++ b/tests/node/integration/events.test.ts @@ -18,10 +18,11 @@ const esploraUrl = // 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(tprv8ZgxMBicQKsPd5puBG1xsJ5V53vVPfCy2gnZfsqzmDSDjaQx8LEW4REFvrj6PQMuer7NqZeBiy9iP9ucqJZiveeEGqQ5CvcfV6SPcy8LQR7/84'/1'/0'/0/*)#jjcsy5wd"; + "wpkh(tprv8ZgxMBicQKsPe2qpAuh1K1Hig72LCoP4JgNxZM2ZRWHZYnpuw5oHoGBsQm7Qb8mLgPpRJVn3hceWgGQRNbPD6x1pp2Qme2YFRAPeYh7vmvE/84'/1'/0'/0/*)#a6kgzlgq"; const internalDescriptor = - "wpkh(tprv8ZgxMBicQKsPd5puBG1xsJ5V53vVPfCy2gnZfsqzmDSDjaQx8LEW4REFvrj6PQMuer7NqZeBiy9iP9ucqJZiveeEGqQ5CvcfV6SPcy8LQR7/84'/1'/0'/1/*)#rxa3ep74"; + "wpkh(tprv8ZgxMBicQKsPe2qpAuh1K1Hig72LCoP4JgNxZM2ZRWHZYnpuw5oHoGBsQm7Qb8mLgPpRJVn3hceWgGQRNbPD6x1pp2Qme2YFRAPeYh7vmvE/84'/1'/0'/1/*)#vwnfl2cc"; // WalletEventKind is a string literal union in TS, so use string constants const EventKind = { @@ -74,8 +75,21 @@ describeRegtest("Wallet events (regtest)", () => { const esploraClient = new EsploraClient(esploraUrl, 0); let wallet: Wallet; - beforeAll(() => { + 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 () => {