Skip to content

Commit 5e307aa

Browse files
authored
feat: tx builder error handling (MetaMask#72)
1 parent 7b61fa7 commit 5e307aa

File tree

8 files changed

+185
-14
lines changed

8 files changed

+185
-14
lines changed

.github/workflows/build-lint-test.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ jobs:
6060
run: yarn lint
6161
- name: Test
6262
working-directory: tests/node
63-
run: yarn test
63+
run: yarn build && yarn test
6464

6565
lint:
6666
name: Lint (fmt + clippy)

src/bitcoin/tx_builder.rs

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

3-
use bdk_wallet::{bitcoin::ScriptBuf, Wallet as BdkWallet};
3+
use bdk_wallet::{bitcoin::ScriptBuf, error::CreateTxError, Wallet as BdkWallet};
4+
use serde::Serialize;
45
use wasm_bindgen::prelude::wasm_bindgen;
56

6-
use crate::{
7-
result::JsResult,
8-
types::{Address, FeeRate, OutPoint, Psbt, Recipient},
9-
};
7+
use crate::types::{Address, Amount, BdkError, BdkErrorCode, FeeRate, OutPoint, Psbt, Recipient};
108

119
/// A transaction builder.
1210
///
@@ -113,7 +111,7 @@ impl TxBuilder {
113111
/// Finish building the transaction.
114112
///
115113
/// Returns a new [`Psbt`] per [`BIP174`].
116-
pub fn finish(self) -> JsResult<Psbt> {
114+
pub fn finish(self) -> Result<Psbt, BdkError> {
117115
let mut wallet = self.wallet.borrow_mut();
118116
let mut builder = wallet.build_tx();
119117

@@ -135,3 +133,50 @@ impl TxBuilder {
135133
Ok(psbt.into())
136134
}
137135
}
136+
137+
/// Wallet's UTXO set is not enough to cover recipient's requested plus fee.
138+
#[wasm_bindgen]
139+
#[derive(Clone, Serialize)]
140+
pub struct InsufficientFunds {
141+
/// Amount needed for the transaction
142+
pub needed: Amount,
143+
/// Amount available for spending
144+
pub available: Amount,
145+
}
146+
147+
impl From<CreateTxError> for BdkError {
148+
fn from(e: CreateTxError) -> Self {
149+
use CreateTxError::*;
150+
match &e {
151+
Descriptor(_) => BdkError::new(BdkErrorCode::Descriptor, e.to_string(), ()),
152+
Policy(_) => BdkError::new(BdkErrorCode::Policy, e.to_string(), ()),
153+
SpendingPolicyRequired(keychain_kind) => {
154+
BdkError::new(BdkErrorCode::SpendingPolicyRequired, e.to_string(), keychain_kind)
155+
}
156+
Version0 => BdkError::new(BdkErrorCode::Version0, e.to_string(), ()),
157+
Version1Csv => BdkError::new(BdkErrorCode::Version1Csv, e.to_string(), ()),
158+
LockTime { .. } => BdkError::new(BdkErrorCode::LockTime, e.to_string(), ()),
159+
RbfSequenceCsv { .. } => BdkError::new(BdkErrorCode::RbfSequenceCsv, e.to_string(), ()),
160+
FeeTooLow { required } => BdkError::new(BdkErrorCode::FeeTooLow, e.to_string(), required),
161+
FeeRateTooLow { required } => BdkError::new(BdkErrorCode::FeeRateTooLow, e.to_string(), required),
162+
NoUtxosSelected => BdkError::new(BdkErrorCode::NoUtxosSelected, e.to_string(), ()),
163+
OutputBelowDustLimit(limit) => BdkError::new(BdkErrorCode::OutputBelowDustLimit, e.to_string(), limit),
164+
CoinSelection(insufficient_funds) => BdkError::new(
165+
BdkErrorCode::InsufficientFunds,
166+
e.to_string(),
167+
InsufficientFunds {
168+
available: insufficient_funds.available.into(),
169+
needed: insufficient_funds.needed.into(),
170+
},
171+
),
172+
NoRecipients => BdkError::new(BdkErrorCode::NoRecipients, e.to_string(), ()),
173+
Psbt(_) => BdkError::new(BdkErrorCode::Psbt, e.to_string(), ()),
174+
MissingKeyOrigin(_) => BdkError::new(BdkErrorCode::MissingKeyOrigin, e.to_string(), ()),
175+
UnknownUtxo => BdkError::new(BdkErrorCode::UnknownUtxo, e.to_string(), ()),
176+
MissingNonWitnessUtxo(outpoint) => {
177+
BdkError::new(BdkErrorCode::MissingNonWitnessUtxo, e.to_string(), outpoint)
178+
}
179+
MiniscriptPsbt(_) => BdkError::new(BdkErrorCode::MiniscriptPsbt, e.to_string(), ()),
180+
}
181+
}
182+
}

src/types/amount.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
use std::ops::Deref;
22

33
use bdk_wallet::bitcoin::{Amount as BdkAmount, Denomination as BdkDenomination};
4+
use serde::Serialize;
45
use wasm_bindgen::prelude::wasm_bindgen;
56

67
use crate::result::JsResult;
@@ -10,7 +11,7 @@ use crate::result::JsResult;
1011
/// The [Amount] type can be used to express Bitcoin amounts that support
1112
/// arithmetic and conversion to various denominations.
1213
#[wasm_bindgen]
13-
#[derive(Clone, Copy)]
14+
#[derive(Clone, Copy, Serialize)]
1415
pub struct Amount(BdkAmount);
1516

1617
#[wasm_bindgen]

src/types/error.rs

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
use serde::Serialize;
2+
use serde_wasm_bindgen::to_value;
3+
use wasm_bindgen::{prelude::wasm_bindgen, JsValue};
4+
5+
#[wasm_bindgen]
6+
pub struct BdkError {
7+
code: BdkErrorCode,
8+
message: String,
9+
data: JsValue,
10+
}
11+
12+
impl BdkError {
13+
pub fn new<D>(code: BdkErrorCode, message: impl Into<String>, data: D) -> Self
14+
where
15+
D: Serialize,
16+
{
17+
BdkError {
18+
code,
19+
message: message.into(),
20+
data: to_value(&data).unwrap_or(JsValue::UNDEFINED),
21+
}
22+
}
23+
}
24+
25+
#[wasm_bindgen]
26+
impl BdkError {
27+
#[wasm_bindgen(getter)]
28+
pub fn code(&self) -> BdkErrorCode {
29+
self.code
30+
}
31+
32+
#[wasm_bindgen(getter)]
33+
pub fn message(&self) -> String {
34+
self.message.clone()
35+
}
36+
37+
#[wasm_bindgen(getter)]
38+
pub fn data(&self) -> JsValue {
39+
self.data.clone()
40+
}
41+
}
42+
43+
#[wasm_bindgen]
44+
#[derive(Clone, Copy)]
45+
pub enum BdkErrorCode {
46+
/// There was a problem with the descriptors passed in
47+
Descriptor,
48+
/// There was a problem while extracting and manipulating policies
49+
Policy,
50+
/// Spending policy is not compatible with this [`KeychainKind`]
51+
SpendingPolicyRequired,
52+
/// Requested invalid transaction version '0'
53+
Version0,
54+
/// Requested transaction version `1`, but at least `2` is needed to use OP_CSV
55+
Version1Csv,
56+
/// Requested `LockTime` is less than is required to spend from this script
57+
LockTime,
58+
/// Cannot enable RBF with `Sequence` given a required OP_CSV
59+
RbfSequenceCsv,
60+
/// When bumping a tx the absolute fee requested is lower than replaced tx absolute fee
61+
FeeTooLow,
62+
/// When bumping a tx the fee rate requested is lower than required
63+
FeeRateTooLow,
64+
/// `manually_selected_only` option is selected but no utxo has been passed
65+
NoUtxosSelected,
66+
/// Output created is under the dust limit, 546 satoshis
67+
OutputBelowDustLimit,
68+
/// Wallet's UTXO set is not enough to cover recipient's requested plus fee.
69+
InsufficientFunds,
70+
/// Cannot build a tx without recipients
71+
NoRecipients,
72+
/// Partially signed bitcoin transaction error
73+
Psbt,
74+
/// In order to use the [`TxBuilder::add_global_xpubs`] option every extended
75+
/// key in the descriptor must either be a master key itself (having depth = 0) or have an
76+
/// explicit origin provided
77+
///
78+
/// [`TxBuilder::add_global_xpubs`]: crate::wallet::tx_builder::TxBuilder::add_global_xpubs
79+
MissingKeyOrigin,
80+
/// Happens when trying to spend an UTXO that is not in the internal database
81+
UnknownUtxo,
82+
/// Missing non_witness_utxo on foreign utxo for given `OutPoint`
83+
MissingNonWitnessUtxo,
84+
/// Miniscript PSBT error
85+
MiniscriptPsbt,
86+
}

src/types/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ mod block;
55
mod chain;
66
mod changeset;
77
mod checkpoint;
8+
mod error;
89
mod fee;
910
mod input;
1011
mod keychain;
@@ -21,6 +22,7 @@ pub use block::*;
2122
pub use chain::*;
2223
pub use changeset::*;
2324
pub use checkpoint::*;
25+
pub use error::*;
2426
pub use fee::*;
2527
pub use input::*;
2628
pub use keychain::*;

src/types/network.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,9 @@ use wasm_bindgen::prelude::wasm_bindgen;
66
#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
77
pub enum NetworkKind {
88
/// The Bitcoin mainnet network.
9-
Main,
9+
Main = "main",
1010
/// Some kind of testnet network.
11-
Test,
11+
Test = "test",
1212
}
1313

1414
impl From<BdkNetworkKind> for NetworkKind {

tests/node/integration/esplora.test.ts

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,14 @@ import {
88
UnconfirmedTx,
99
Wallet,
1010
SignOptions,
11+
BdkError,
12+
BdkErrorCode,
1113
} from "../../../pkg/bitcoindevkit";
1214

1315
// Tests are expected to run in order
1416
describe("Esplora client", () => {
15-
const stopGap = 5;
16-
const parallelRequests = 1;
17+
const stopGap = 2;
18+
const parallelRequests = 10;
1719
const externalDescriptor =
1820
"wpkh(tprv8ZgxMBicQKsPe2qpAuh1K1Hig72LCoP4JgNxZM2ZRWHZYnpuw5oHoGBsQm7Qb8mLgPpRJVn3hceWgGQRNbPD6x1pp2Qme2YFRAPeYh7vmvE/84'/1'/0'/0/*)#a6kgzlgq";
1921
const internalDescriptor =
@@ -59,7 +61,7 @@ describe("Esplora client", () => {
5961
feeRate = new FeeRate(BigInt(Math.floor(fee)));
6062
});
6163

62-
it("sends a transaction", async () => {
64+
it.skip("sends a transaction", async () => {
6365
const sendAmount = Amount.from_sat(BigInt(1000));
6466
expect(wallet.balance.trusted_spendable.to_sat()).toBeGreaterThan(
6567
sendAmount.to_sat()
@@ -105,4 +107,39 @@ describe("Esplora client", () => {
105107
.finish();
106108
}).toThrow();
107109
});
110+
111+
it("catches fine-grained errors and deserializes its data", () => {
112+
// Amount should be too big so we fail with InsufficientFunds
113+
const sendAmount = Amount.from_sat(BigInt(2000000000));
114+
115+
try {
116+
wallet
117+
.build_tx()
118+
.fee_rate(new FeeRate(BigInt(1)))
119+
.add_recipient(new Recipient(recipientAddress, sendAmount))
120+
.finish();
121+
} catch (error) {
122+
expect(error).toBeInstanceOf(BdkError);
123+
124+
const { code, message, data } = error;
125+
expect(code).toBe(BdkErrorCode.InsufficientFunds);
126+
expect(message.startsWith("Insufficient funds:")).toBe(true);
127+
expect(data.needed).toBe(2000000000 + 110);
128+
expect(data.available).toBeDefined();
129+
}
130+
131+
try {
132+
wallet
133+
.build_tx()
134+
.fee_rate(new FeeRate(BigInt(1)))
135+
.finish();
136+
} catch (error) {
137+
expect(error).toBeInstanceOf(BdkError);
138+
139+
const { code, message, data } = error;
140+
expect(code).toBe(BdkErrorCode.NoRecipients);
141+
expect(message).toBe("Cannot build tx without recipients");
142+
expect(data).toBeUndefined();
143+
}
144+
});
108145
});

tests/node/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"lint": "eslint .",
44
"lint:fix": "eslint --fix .",
55
"build": "wasm-pack build --target nodejs --all-features",
6-
"test": "yarn build && jest"
6+
"test": "jest"
77
},
88
"devDependencies": {
99
"@types/jest": "^29.5.14",

0 commit comments

Comments
 (0)