Skip to content
This repository was archived by the owner on Aug 21, 2025. It is now read-only.
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
49 changes: 44 additions & 5 deletions src/bitcoin/tx_builder.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
use std::{cell::RefCell, rc::Rc};

use bdk_wallet::{bitcoin::ScriptBuf, error::CreateTxError, Wallet as BdkWallet};
use bdk_wallet::{error::CreateTxError, TxOrdering as BdkTxOrdering, Wallet as BdkWallet};
use serde::Serialize;
use wasm_bindgen::prelude::wasm_bindgen;

use crate::types::{Address, Amount, BdkError, BdkErrorCode, FeeRate, OutPoint, Psbt, Recipient};
use crate::types::{Amount, BdkError, BdkErrorCode, FeeRate, OutPoint, Psbt, Recipient, ScriptBuf};

/// A transaction builder.
///
Expand All @@ -22,6 +22,7 @@ pub struct TxBuilder {
drain_wallet: bool,
drain_to: Option<ScriptBuf>,
allow_dust: bool,
ordering: TxOrdering,
}

#[wasm_bindgen]
Expand All @@ -36,6 +37,7 @@ impl TxBuilder {
drain_wallet: false,
allow_dust: false,
drain_to: None,
ordering: BdkTxOrdering::default().into(),
}
}

Expand Down Expand Up @@ -95,8 +97,8 @@ impl TxBuilder {
///
/// If you choose not to set any recipients, you should provide the utxos that the
/// transaction should spend via [`add_utxos`].
pub fn drain_to(mut self, address: Address) -> Self {
self.drain_to = Some(address.script_pubkey());
pub fn drain_to(mut self, script_pubkey: ScriptBuf) -> Self {
self.drain_to = Some(script_pubkey);
self
}

Expand All @@ -108,6 +110,12 @@ impl TxBuilder {
self
}

/// Choose the ordering for inputs and outputs of the transaction
pub fn ordering(mut self, ordering: TxOrdering) -> Self {
self.ordering = ordering;
self
}

/// Finish building the transaction.
///
/// Returns a new [`Psbt`] per [`BIP174`].
Expand All @@ -116,6 +124,7 @@ impl TxBuilder {
let mut builder = wallet.build_tx();

builder
.ordering(self.ordering.into())
.set_recipients(self.recipients.into_iter().map(Into::into).collect())
.unspendable(self.unspendable.into_iter().map(Into::into).collect())
.fee_rate(self.fee_rate.into())
Expand All @@ -126,14 +135,44 @@ impl TxBuilder {
}

if let Some(drain_recipient) = self.drain_to {
builder.drain_to(drain_recipient);
builder.drain_to(drain_recipient.into());
}

let psbt = builder.finish()?;
Ok(psbt.into())
}
}

/// Ordering of the transaction's inputs and outputs
#[derive(Clone, Default)]
#[wasm_bindgen]
pub enum TxOrdering {
/// Randomized (default)
#[default]
Shuffle,
/// Unchanged
Untouched,
}

impl From<BdkTxOrdering> for TxOrdering {
fn from(ordering: BdkTxOrdering) -> Self {
match ordering {
BdkTxOrdering::Shuffle => TxOrdering::Shuffle,
BdkTxOrdering::Untouched => TxOrdering::Untouched,
_ => panic!("Unsupported ordering"),
}
}
}

impl From<TxOrdering> for BdkTxOrdering {
fn from(ordering: TxOrdering) -> Self {
match ordering {
TxOrdering::Shuffle => BdkTxOrdering::Shuffle,
TxOrdering::Untouched => BdkTxOrdering::Untouched,
}
}
}

/// Wallet's UTXO set is not enough to cover recipient's requested plus fee.
#[wasm_bindgen]
#[derive(Clone, Serialize)]
Expand Down
27 changes: 27 additions & 0 deletions src/types/address.rs
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,11 @@ impl Address {
pub fn to_string(&self) -> String {
self.0.to_string()
}

#[wasm_bindgen(getter)]
pub fn script_pubkey(&self) -> ScriptBuf {
self.0.script_pubkey().into()
}
}

impl From<BdkAddress> for Address {
Expand Down Expand Up @@ -133,6 +138,7 @@ impl From<ParseError> for BdkError {
/// `ScriptBuf` is the most common script type that has the ownership over the contents of the
/// script. It has a close relationship with its borrowed counterpart, [`Script`].
#[wasm_bindgen]
#[derive(Clone)]
pub struct ScriptBuf(BdkScriptBuf);

impl Deref for ScriptBuf {
Expand All @@ -145,6 +151,15 @@ impl Deref for ScriptBuf {

#[wasm_bindgen]
impl ScriptBuf {
pub fn from_hex(s: &str) -> JsResult<Self> {
let script = BdkScriptBuf::from_hex(s)?;
Ok(script.into())
}

pub fn from_bytes(bytes: Vec<u8>) -> Self {
BdkScriptBuf::from_bytes(bytes).into()
}

#[allow(clippy::inherent_to_string)]
#[wasm_bindgen(js_name = toString)]
pub fn to_string(&self) -> String {
Expand All @@ -154,6 +169,18 @@ impl ScriptBuf {
pub fn as_bytes(&self) -> Vec<u8> {
self.0.as_bytes().to_vec()
}

pub fn to_asm_string(&self) -> String {
self.0.to_asm_string()
}

pub fn to_hex_string(&self) -> String {
self.0.to_hex_string()
}

pub fn is_op_return(&self) -> bool {
self.0.is_op_return()
}
}

impl From<BdkScriptBuf> for ScriptBuf {
Expand Down
66 changes: 58 additions & 8 deletions src/types/psbt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ use bdk_wallet::{
use wasm_bindgen::prelude::wasm_bindgen;

use crate::result::JsResult;
use crate::types::ScriptBuf;

use super::{Address, Amount, FeeRate, Transaction};

Expand All @@ -33,11 +34,25 @@ impl DerefMut for Psbt {

#[wasm_bindgen]
impl Psbt {
/// Extracts the [`Transaction`] from a [`Psbt`] by filling in the available signature information.
///
/// ## Errors
///
/// [`ExtractTxError`] variants will contain either the [`Psbt`] itself or the [`Transaction`]
/// that was extracted. These can be extracted from the Errors in order to recover.
/// See the error documentation for info on the variants. In general, it covers large fees.
pub fn extract_tx_fee_rate_limit(self) -> JsResult<Transaction> {
let tx = self.0.extract_tx_fee_rate_limit()?;
Ok(tx.into())
}

/// An alias for [`extract_tx_fee_rate_limit`].
pub fn extract_tx(self) -> JsResult<Transaction> {
let tx = self.0.extract_tx()?;
Ok(tx.into())
}

/// Extracts the [`Transaction`] from a [`Psbt`] by filling in the available signature information.
pub fn extract_tx_with_fee_rate_limit(self, max_fee_rate: FeeRate) -> JsResult<Transaction> {
let tx = self.0.extract_tx_with_fee_rate_limit(max_fee_rate.into())?;
Ok(tx.into())
Expand All @@ -48,16 +63,41 @@ impl Psbt {
Ok(fee.into())
}

/// The total transaction fee amount, sum of input amounts minus sum of output amounts, in sats.
/// If the PSBT is missing a TxOut for an input returns None.
pub fn fee_amount(&self) -> Option<Amount> {
let fee_amount = self.0.fee_amount();
fee_amount.map(Into::into)
}

/// The transaction's fee rate. This value will only be accurate if calculated AFTER the
/// `Psbt` is finalized and all witness/signature data is added to the transaction.
/// If the PSBT is missing a TxOut for an input returns None.
pub fn fee_rate(&self) -> Option<FeeRate> {
let fee_rate = self.0.fee_rate();
fee_rate.map(Into::into)
}

/// The version number of this PSBT. If omitted, the version number is 0.
#[wasm_bindgen(getter)]
pub fn version(&self) -> u32 {
self.0.version
}

/// Combines this [`Psbt`] with `other` PSBT as described by BIP 174. In-place.
///
/// In accordance with BIP 174 this function is commutative i.e., `A.combine(B) == B.combine(A)`
pub fn combine(&mut self, other: Psbt) -> JsResult<()> {
self.0.combine(other.into())?;
Ok(())
}

/// The unsigned transaction, scriptSigs and witnesses for each input must be empty.
#[wasm_bindgen(getter)]
pub fn unsigned_tx(&self) -> Transaction {
self.0.unsigned_tx.clone().into()
}

/// Serialize the PSBT to a string in base64 format
#[allow(clippy::inherent_to_string)]
#[wasm_bindgen(js_name = toString)]
Expand Down Expand Up @@ -92,30 +132,40 @@ impl From<Psbt> for BdkPsbt {
#[wasm_bindgen]
#[derive(Clone)]
pub struct Recipient {
address: Address,
amount: Amount,
script_pubkey: BdkScriptBuf,
amount: BdkAmount,
}

#[wasm_bindgen]
impl Recipient {
#[wasm_bindgen(constructor)]
pub fn new(address: Address, amount: Amount) -> Self {
Recipient { address, amount }
pub fn new(script_pubkey: ScriptBuf, amount: Amount) -> Self {
Recipient {
script_pubkey: script_pubkey.into(),
amount: amount.into(),
}
}

pub fn from_address(address: Address, amount: Amount) -> Self {
Recipient {
script_pubkey: address.script_pubkey().into(),
amount: amount.into(),
}
}

#[wasm_bindgen(getter)]
pub fn address(&self) -> Address {
self.address.clone()
pub fn script_pubkey(&self) -> ScriptBuf {
self.script_pubkey.clone().into()
}

#[wasm_bindgen(getter)]
pub fn amount(&self) -> Amount {
self.amount
self.amount.into()
}
}

impl From<Recipient> for (BdkScriptBuf, BdkAmount) {
fn from(r: Recipient) -> Self {
(r.address().script_pubkey(), r.amount().into())
(r.script_pubkey.clone(), r.amount)
}
}
51 changes: 51 additions & 0 deletions tests/node/integration/errors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import {
Address,
Amount,
BdkError,
BdkErrorCode,
} from "../../../pkg/bitcoindevkit";
import type { Network } from "../../../pkg/bitcoindevkit";

describe("Wallet", () => {
const network: Network = "testnet";

it("catches fine-grained address errors", () => {
try {
Address.from_string(
"tb1qd28npep0s8frcm3y7dxqajkcy2m40eysplyr9v",
"bitcoin"
);
} catch (error) {
expect(error).toBeInstanceOf(BdkError);

const { code, message, data } = error;
expect(code).toBe(BdkErrorCode.NetworkValidation);
expect(message.startsWith("validation error")).toBe(true);
expect(data).toBeUndefined();
}

try {
Address.from_string("notAnAddress", network);
} catch (error) {
expect(error).toBeInstanceOf(BdkError);

const { code, message, data } = error;
expect(code).toBe(BdkErrorCode.Base58);
expect(message.startsWith("base58 error")).toBe(true);
expect(data).toBeUndefined();
}
});

it("catches fine-grained amount errors", () => {
try {
Amount.from_btc(-100000000);
} catch (error) {
expect(error).toBeInstanceOf(BdkError);

const { code, message, data } = error;
expect(code).toBe(BdkErrorCode.OutOfRange);
expect(message.startsWith("amount out of range")).toBe(true);
expect(data).toBeUndefined();
}
});
});
30 changes: 29 additions & 1 deletion tests/node/integration/esplora.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import {
UnconfirmedTx,
Wallet,
SignOptions,
Psbt,
TxOrdering,
} from "../../../pkg/bitcoindevkit";

// Tests are expected to run in order
Expand Down Expand Up @@ -69,7 +71,7 @@ describe("Esplora client", () => {
const psbt = wallet
.build_tx()
.fee_rate(feeRate)
.add_recipient(new Recipient(recipientAddress, sendAmount))
.add_recipient(new Recipient(recipientAddress.script_pubkey, sendAmount))
.finish();

expect(psbt.fee().to_sat()).toBeGreaterThan(100); // We cannot know the exact fees
Expand Down Expand Up @@ -105,4 +107,30 @@ describe("Esplora client", () => {
.finish();
}).toThrow();
});

it("fills inputs of an output-only Psbt", () => {
const psbtBase64 =
"cHNidP8BAI4CAAAAAAM1gwEAAAAAACJRIORP1Ndiq325lSC/jMG0RlhATHYmuuULfXgEHUM3u5i4AAAAAAAAAAAxai8AAUSx+i9Igg4HWdcpyagCs8mzuRCklgA7nRMkm69rAAAAAAAAAAAAAQACAAAAACp2AAAAAAAAFgAUArpyBMj+3+/wQDj+orDWG4y4yfUAAAAAAAAAAAA=";
const template = Psbt.from_string(psbtBase64);

let builder = wallet
.build_tx()
.fee_rate(new FeeRate(BigInt(1)))
.ordering(TxOrdering.Untouched);

for (const txout of template.unsigned_tx.output) {
if (wallet.is_mine(txout.script_pubkey)) {
builder = builder.drain_to(txout.script_pubkey);
} else {
const recipient = new Recipient(txout.script_pubkey, txout.value);
builder = builder.add_recipient(recipient);
}
}

const psbt = builder.finish();
expect(psbt.unsigned_tx.output).toHaveLength(
template.unsigned_tx.output.length
);
expect(psbt.unsigned_tx.tx_out(2).value.to_btc()).toBeGreaterThan(0);
});
});
Loading
Loading