diff --git a/CHANGELOG.md b/CHANGELOG.md index 451d1b1..a0c2fc5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/bitcoin/wallet.rs b/src/bitcoin/wallet.rs index 9a1a328..964b757 100644 --- a/src/bitcoin/wallet.rs +++ b/src/bitcoin/wallet.rs @@ -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, }, }; @@ -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 { + 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 { + self.0.borrow().tx_details(txid.into()).map(Into::into) + } + pub fn apply_unconfirmed_txs(&self, unconfirmed_txs: Vec) { self.0 .borrow_mut() diff --git a/src/types/mod.rs b/src/types/mod.rs index 492e1f2..c89c3d9 100644 --- a/src/types/mod.rs +++ b/src/types/mod.rs @@ -16,6 +16,7 @@ mod psbt; mod script; mod slip10; mod transaction; +mod tx_details; pub use address::*; pub use amount::*; @@ -35,3 +36,4 @@ pub use psbt::*; pub use script::*; pub use slip10::*; pub use transaction::*; +pub use tx_details::*; diff --git a/src/types/tx_details.rs b/src/types/tx_details.rs new file mode 100644 index 0000000..d6cdebb --- /dev/null +++ b/src/types/tx_details.rs @@ -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, + fee_rate: Option, + balance_delta_sat: i64, + chain_position: bdk_wallet::chain::ChainPosition, + 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 { + 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 { + 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 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(), + } + } +} diff --git a/tests/node/integration/esplora.test.ts b/tests/node/integration/esplora.test.ts index 5c79823..276f5e9 100644 --- a/tests/node/integration/esplora.test.ts +++ b/tests/node/integration/esplora.test.ts @@ -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); diff --git a/tests/node/integration/events.test.ts b/tests/node/integration/events.test.ts index 5d1d1e4..f8b1195 100644 --- a/tests/node/integration/events.test.ts +++ b/tests/node/integration/events.test.ts @@ -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(); @@ -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(); diff --git a/tests/node/integration/wallet.test.ts b/tests/node/integration/wallet.test.ts index 0c0fa10..123b591 100644 --- a/tests/node/integration/wallet.test.ts +++ b/tests/node/integration/wallet.test.ts @@ -302,4 +302,99 @@ describe("Wallet", () => { expect(data.available).toBeDefined(); } }); + + describe("descriptor_checksum", () => { + it("returns a non-empty checksum string", () => { + const checksum = wallet.descriptor_checksum("external"); + + expect(typeof checksum).toBe("string"); + expect(checksum.length).toBeGreaterThan(0); + // Descriptor checksums are 8 characters of bech32 + expect(checksum.length).toBe(8); + }); + + it("returns different checksums for external and internal keychains", () => { + const externalChecksum = wallet.descriptor_checksum("external"); + const internalChecksum = wallet.descriptor_checksum("internal"); + + expect(externalChecksum).not.toBe(internalChecksum); + }); + }); + + describe("next_derivation_index", () => { + it("returns 0 for a fresh wallet with no revealed addresses", () => { + const freshWallet = Wallet.create(network, externalDesc, internalDesc); + const index = freshWallet.next_derivation_index("external"); + + expect(typeof index).toBe("number"); + expect(index).toBe(0); + }); + + it("increments after revealing an address", () => { + const freshWallet = Wallet.create(network, externalDesc, internalDesc); + freshWallet.reveal_next_address("external"); + const index = freshWallet.next_derivation_index("external"); + + expect(index).toBe(1); + }); + + it("is consistent with derivation_index", () => { + const freshWallet = Wallet.create(network, externalDesc, internalDesc); + freshWallet.reveal_next_address("external"); + freshWallet.reveal_next_address("external"); + + const derivIndex = freshWallet.derivation_index("external"); + const nextIndex = freshWallet.next_derivation_index("external"); + + // next_derivation_index should be derivation_index + 1 + expect(nextIndex).toBe(derivIndex! + 1); + }); + }); + + describe("cancel_tx", () => { + it("frees the reserved change address after cancellation", () => { + // cancel_tx unmarks change addresses reserved during build_tx, + // making them available for future transactions. + // Without funds we can't build a real tx, so we verify the method + // is callable and does not throw on an empty wallet. + const freshWallet = Wallet.create(network, externalDesc, internalDesc); + + // Get the internal derivation index before and after cancel + const indexBefore = freshWallet.next_derivation_index("internal"); + // cancel_tx on a wallet with no pending tx is a no-op but must not throw. + // Note: cancel_tx takes a Transaction, not a Txid. We test the full + // flow in esplora integration tests where we have funded wallets. + expect(typeof freshWallet.cancel_tx).toBe("function"); + const indexAfter = freshWallet.next_derivation_index("internal"); + expect(indexAfter).toBe(indexBefore); + }); + }); + + describe("finalize_psbt", () => { + it("throws when finalizing an unsigned PSBT", () => { + // finalize_psbt requires a signed PSBT. Attempting to finalize + // without signatures should fail. Without funds we can't create + // a real PSBT, so we verify the method signature is correct. + // Full sign + finalize flow is tested in esplora integration tests. + expect(typeof wallet.finalize_psbt).toBe("function"); + }); + }); + + describe("tx_details", () => { + it("returns undefined for a non-existent txid", () => { + const unknownTxid = Txid.from_string( + "0000000000000000000000000000000000000000000000000000000000000000" + ); + const details = wallet.tx_details(unknownTxid); + expect(details).toBeUndefined(); + }); + + it("returns undefined on a fresh wallet with no transactions", () => { + const freshWallet = Wallet.create(network, externalDesc, internalDesc); + const txid = Txid.from_string( + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + ); + expect(freshWallet.tx_details(txid)).toBeUndefined(); + }); + }); });