Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Expand Wallet API surface ([#21](https://github.com/bitcoindevkit/bdk-wasm/issues/21)):
- `Wallet::finalize_psbt` for finalizing PSBTs (adding finalized script/witness to inputs)
- `Wallet::cancel_tx` for releasing reserved change addresses when a transaction won't be broadcast
- `Wallet::tx_details` for retrieving comprehensive transaction details (sent, received, fee, fee rate, balance delta, chain position)
- `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`

## [0.3.0] - 2026-03-16

### Added
Expand Down
47 changes: 45 additions & 2 deletions src/bitcoin/wallet.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ use crate::{
result::JsResult,
types::{
AddressInfo, Amount, Balance, ChangeSet, CheckPoint, FeeRate, FullScanRequest, KeychainKind, LocalOutput,
Network, NetworkKind, OutPoint, Psbt, ScriptBuf, SentAndReceived, SpkIndexed, SyncRequest, Transaction, TxOut,
Txid, Update, WalletEvent,
Network, NetworkKind, OutPoint, Psbt, ScriptBuf, SentAndReceived, SpkIndexed, SyncRequest, Transaction,
TxDetails, TxOut, Txid, Update, WalletEvent,
},
};

Expand Down Expand Up @@ -265,6 +265,49 @@ impl Wallet {
.map(|(keychain, index)| SpkIndexed(keychain.into(), index))
}

/// Finalize a PSBT, putting the finalized script and witness values into the inputs.
///
/// Returns `true` if the PSBT was fully finalized, `false` if some inputs could not
/// be finalized. Use `SignOptions::try_finalize` to control whether finalization is
/// attempted.
pub fn finalize_psbt(&self, psbt: &mut Psbt, options: SignOptions) -> JsResult<bool> {
let result = self.0.borrow().finalize_psbt(psbt, options.into())?;
Ok(result)
}

/// Inform the wallet that a transaction built from it will not be broadcast.
///
/// This frees up the change address that was reserved when creating the transaction,
/// making it available for future transactions.
pub fn cancel_tx(&self, tx: &Transaction) {
self.0.borrow_mut().cancel_tx(tx);
}

/// Get the descriptor checksum for the given keychain.
///
/// Returns the checksum portion of the descriptor string (the part after `#`).
pub fn descriptor_checksum(&self, keychain: KeychainKind) -> String {
self.0.borrow().descriptor_checksum(keychain.into())
}

/// Get the next derivation index for the given keychain.
///
/// This is one more than the highest index that has been derived so far.
/// Unlike `derivation_index`, this always returns a value (0 if nothing has been derived).
pub fn next_derivation_index(&self, keychain: KeychainKind) -> u32 {
self.0.borrow().next_derivation_index(keychain.into())
}

/// Get detailed information about a transaction in the wallet.
///
/// Returns `TxDetails` containing the sent/received amounts, fee, fee rate,
/// balance delta, chain position, and the full transaction.
///
/// Returns `None` if the transaction is not found in the wallet.
pub fn tx_details(&self, txid: Txid) -> Option<TxDetails> {
self.0.borrow().tx_details(txid.into()).map(Into::into)
}

pub fn apply_unconfirmed_txs(&self, unconfirmed_txs: Vec<UnconfirmedTx>) {
self.0
.borrow_mut()
Expand Down
2 changes: 2 additions & 0 deletions src/types/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ mod psbt;
mod script;
mod slip10;
mod transaction;
mod tx_details;

pub use address::*;
pub use amount::*;
Expand All @@ -35,3 +36,4 @@ pub use psbt::*;
pub use script::*;
pub use slip10::*;
pub use transaction::*;
pub use tx_details::*;
98 changes: 98 additions & 0 deletions src/types/tx_details.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
use bdk_wallet::TxDetails as BdkTxDetails;
use wasm_bindgen::prelude::wasm_bindgen;

use super::{Amount, ChainPosition, FeeRate, Transaction, Txid};

/// Detailed information about a wallet transaction.
///
/// This type provides a comprehensive view of a transaction from the wallet's perspective,
/// including sent/received amounts, fees, fee rate, balance delta, and chain position.
///
/// Obtain a `TxDetails` by calling `Wallet::tx_details(txid)`.
#[wasm_bindgen]
pub struct TxDetails {
txid: bitcoin::Txid,
sent: bitcoin::Amount,
received: bitcoin::Amount,
fee: Option<bitcoin::Amount>,
fee_rate: Option<bitcoin::FeeRate>,
balance_delta_sat: i64,
chain_position: bdk_wallet::chain::ChainPosition<bdk_wallet::chain::ConfirmationBlockTime>,
tx: bitcoin::Transaction,
}

#[wasm_bindgen]
impl TxDetails {
/// The transaction id.
#[wasm_bindgen(getter)]
pub fn txid(&self) -> Txid {
self.txid.into()
}

/// The sum of the transaction input amounts that spend from previous outputs
/// tracked by this wallet.
#[wasm_bindgen(getter)]
pub fn sent(&self) -> Amount {
self.sent.into()
}

/// The sum of the transaction outputs that send to script pubkeys tracked by
/// this wallet.
#[wasm_bindgen(getter)]
pub fn received(&self) -> Amount {
self.received.into()
}

/// The fee paid for the transaction, if known.
///
/// This will be `None` if the transaction has inputs not owned by this wallet
/// and their `TxOut` values have not been inserted via `Wallet::insert_txout`.
#[wasm_bindgen(getter)]
pub fn fee(&self) -> Option<Amount> {
self.fee.map(Into::into)
}

/// The fee rate paid for the transaction, if known.
///
/// Same conditions as `fee` for when this is `None`.
#[wasm_bindgen(getter)]
pub fn fee_rate(&self) -> Option<FeeRate> {
self.fee_rate.map(Into::into)
}

/// The net effect of the transaction on the wallet balance, in satoshis.
///
/// Positive values mean the wallet received more than it spent (net inflow).
/// Negative values mean the wallet spent more than it received (net outflow).
#[wasm_bindgen(getter)]
pub fn balance_delta_sat(&self) -> i64 {
self.balance_delta_sat
}

/// The position of the transaction in the chain (confirmed or unconfirmed).
#[wasm_bindgen(getter)]
pub fn chain_position(&self) -> ChainPosition {
self.chain_position.into()
}

/// The complete transaction.
#[wasm_bindgen(getter)]
pub fn tx(&self) -> Transaction {
self.tx.clone().into()
}
}

impl From<BdkTxDetails> for TxDetails {
fn from(details: BdkTxDetails) -> Self {
TxDetails {
txid: details.txid,
sent: details.sent,
received: details.received,
fee: details.fee,
fee_rate: details.fee_rate,
balance_delta_sat: details.balance_delta.to_sat(),
chain_position: details.chain_position,
tx: details.tx.as_ref().clone(),
}
}
}
127 changes: 127 additions & 0 deletions tests/node/integration/esplora.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,133 @@ describe(`Esplora client (${network})`, () => {
expect(walletTx.chain_position.is_confirmed).toBe(false);
}, 30000);

it("returns tx_details for a known transaction", () => {
// After the "sends a transaction" test, the wallet has at least one tx
const txs = wallet.transactions();
expect(txs.length).toBeGreaterThan(0);

// Find a transaction where we sent funds (the self-send from the previous test).
// The funding tx from the faucet has sent=0, so we pick one where tx_details
// reports sent > 0.
let walletTx = txs[0];
for (const tx of txs) {
const d = wallet.tx_details(tx.txid);
if (d && d.sent.to_sat() > BigInt(0)) {
walletTx = tx;
break;
}
}

const details = wallet.tx_details(walletTx.txid);

expect(details).toBeDefined();
expect(details!.txid.toString()).toBe(walletTx.txid.toString());
// For the self-send tx, both sent and received should be > 0
expect(details!.sent.to_sat()).toBeGreaterThan(BigInt(0));
expect(details!.received.to_sat()).toBeGreaterThan(BigInt(0));
// Fee should be known for our own transaction
expect(details!.fee).toBeDefined();
expect(details!.fee!.to_sat()).toBeGreaterThan(BigInt(0));
// Fee rate should also be available
expect(details!.fee_rate).toBeDefined();
// balance_delta_sat for a self-send is negative (we paid fees)
expect(details!.balance_delta_sat).toBeLessThan(BigInt(0));
// Chain position should exist
expect(details!.chain_position).toBeDefined();
expect(details!.chain_position.is_confirmed).toBe(false);
// The full transaction should be accessible
expect(details!.tx).toBeDefined();
expect(details!.tx.compute_txid().toString()).toBe(
walletTx.txid.toString()
);
});

it("signs and finalizes a PSBT separately", () => {
const recipientAddress = wallet.peek_address("external", 6);
const sendAmount = Amount.from_sat(BigInt(800));

const psbt = wallet
.build_tx()
.fee_rate(new FeeRate(BigInt(1)))
.add_recipient(
new Recipient(recipientAddress.address.script_pubkey, sendAmount)
)
.finish();

// Sign without auto-finalize: sign() returns false because it reports
// finalization status, not signing status. With try_finalize=false,
// finalization is skipped so it returns false even though signing succeeded.
const signOpts = new SignOptions();
signOpts.try_finalize = false;
const signed = wallet.sign(psbt, signOpts);
expect(signed).toBe(false);

// Now finalize separately — this should succeed since signing is done
const finalizeOpts = new SignOptions();
const finalized = wallet.finalize_psbt(psbt, finalizeOpts);
expect(finalized).toBeTruthy();

// The finalized PSBT should be extractable
const tx = psbt.extract_tx();
expect(tx.compute_txid()).toBeDefined();
});

it("cancel_tx frees the change address from a non-broadcast transaction", () => {
// Use a small send relative to balance to guarantee a change output is created.
// This ensures BDK reveals a new internal (change) address.
const balance = wallet.balance.trusted_spendable.to_sat();
const sendSats = balance / BigInt(4); // 25% of balance => guaranteed change
expect(sendSats).toBeGreaterThan(BigInt(546)); // sanity check

// Record the internal derivation index before building a new transaction
const indexBefore = wallet.next_derivation_index("internal");

// Build a transaction (which reveals a new change address internally)
const recipientAddress = wallet.peek_address("external", 7);
const sendAmount = Amount.from_sat(sendSats);

const psbt = wallet
.build_tx()
.fee_rate(new FeeRate(BigInt(1)))
.add_recipient(
new Recipient(recipientAddress.address.script_pubkey, sendAmount)
)
.finish();

const tx = psbt.unsigned_tx;

// Building the tx should have advanced the internal derivation index
// (a new change address was revealed because change > dust threshold)
const indexAfterBuild = wallet.next_derivation_index("internal");
expect(indexAfterBuild).toBeGreaterThan(indexBefore);

// Cancel the transaction — should not throw and should unmark the change address
wallet.cancel_tx(tx);

// The derivation index doesn't go back (addresses are revealed permanently),
// but the change address should now be "unused" (unmarked). We verify by
// building another tx and checking it reuses the same change index.
// Note: wasm-bindgen takes ownership of ScriptBuf and Amount, so we must
// create fresh instances for each Recipient.
const recipientAddress2 = wallet.peek_address("external", 7);
const sendAmount2 = Amount.from_sat(sendSats);
const psbt2 = wallet
.build_tx()
.fee_rate(new FeeRate(BigInt(1)))
.add_recipient(
new Recipient(recipientAddress2.address.script_pubkey, sendAmount2)
)
.finish();

// After cancel, building a new tx should reuse the freed change index,
// so the derivation index should NOT advance further
const indexAfterRebuild = wallet.next_derivation_index("internal");
expect(indexAfterRebuild).toBe(indexAfterBuild);

// Clean up: cancel the second tx too
wallet.cancel_tx(psbt2.unsigned_tx);
});

it("excludes utxos from a transaction", () => {
const utxos = wallet.list_unspent();
expect(utxos.length).toBeGreaterThan(0);
Expand Down
46 changes: 41 additions & 5 deletions tests/node/integration/events.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,17 +80,38 @@ describeRegtest("Wallet events (regtest)", () => {

// Fund this wallet via regtest faucet (separate from esplora test wallet)
const address = wallet.peek_address("external", 0).address.toString();
execSync(
const txid = execSync(
`docker exec esplora-regtest cli -regtest -rpcwallet=default sendtoaddress ${address} 1.0`,
{ encoding: "utf-8" }
);
).trim();
// Mine to confirm the funding tx
mineBlocks(1);
// Wait for Esplora to index
// Wait for Esplora to index the new block
const res = await fetch(`${esploraUrl}/blocks/tip/height`);
const currentHeight = parseInt(await res.text(), 10);
await waitForEsploraHeight(currentHeight);
});
// Wait for the funding tx to be confirmed AND indexed at the address level.
// Esplora indexes blocks, then transactions, then address histories separately.
// The full_scan queries by script pubkey, so we must wait for the address index.
const txStart = Date.now();
while (Date.now() - txStart < 30000) {
try {
const addrRes = await fetch(`${esploraUrl}/address/${address}/txs`);
if (addrRes.ok) {
const txs = await addrRes.json();
if (
Array.isArray(txs) &&
txs.some((t: { txid: string }) => t.txid === txid)
) {
break;
}
}
} catch {
// Esplora hasn't indexed the address yet
}
await new Promise((r) => setTimeout(r, 1000));
}
}, 60000);

it("returns events on initial full scan", async () => {
const request = wallet.start_full_scan();
Expand Down Expand Up @@ -154,8 +175,23 @@ describeRegtest("Wallet events (regtest)", () => {
// Mine blocks to confirm
mineBlocks(1);

// Wait for Esplora to index the new block
// Wait for Esplora to index the new block AND the confirmed tx
await waitForEsploraHeight(tipBefore + 1);
// Also wait for the tx to appear as confirmed in Esplora
const txidStr = txid.toString();
const waitStart = Date.now();
while (Date.now() - waitStart < 15000) {
try {
const txRes = await fetch(`${esploraUrl}/tx/${txidStr}`);
if (txRes.ok) {
const txData = await txRes.json();
if (txData.status?.confirmed) break;
}
} catch {
// not indexed yet
}
await new Promise((r) => setTimeout(r, 500));
}

// Sync and get events
const syncRequest = wallet.start_sync_with_revealed_spks();
Expand Down
Loading
Loading