Skip to content

Commit 013b555

Browse files
authored
feat: improved TxBuilder and Psbt (MetaMask#79)
1 parent b712a8c commit 013b555

File tree

6 files changed

+212
-55
lines changed

6 files changed

+212
-55
lines changed

src/bitcoin/tx_builder.rs

Lines changed: 44 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
use std::{cell::RefCell, rc::Rc};
22

3-
use bdk_wallet::{bitcoin::ScriptBuf, error::CreateTxError, Wallet as BdkWallet};
3+
use bdk_wallet::{error::CreateTxError, TxOrdering as BdkTxOrdering, Wallet as BdkWallet};
44
use serde::Serialize;
55
use wasm_bindgen::prelude::wasm_bindgen;
66

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

99
/// A transaction builder.
1010
///
@@ -22,6 +22,7 @@ pub struct TxBuilder {
2222
drain_wallet: bool,
2323
drain_to: Option<ScriptBuf>,
2424
allow_dust: bool,
25+
ordering: TxOrdering,
2526
}
2627

2728
#[wasm_bindgen]
@@ -36,6 +37,7 @@ impl TxBuilder {
3637
drain_wallet: false,
3738
allow_dust: false,
3839
drain_to: None,
40+
ordering: BdkTxOrdering::default().into(),
3941
}
4042
}
4143

@@ -95,8 +97,8 @@ impl TxBuilder {
9597
///
9698
/// If you choose not to set any recipients, you should provide the utxos that the
9799
/// transaction should spend via [`add_utxos`].
98-
pub fn drain_to(mut self, address: Address) -> Self {
99-
self.drain_to = Some(address.script_pubkey());
100+
pub fn drain_to(mut self, script_pubkey: ScriptBuf) -> Self {
101+
self.drain_to = Some(script_pubkey);
100102
self
101103
}
102104

@@ -108,6 +110,12 @@ impl TxBuilder {
108110
self
109111
}
110112

113+
/// Choose the ordering for inputs and outputs of the transaction
114+
pub fn ordering(mut self, ordering: TxOrdering) -> Self {
115+
self.ordering = ordering;
116+
self
117+
}
118+
111119
/// Finish building the transaction.
112120
///
113121
/// Returns a new [`Psbt`] per [`BIP174`].
@@ -116,6 +124,7 @@ impl TxBuilder {
116124
let mut builder = wallet.build_tx();
117125

118126
builder
127+
.ordering(self.ordering.into())
119128
.set_recipients(self.recipients.into_iter().map(Into::into).collect())
120129
.unspendable(self.unspendable.into_iter().map(Into::into).collect())
121130
.fee_rate(self.fee_rate.into())
@@ -126,14 +135,44 @@ impl TxBuilder {
126135
}
127136

128137
if let Some(drain_recipient) = self.drain_to {
129-
builder.drain_to(drain_recipient);
138+
builder.drain_to(drain_recipient.into());
130139
}
131140

132141
let psbt = builder.finish()?;
133142
Ok(psbt.into())
134143
}
135144
}
136145

146+
/// Ordering of the transaction's inputs and outputs
147+
#[derive(Clone, Default)]
148+
#[wasm_bindgen]
149+
pub enum TxOrdering {
150+
/// Randomized (default)
151+
#[default]
152+
Shuffle,
153+
/// Unchanged
154+
Untouched,
155+
}
156+
157+
impl From<BdkTxOrdering> for TxOrdering {
158+
fn from(ordering: BdkTxOrdering) -> Self {
159+
match ordering {
160+
BdkTxOrdering::Shuffle => TxOrdering::Shuffle,
161+
BdkTxOrdering::Untouched => TxOrdering::Untouched,
162+
_ => panic!("Unsupported ordering"),
163+
}
164+
}
165+
}
166+
167+
impl From<TxOrdering> for BdkTxOrdering {
168+
fn from(ordering: TxOrdering) -> Self {
169+
match ordering {
170+
TxOrdering::Shuffle => BdkTxOrdering::Shuffle,
171+
TxOrdering::Untouched => BdkTxOrdering::Untouched,
172+
}
173+
}
174+
}
175+
137176
/// Wallet's UTXO set is not enough to cover recipient's requested plus fee.
138177
#[wasm_bindgen]
139178
#[derive(Clone, Serialize)]

src/types/address.rs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,11 @@ impl Address {
9696
pub fn to_string(&self) -> String {
9797
self.0.to_string()
9898
}
99+
100+
#[wasm_bindgen(getter)]
101+
pub fn script_pubkey(&self) -> ScriptBuf {
102+
self.0.script_pubkey().into()
103+
}
99104
}
100105

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

138144
impl Deref for ScriptBuf {
@@ -145,6 +151,15 @@ impl Deref for ScriptBuf {
145151

146152
#[wasm_bindgen]
147153
impl ScriptBuf {
154+
pub fn from_hex(s: &str) -> JsResult<Self> {
155+
let script = BdkScriptBuf::from_hex(s)?;
156+
Ok(script.into())
157+
}
158+
159+
pub fn from_bytes(bytes: Vec<u8>) -> Self {
160+
BdkScriptBuf::from_bytes(bytes).into()
161+
}
162+
148163
#[allow(clippy::inherent_to_string)]
149164
#[wasm_bindgen(js_name = toString)]
150165
pub fn to_string(&self) -> String {
@@ -154,6 +169,18 @@ impl ScriptBuf {
154169
pub fn as_bytes(&self) -> Vec<u8> {
155170
self.0.as_bytes().to_vec()
156171
}
172+
173+
pub fn to_asm_string(&self) -> String {
174+
self.0.to_asm_string()
175+
}
176+
177+
pub fn to_hex_string(&self) -> String {
178+
self.0.to_hex_string()
179+
}
180+
181+
pub fn is_op_return(&self) -> bool {
182+
self.0.is_op_return()
183+
}
157184
}
158185

159186
impl From<BdkScriptBuf> for ScriptBuf {

src/types/psbt.rs

Lines changed: 58 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ use bdk_wallet::{
1010
use wasm_bindgen::prelude::wasm_bindgen;
1111

1212
use crate::result::JsResult;
13+
use crate::types::ScriptBuf;
1314

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

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

3435
#[wasm_bindgen]
3536
impl Psbt {
37+
/// Extracts the [`Transaction`] from a [`Psbt`] by filling in the available signature information.
38+
///
39+
/// ## Errors
40+
///
41+
/// [`ExtractTxError`] variants will contain either the [`Psbt`] itself or the [`Transaction`]
42+
/// that was extracted. These can be extracted from the Errors in order to recover.
43+
/// See the error documentation for info on the variants. In general, it covers large fees.
44+
pub fn extract_tx_fee_rate_limit(self) -> JsResult<Transaction> {
45+
let tx = self.0.extract_tx_fee_rate_limit()?;
46+
Ok(tx.into())
47+
}
48+
49+
/// An alias for [`extract_tx_fee_rate_limit`].
3650
pub fn extract_tx(self) -> JsResult<Transaction> {
3751
let tx = self.0.extract_tx()?;
3852
Ok(tx.into())
3953
}
4054

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

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

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

81+
/// The version number of this PSBT. If omitted, the version number is 0.
82+
#[wasm_bindgen(getter)]
83+
pub fn version(&self) -> u32 {
84+
self.0.version
85+
}
86+
87+
/// Combines this [`Psbt`] with `other` PSBT as described by BIP 174. In-place.
88+
///
89+
/// In accordance with BIP 174 this function is commutative i.e., `A.combine(B) == B.combine(A)`
90+
pub fn combine(&mut self, other: Psbt) -> JsResult<()> {
91+
self.0.combine(other.into())?;
92+
Ok(())
93+
}
94+
95+
/// The unsigned transaction, scriptSigs and witnesses for each input must be empty.
96+
#[wasm_bindgen(getter)]
97+
pub fn unsigned_tx(&self) -> Transaction {
98+
self.0.unsigned_tx.clone().into()
99+
}
100+
61101
/// Serialize the PSBT to a string in base64 format
62102
#[allow(clippy::inherent_to_string)]
63103
#[wasm_bindgen(js_name = toString)]
@@ -92,30 +132,40 @@ impl From<Psbt> for BdkPsbt {
92132
#[wasm_bindgen]
93133
#[derive(Clone)]
94134
pub struct Recipient {
95-
address: Address,
96-
amount: Amount,
135+
script_pubkey: BdkScriptBuf,
136+
amount: BdkAmount,
97137
}
98138

99139
#[wasm_bindgen]
100140
impl Recipient {
101141
#[wasm_bindgen(constructor)]
102-
pub fn new(address: Address, amount: Amount) -> Self {
103-
Recipient { address, amount }
142+
pub fn new(script_pubkey: ScriptBuf, amount: Amount) -> Self {
143+
Recipient {
144+
script_pubkey: script_pubkey.into(),
145+
amount: amount.into(),
146+
}
147+
}
148+
149+
pub fn from_address(address: Address, amount: Amount) -> Self {
150+
Recipient {
151+
script_pubkey: address.script_pubkey().into(),
152+
amount: amount.into(),
153+
}
104154
}
105155

106156
#[wasm_bindgen(getter)]
107-
pub fn address(&self) -> Address {
108-
self.address.clone()
157+
pub fn script_pubkey(&self) -> ScriptBuf {
158+
self.script_pubkey.clone().into()
109159
}
110160

111161
#[wasm_bindgen(getter)]
112162
pub fn amount(&self) -> Amount {
113-
self.amount
163+
self.amount.into()
114164
}
115165
}
116166

117167
impl From<Recipient> for (BdkScriptBuf, BdkAmount) {
118168
fn from(r: Recipient) -> Self {
119-
(r.address().script_pubkey(), r.amount().into())
169+
(r.script_pubkey.clone(), r.amount)
120170
}
121171
}

tests/node/integration/errors.test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import {
2+
Address,
3+
Amount,
4+
BdkError,
5+
BdkErrorCode,
6+
} from "../../../pkg/bitcoindevkit";
7+
import type { Network } from "../../../pkg/bitcoindevkit";
8+
9+
describe("Wallet", () => {
10+
const network: Network = "testnet";
11+
12+
it("catches fine-grained address errors", () => {
13+
try {
14+
Address.from_string(
15+
"tb1qd28npep0s8frcm3y7dxqajkcy2m40eysplyr9v",
16+
"bitcoin"
17+
);
18+
} catch (error) {
19+
expect(error).toBeInstanceOf(BdkError);
20+
21+
const { code, message, data } = error;
22+
expect(code).toBe(BdkErrorCode.NetworkValidation);
23+
expect(message.startsWith("validation error")).toBe(true);
24+
expect(data).toBeUndefined();
25+
}
26+
27+
try {
28+
Address.from_string("notAnAddress", network);
29+
} catch (error) {
30+
expect(error).toBeInstanceOf(BdkError);
31+
32+
const { code, message, data } = error;
33+
expect(code).toBe(BdkErrorCode.Base58);
34+
expect(message.startsWith("base58 error")).toBe(true);
35+
expect(data).toBeUndefined();
36+
}
37+
});
38+
39+
it("catches fine-grained amount errors", () => {
40+
try {
41+
Amount.from_btc(-100000000);
42+
} catch (error) {
43+
expect(error).toBeInstanceOf(BdkError);
44+
45+
const { code, message, data } = error;
46+
expect(code).toBe(BdkErrorCode.OutOfRange);
47+
expect(message.startsWith("amount out of range")).toBe(true);
48+
expect(data).toBeUndefined();
49+
}
50+
});
51+
});

tests/node/integration/esplora.test.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import {
88
UnconfirmedTx,
99
Wallet,
1010
SignOptions,
11+
Psbt,
12+
TxOrdering,
1113
} from "../../../pkg/bitcoindevkit";
1214

1315
// Tests are expected to run in order
@@ -69,7 +71,7 @@ describe("Esplora client", () => {
6971
const psbt = wallet
7072
.build_tx()
7173
.fee_rate(feeRate)
72-
.add_recipient(new Recipient(recipientAddress, sendAmount))
74+
.add_recipient(new Recipient(recipientAddress.script_pubkey, sendAmount))
7375
.finish();
7476

7577
expect(psbt.fee().to_sat()).toBeGreaterThan(100); // We cannot know the exact fees
@@ -105,4 +107,30 @@ describe("Esplora client", () => {
105107
.finish();
106108
}).toThrow();
107109
});
110+
111+
it("fills inputs of an output-only Psbt", () => {
112+
const psbtBase64 =
113+
"cHNidP8BAI4CAAAAAAM1gwEAAAAAACJRIORP1Ndiq325lSC/jMG0RlhATHYmuuULfXgEHUM3u5i4AAAAAAAAAAAxai8AAUSx+i9Igg4HWdcpyagCs8mzuRCklgA7nRMkm69rAAAAAAAAAAAAAQACAAAAACp2AAAAAAAAFgAUArpyBMj+3+/wQDj+orDWG4y4yfUAAAAAAAAAAAA=";
114+
const template = Psbt.from_string(psbtBase64);
115+
116+
let builder = wallet
117+
.build_tx()
118+
.fee_rate(new FeeRate(BigInt(1)))
119+
.ordering(TxOrdering.Untouched);
120+
121+
for (const txout of template.unsigned_tx.output) {
122+
if (wallet.is_mine(txout.script_pubkey)) {
123+
builder = builder.drain_to(txout.script_pubkey);
124+
} else {
125+
const recipient = new Recipient(txout.script_pubkey, txout.value);
126+
builder = builder.add_recipient(recipient);
127+
}
128+
}
129+
130+
const psbt = builder.finish();
131+
expect(psbt.unsigned_tx.output).toHaveLength(
132+
template.unsigned_tx.output.length
133+
);
134+
expect(psbt.unsigned_tx.tx_out(2).value.to_btc()).toBeGreaterThan(0);
135+
});
108136
});

0 commit comments

Comments
 (0)