diff --git a/CHANGELOG.md b/CHANGELOG.md index a0c2fc5..24a102a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `Wallet::descriptor_checksum` for getting the descriptor checksum string for a keychain - `Wallet::next_derivation_index` for getting the next unused derivation index for a keychain - `TxDetails` type with getters for `txid`, `sent`, `received`, `fee`, `fee_rate`, `balance_delta_sat`, `chain_position`, and `tx` +- Block-by-block processing with events ([#20](https://github.com/bitcoindevkit/bdk-wasm/issues/20)): + - `Wallet::apply_block_events` for applying a block and connecting via `prev_blockhash`, returns `WalletEvent`s + - `Wallet::apply_block_connected_to_events` for applying a block with explicit chain connection point, returns `WalletEvent`s + - `Wallet::apply_evicted_txs` for marking unconfirmed transactions as evicted from the mempool + - `Wallet::checkpoints` for listing all checkpoints in the wallet's internal chain +- `Block` type with `from_bytes()` deserialization and accessors (`block_hash`, `prev_blockhash`, `time`, `txdata`, `tx_count`) +- `BlockId` constructor from height and hash string +- `EvictedTx` type for pairing a transaction ID with an eviction timestamp +- `BdkErrorCode::CannotConnect` and `BdkErrorCode::UnexpectedConnectedToHash` error codes for block application errors ## [0.3.0] - 2026-03-16 diff --git a/src/bitcoin/wallet.rs b/src/bitcoin/wallet.rs index 964b757..706f27d 100644 --- a/src/bitcoin/wallet.rs +++ b/src/bitcoin/wallet.rs @@ -10,14 +10,17 @@ use crate::{ bitcoin::WalletTx, result::JsResult, types::{ - AddressInfo, Amount, Balance, ChangeSet, CheckPoint, FeeRate, FullScanRequest, KeychainKind, LocalOutput, - Network, NetworkKind, OutPoint, Psbt, ScriptBuf, SentAndReceived, SpkIndexed, SyncRequest, Transaction, - TxDetails, TxOut, Txid, Update, WalletEvent, + AddressInfo, Amount, Balance, Block, ChangeSet, CheckPoint, EvictedTx, FeeRate, FullScanRequest, KeychainKind, + LocalOutput, Network, NetworkKind, OutPoint, Psbt, ScriptBuf, SentAndReceived, SpkIndexed, SyncRequest, + Transaction, TxDetails, TxOut, Txid, Update, WalletEvent, }, }; use super::{TxBuilder, UnconfirmedTx}; +use crate::types::{BdkError, BdkErrorCode, BlockId}; +use bdk_wallet::chain::local_chain::{ApplyHeaderError, CannotConnectError}; + // We wrap a `BdkWallet` in `Rc>` because `wasm_bindgen` do not // support Rust's lifetimes. This allows us to forward a reference to the // internal wallet when using `build_tx` and to enforce the lifetime at runtime @@ -313,6 +316,65 @@ impl Wallet { .borrow_mut() .apply_unconfirmed_txs(unconfirmed_txs.into_iter().map(Into::into)) } + + /// Apply a block to the wallet, connecting it via its `prev_blockhash`. + /// + /// This is a convenience method that introduces a `Block` at the given `height` + /// and connects it to the chain using the block's `prev_blockhash` header field. + /// + /// Returns a list of `WalletEvent`s describing what changed (new transactions, + /// confirmations, etc.). + pub fn apply_block_events(&self, block: &Block, height: u32) -> Result, BdkError> { + let events = self + .0 + .borrow_mut() + .apply_block_events(block, height) + .map_err(BdkError::from)?; + Ok(events.into_iter().map(WalletEvent::from).collect()) + } + + /// Apply a block to the wallet, explicitly specifying the connection point. + /// + /// The `connected_to` parameter tells the wallet how this block connects to the + /// internal chain. Use this when you have explicit knowledge of the chain topology, + /// e.g. when processing blocks out of order or from a specific fork. + /// + /// Returns a list of `WalletEvent`s describing what changed. + pub fn apply_block_connected_to_events( + &self, + block: &Block, + height: u32, + connected_to: BlockId, + ) -> Result, BdkError> { + let events = self + .0 + .borrow_mut() + .apply_block_connected_to_events(block, height, connected_to.into()) + .map_err(BdkError::from)?; + Ok(events.into_iter().map(WalletEvent::from).collect()) + } + + /// Mark unconfirmed transactions as evicted from the mempool. + /// + /// Evicted transactions are no longer considered canonical and won't appear in + /// `transactions()`. This is useful when a blockchain backend reports that certain + /// transactions have been dropped (e.g. due to low fees or conflicts). + /// + /// Only unconfirmed, canonical transactions are affected. An evicted transaction + /// can become canonical again if it is later observed on-chain or in the mempool + /// with higher priority. + pub fn apply_evicted_txs(&self, evicted_txs: Vec) { + self.0 + .borrow_mut() + .apply_evicted_txs(evicted_txs.into_iter().map(|e| (e.txid, e.evicted_at))); + } + + /// List all checkpoints in the wallet's internal chain, ordered by height. + /// + /// Returns an array of `CheckPoint`s representing the wallet's view of the blockchain. + pub fn checkpoints(&self) -> Vec { + self.0.borrow().checkpoints().map(Into::into).collect() + } } /// Options for signing a PSBT. @@ -406,3 +468,19 @@ impl Default for SignOptions { Self::new() } } + +impl From for BdkError { + fn from(e: CannotConnectError) -> Self { + BdkError::new(BdkErrorCode::CannotConnect, e.to_string(), ()) + } +} + +impl From for BdkError { + fn from(e: ApplyHeaderError) -> Self { + use ApplyHeaderError::*; + match &e { + InconsistentBlocks => BdkError::new(BdkErrorCode::UnexpectedConnectedToHash, e.to_string(), ()), + CannotConnect(inner) => BdkError::new(BdkErrorCode::CannotConnect, inner.to_string(), ()), + } + } +} diff --git a/src/types/block.rs b/src/types/block.rs index 9897385..749d389 100644 --- a/src/types/block.rs +++ b/src/types/block.rs @@ -1,7 +1,14 @@ -use std::ops::Deref; +use std::{ops::Deref, str::FromStr}; -use bdk_wallet::chain::{BlockId as BdkBlockId, ConfirmationBlockTime as BdkConfirmationBlockTime}; -use wasm_bindgen::prelude::wasm_bindgen; +use bdk_wallet::{ + bitcoin::{consensus::deserialize, Block as BdkBlock, BlockHash as BdkBlockHash}, + chain::{BlockId as BdkBlockId, ConfirmationBlockTime as BdkConfirmationBlockTime}, +}; +use wasm_bindgen::{prelude::wasm_bindgen, JsError}; + +use crate::result::JsResult; + +use super::Transaction; /// A reference to a block in the canonical chain. #[wasm_bindgen] @@ -10,6 +17,16 @@ pub struct BlockId(BdkBlockId); #[wasm_bindgen] impl BlockId { + /// Create a new `BlockId` from a height and block hash string. + #[wasm_bindgen(constructor)] + pub fn new(height: u32, hash: &str) -> JsResult { + let block_hash = BdkBlockHash::from_str(hash).map_err(|e| JsError::new(&format!("Invalid block hash: {e}")))?; + Ok(BlockId(BdkBlockId { + height, + hash: block_hash, + })) + } + /// The height of the block. #[wasm_bindgen(getter)] pub fn height(&self) -> u32 { @@ -29,6 +46,107 @@ impl From for BlockId { } } +impl From for BdkBlockId { + fn from(block_id: BlockId) -> Self { + block_id.0 + } +} + +/// A full Bitcoin block (header + transactions). +/// +/// Construct from consensus-encoded bytes using `Block.from_bytes()`. +#[wasm_bindgen] +#[derive(Clone)] +pub struct Block(BdkBlock); + +impl Deref for Block { + type Target = BdkBlock; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +#[wasm_bindgen] +impl Block { + /// Deserialize a block from consensus-encoded bytes. + /// + /// Accepts a `Uint8Array` of raw block bytes as produced by Bitcoin Core + /// or any standard Bitcoin serializer. + pub fn from_bytes(bytes: &[u8]) -> JsResult { + let block: BdkBlock = + deserialize(bytes).map_err(|e| JsError::new(&format!("Failed to deserialize block: {e}")))?; + Ok(Block(block)) + } + + /// Returns the block hash. + #[wasm_bindgen(getter)] + pub fn block_hash(&self) -> String { + self.0.block_hash().to_string() + } + + /// Returns the previous block hash from the header. + #[wasm_bindgen(getter)] + pub fn prev_blockhash(&self) -> String { + self.0.header.prev_blockhash.to_string() + } + + /// Returns the block header timestamp. + #[wasm_bindgen(getter)] + pub fn time(&self) -> u32 { + self.0.header.time + } + + /// Returns the list of transactions in the block. + #[wasm_bindgen(getter)] + pub fn txdata(&self) -> Vec { + self.0.txdata.clone().into_iter().map(Into::into).collect() + } + + /// Returns the number of transactions in the block. + #[wasm_bindgen(getter)] + pub fn tx_count(&self) -> usize { + self.0.txdata.len() + } +} + +impl From for Block { + fn from(inner: BdkBlock) -> Self { + Block(inner) + } +} + +impl From<&Block> for BdkBlock { + fn from(block: &Block) -> Self { + block.0.clone() + } +} + +/// A transaction ID paired with an eviction timestamp. +/// +/// Used with `Wallet::apply_evicted_txs` to mark unconfirmed transactions +/// as evicted from the mempool. +#[wasm_bindgen] +pub struct EvictedTx { + pub(crate) txid: bdk_wallet::bitcoin::Txid, + pub(crate) evicted_at: u64, +} + +#[wasm_bindgen] +impl EvictedTx { + /// Create a new `EvictedTx`. + /// + /// - `txid`: The transaction ID to evict + /// - `evicted_at`: Unix timestamp of when the transaction was evicted + #[wasm_bindgen(constructor)] + pub fn new(txid: super::Txid, evicted_at: u64) -> EvictedTx { + EvictedTx { + txid: txid.into(), + evicted_at, + } + } +} + /// Represents the observed position of some chain data. #[wasm_bindgen] pub struct ConfirmationBlockTime(BdkConfirmationBlockTime); diff --git a/src/types/error.rs b/src/types/error.rs index 270258b..f65b707 100644 --- a/src/types/error.rs +++ b/src/types/error.rs @@ -133,6 +133,13 @@ pub enum BdkErrorCode { /// Invalid character in input. InvalidCharacter, + /// ------- Block application errors ------- + + /// Cannot connect block to the existing chain + CannotConnect, + /// Connected-to hash does not match the expected hash + UnexpectedConnectedToHash, + /// ------- Other errors ------- /// Unexpected error, should never happen Unexpected, diff --git a/tests/node/integration/esplora.test.ts b/tests/node/integration/esplora.test.ts index 276f5e9..1deb6ea 100644 --- a/tests/node/integration/esplora.test.ts +++ b/tests/node/integration/esplora.test.ts @@ -1,14 +1,20 @@ +import { execSync } from "child_process"; import { Amount, + BdkError, + BdkErrorCode, + Block, + BlockId, EsploraClient, + EvictedTx, FeeRate, Network, Recipient, - UnconfirmedTx, - Wallet, SignOptions, Psbt, TxOrdering, + UnconfirmedTx, + Wallet, } from "../../../pkg/bitcoindevkit"; // Network configuration via environment variables. @@ -23,6 +29,81 @@ const expectedAddress: Record = { regtest: "bcrt1qkn59f87tznmmjw5nu6ng8p7k6vcur2emmngn5j", }; +const describeRegtest = network === "regtest" ? describe : describe.skip; + +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" } + ); +} + +function getBlockHash(height: number): string { + return execSync( + `docker exec esplora-regtest cli -regtest getblockhash ${height}`, + { encoding: "utf-8" } + ).trim(); +} + +function getBlock(height: number): Block { + const blockHash = getBlockHash(height); + const blockHex = execSync( + `docker exec esplora-regtest cli -regtest getblock ${blockHash} 0`, + { encoding: "utf-8" } + ).trim(); + return Block.from_bytes(Buffer.from(blockHex, "hex")); +} + +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((resolve) => setTimeout(resolve, 1000)); + } + throw new Error( + `Esplora did not reach height ${minHeight} within ${timeoutMs}ms` + ); +} + +async function waitForAddressTx( + address: string, + txid: string, + timeoutMs = 30000 +): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + try { + const res = await fetch(`${esploraUrl}/address/${address}/txs`); + if (res.ok) { + const txs = await res.json(); + if ( + Array.isArray(txs) && + txs.some((tx: { txid: string }) => tx.txid === txid) + ) { + return; + } + } + } catch { + // Esplora has not indexed the address yet + } + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + throw new Error(`Esplora did not index ${txid} for ${address} in time`); +} + // Tests are expected to run in order describe(`Esplora client (${network})`, () => { const stopGap = 5; @@ -280,3 +361,216 @@ describe(`Esplora client (${network})`, () => { }); } }); + +describeRegtest("Block application APIs (regtest)", () => { + const stopGap = 5; + const externalDescriptor = + "wpkh(tprv8ZgxMBicQKsPf6vydw7ixvsLKY79hmeXujBkGCNCApyft92yVYng2y28JpFZcneBYTTHycWSRpokhHE25GfHPBxnW5GpSm2dMWzEi9xxEyU/84'/1'/0'/0/*)#uel0vg9p"; + const internalDescriptor = + "wpkh(tprv8ZgxMBicQKsPf6vydw7ixvsLKY79hmeXujBkGCNCApyft92yVYng2y28JpFZcneBYTTHycWSRpokhHE25GfHPBxnW5GpSm2dMWzEi9xxEyU/84'/1'/0'/1/*)#dd6w3a4e"; + + const esploraClient = new EsploraClient(esploraUrl, 0); + let wallet: Wallet; + + beforeAll(async () => { + wallet = Wallet.create(network, externalDescriptor, internalDescriptor); + + const fundingAddress = wallet.reveal_next_address("external").address.toString(); + const fundingTxid = execSync( + `docker exec esplora-regtest cli -regtest -rpcwallet=default sendtoaddress ${fundingAddress} 1.0`, + { encoding: "utf-8" } + ).trim(); + + mineBlocks(1); + + const currentHeight = parseInt( + execSync(`docker exec esplora-regtest cli -regtest getblockcount`, { + encoding: "utf-8", + }).trim(), + 10 + ); + await waitForEsploraHeight(currentHeight); + await waitForAddressTx(fundingAddress, fundingTxid); + + const request = wallet.start_full_scan(); + const update = await esploraClient.full_scan(request, stopGap, 1); + wallet.apply_update(update); + + expect(wallet.balance.trusted_spendable.to_sat()).toBeGreaterThan(BigInt(0)); + expect(wallet.latest_checkpoint.height).toBeGreaterThan(0); + }, 60000); + + it("applies a mined block via prev_blockhash and returns real events", async () => { + const tipBefore = wallet.latest_checkpoint; + const recipientAddress = wallet.reveal_next_address("external"); + const sendAmount = Amount.from_sat(BigInt(5000)); + + const psbt = wallet + .build_tx() + .fee_rate(new FeeRate(BigInt(1))) + .add_recipient( + new Recipient(recipientAddress.address.script_pubkey, sendAmount) + ) + .finish(); + + expect(wallet.sign(psbt, new SignOptions())).toBe(true); + + const tx = psbt.extract_tx(); + const txid = tx.compute_txid(); + await esploraClient.broadcast(tx); + + mineBlocks(1); + + const newHeight = tipBefore.height + 1; + const block = getBlock(newHeight); + const events = wallet.apply_block_events(block, newHeight); + + expect(block.prev_blockhash).toBe(tipBefore.hash); + expect(block.block_hash).toBe(getBlockHash(newHeight)); + expect(block.tx_count).toBeGreaterThan(0); + expect( + block.txdata.some( + (candidate) => candidate.compute_txid().toString() === txid.toString() + ) + ).toBe(true); + + const confirmedEvent = events.find( + (event) => event.txid?.toString() === txid.toString() + ); + expect(confirmedEvent).toBeDefined(); + expect(confirmedEvent!.kind).toBe("tx_confirmed"); + expect(confirmedEvent!.block_time!.block_id.height).toBe(newHeight); + + const chainTipEvent = events.find( + (event) => event.kind === "chain_tip_changed" + ); + expect(chainTipEvent).toBeDefined(); + expect(chainTipEvent!.old_tip!.height).toBe(tipBefore.height); + expect(chainTipEvent!.new_tip!.hash).toBe(block.block_hash); + + const details = wallet.tx_details(txid); + expect(details).toBeDefined(); + expect(details!.chain_position.is_confirmed).toBe(true); + expect(details!.chain_position.anchor!.block_id.height).toBe(newHeight); + + const checkpoints = wallet.checkpoints(); + expect( + checkpoints.some( + (checkpoint) => + checkpoint.height === newHeight && checkpoint.hash === block.block_hash + ) + ).toBe(true); + }, 30000); + + it("applies a mined block with an explicit connection point", async () => { + const previousTip = wallet.latest_checkpoint; + const recipientAddress = wallet.reveal_next_address("external"); + const sendAmount = Amount.from_sat(BigInt(7000)); + + const psbt = wallet + .build_tx() + .fee_rate(new FeeRate(BigInt(1))) + .add_recipient( + new Recipient(recipientAddress.address.script_pubkey, sendAmount) + ) + .finish(); + + expect(wallet.sign(psbt, new SignOptions())).toBe(true); + + const tx = psbt.extract_tx(); + const txid = tx.compute_txid(); + await esploraClient.broadcast(tx); + + mineBlocks(1); + + const newHeight = previousTip.height + 1; + const block = getBlock(newHeight); + const connectedTo = new BlockId(previousTip.height, previousTip.hash); + const events = wallet.apply_block_connected_to_events( + block, + newHeight, + connectedTo + ); + + const confirmedEvent = events.find( + (event) => event.txid?.toString() === txid.toString() + ); + expect(confirmedEvent).toBeDefined(); + expect(confirmedEvent!.kind).toBe("tx_confirmed"); + expect(wallet.latest_checkpoint.height).toBe(newHeight); + expect(wallet.latest_checkpoint.hash).toBe(block.block_hash); + + expect(wallet.latest_checkpoint.prev!.hash).toBe(previousTip.hash); + }, 30000); + + it("rejects blocks with the wrong connected_to hash", () => { + const previousTip = wallet.latest_checkpoint; + mineBlocks(1); + + const newHeight = previousTip.height + 1; + const block = getBlock(newHeight); + const wrongHash = wallet + .checkpoints() + .find((checkpoint) => checkpoint.hash !== previousTip.hash)!.hash; + const wrongConnectedTo = new BlockId(previousTip.height, wrongHash); + + try { + wallet.apply_block_connected_to_events(block, newHeight, wrongConnectedTo); + throw new Error("Expected apply_block_connected_to_events to throw"); + } catch (error) { + expect(error).toBeInstanceOf(BdkError); + expect((error as BdkError).code).toBe( + BdkErrorCode.UnexpectedConnectedToHash + ); + } + + expect(wallet.latest_checkpoint.height).toBe(previousTip.height); + expect(wallet.latest_checkpoint.hash).toBe(previousTip.hash); + + const connectedTo = new BlockId(previousTip.height, previousTip.hash); + const events = wallet.apply_block_connected_to_events( + block, + newHeight, + connectedTo + ); + expect( + events.some((event) => event.kind === "chain_tip_changed") + ).toBe(true); + expect(wallet.latest_checkpoint.height).toBe(newHeight); + }); + + it("drops evicted mempool transactions from canonical history", () => { + const recipientAddress = wallet.reveal_next_address("external"); + const sendAmount = Amount.from_sat(BigInt(9000)); + const firstSeen = BigInt(Math.floor(Date.now() / 1000)); + + const psbt = wallet + .build_tx() + .fee_rate(new FeeRate(BigInt(1))) + .add_recipient( + new Recipient(recipientAddress.address.script_pubkey, sendAmount) + ) + .finish(); + + expect(wallet.sign(psbt, new SignOptions())).toBe(true); + + const tx = psbt.extract_tx(); + const txid = tx.compute_txid(); + const txidString = txid.toString(); + wallet.apply_unconfirmed_txs([new UnconfirmedTx(tx, firstSeen)]); + + expect( + wallet + .transactions() + .some((candidate) => candidate.txid.toString() === txidString) + ).toBe(true); + + wallet.apply_evicted_txs([new EvictedTx(txid, firstSeen + BigInt(1))]); + + expect( + wallet + .transactions() + .some((candidate) => candidate.txid.toString() === txidString) + ).toBe(false); + }); +}); diff --git a/tests/node/integration/wallet.test.ts b/tests/node/integration/wallet.test.ts index 123b591..7728496 100644 --- a/tests/node/integration/wallet.test.ts +++ b/tests/node/integration/wallet.test.ts @@ -3,7 +3,10 @@ import { Amount, BdkError, BdkErrorCode, + Block, + BlockId, ChangeSpendPolicy, + EvictedTx, FeeRate, OutPoint, Recipient, @@ -397,4 +400,56 @@ describe("Wallet", () => { expect(freshWallet.tx_details(txid)).toBeUndefined(); }); }); + + describe("BlockId", () => { + it("creates from height and hash string", () => { + const hash = + "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f"; + const blockId = new BlockId(0, hash); + + expect(blockId.height).toBe(0); + expect(blockId.hash).toBe(hash); + }); + + it("throws for an invalid hash string", () => { + expect(() => new BlockId(0, "not-a-hash")).toThrow(); + }); + }); + + describe("Block", () => { + it("throws for invalid bytes", () => { + expect(() => Block.from_bytes(new Uint8Array([0, 1, 2]))).toThrow(); + }); + }); + + describe("EvictedTx", () => { + it("creates from txid and timestamp", () => { + const txid = Txid.from_string( + "0000000000000000000000000000000000000000000000000000000000000000" + ); + const evicted = new EvictedTx(txid, BigInt(1700000000)); + + expect(evicted).toBeDefined(); + }); + }); + + describe("apply_evicted_txs", () => { + it("is callable with an empty list (no-op)", () => { + const freshWallet = Wallet.create(network, externalDesc, internalDesc); + // Applying an empty eviction list should be a no-op and not throw + freshWallet.apply_evicted_txs([]); + expect(freshWallet.transactions().length).toBe(0); + }); + }); + + describe("checkpoints", () => { + it("returns at least the genesis checkpoint for a fresh wallet", () => { + const freshWallet = Wallet.create(network, externalDesc, internalDesc); + const cps = freshWallet.checkpoints(); + + // A fresh wallet should have at least the genesis checkpoint + expect(cps.length).toBeGreaterThanOrEqual(1); + expect(cps[0].height).toBe(0); + }); + }); });