Skip to content

Commit 8a3a3bc

Browse files
committed
Remove RequestBuilder configuration footguns
Use the type system to ensure a built Request has valid optional parameters.
1 parent df3c8ff commit 8a3a3bc

File tree

4 files changed

+69
-109
lines changed

4 files changed

+69
-109
lines changed

payjoin-cli/src/app.rs

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -83,13 +83,9 @@ impl App {
8383
let psbt = Psbt::from_str(&psbt).with_context(|| "Failed to load PSBT from base64")?;
8484
log::debug!("Original psbt: {:#?}", psbt);
8585

86-
let payout_scripts = std::iter::once(uri.address.script_pubkey());
87-
8886
let (req, ctx) = payjoin::send::RequestBuilder::from_psbt_and_uri(psbt, uri)
8987
.with_context(|| "Failed to build payjoin request")?
90-
.with_recommended_params(payout_scripts, fee_rate)
91-
.with_context(|| "Configuration error")?
92-
.build()
88+
.build_recommended(fee_rate)
9389
.with_context(|| "Failed to build payjoin request")?;
9490

9591
let client = reqwest::blocking::Client::builder()

payjoin/src/send/error.rs

Lines changed: 6 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -127,44 +127,6 @@ impl std::error::Error for ValidationError {
127127
}
128128
}
129129

130-
#[derive(Debug)]
131-
pub struct ConfigurationError(InternalConfigurationError);
132-
133-
#[derive(Debug)]
134-
pub(crate) enum InternalConfigurationError {
135-
PrevTxOut(crate::psbt::PrevTxOutError),
136-
InputType(crate::input_type::InputTypeError),
137-
NoInputs,
138-
}
139-
140-
impl From<InternalConfigurationError> for ConfigurationError {
141-
fn from(value: InternalConfigurationError) -> Self { ConfigurationError(value) }
142-
}
143-
144-
impl fmt::Display for ConfigurationError {
145-
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
146-
use InternalConfigurationError::*;
147-
148-
match &self.0 {
149-
PrevTxOut(e) => write!(f, "invalid previous transaction output: {}", e),
150-
InputType(e) => write!(f, "invalid input type: {}", e),
151-
NoInputs => write!(f, "no inputs"),
152-
}
153-
}
154-
}
155-
156-
impl std::error::Error for ConfigurationError {
157-
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
158-
use InternalConfigurationError::*;
159-
160-
match &self.0 {
161-
PrevTxOut(error) => Some(error),
162-
InputType(error) => Some(error),
163-
NoInputs => None,
164-
}
165-
}
166-
}
167-
168130
/// Error returned when request could not be created.
169131
///
170132
/// This error can currently only happen due to programmer mistake.
@@ -188,6 +150,8 @@ pub(crate) enum InternalCreateRequestError {
188150
ChangeIndexPointsAtPayee,
189151
Url(url::ParseError),
190152
UriDoesNotSupportPayjoin,
153+
PrevTxOut(crate::psbt::PrevTxOutError),
154+
InputType(crate::input_type::InputTypeError),
191155
}
192156

193157
impl fmt::Display for CreateRequestError {
@@ -208,6 +172,8 @@ impl fmt::Display for CreateRequestError {
208172
ChangeIndexPointsAtPayee => write!(f, "fee output index is points at output belonging to the payee"),
209173
Url(e) => write!(f, "cannot parse endpoint url: {:#?}", e),
210174
UriDoesNotSupportPayjoin => write!(f, "the URI does not support payjoin"),
175+
PrevTxOut(e) => write!(f, "invalid previous transaction output: {}", e),
176+
InputType(e) => write!(f, "invalid input type: {}", e),
211177
}
212178
}
213179
}
@@ -230,6 +196,8 @@ impl std::error::Error for CreateRequestError {
230196
ChangeIndexPointsAtPayee => None,
231197
Url(error) => Some(error),
232198
UriDoesNotSupportPayjoin => None,
199+
PrevTxOut(error) => Some(error),
200+
InputType(error) => Some(error),
233201
}
234202
}
235203
}

payjoin/src/send/mod.rs

Lines changed: 56 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -150,10 +150,8 @@ pub use error::{CreateRequestError, ValidationError};
150150
pub(crate) use error::{InternalCreateRequestError, InternalValidationError};
151151
use url::Url;
152152

153-
use self::error::ConfigurationError;
154153
use crate::input_type::InputType;
155154
use crate::psbt::PsbtExt;
156-
use crate::send::error::InternalConfigurationError;
157155
use crate::uri::UriExt;
158156
use crate::weight::{varint_size, ComputeWeight};
159157
use crate::PjUri;
@@ -199,18 +197,30 @@ impl<'a> RequestBuilder<'a> {
199197
})
200198
}
201199

200+
/// Disable output substitution even if the receiver didn't.
201+
///
202+
/// This forbids receiver switching output or decreasing amount.
203+
/// It is generally **not** recommended to set this as it may prevent the receiver from
204+
/// doing advanced operations such as opening LN channels and it also guarantees the
205+
/// receiver will **not** reward the sender with a discount.
206+
pub fn always_disable_output_substitution(mut self, disable: bool) -> Self {
207+
self.disable_output_substitution = disable;
208+
self
209+
}
210+
202211
// Calculate the recommended fee contribution for an Original PSBT.
203212
//
204213
// BIP 78 recommends contributing `originalPSBTFeeRate * vsize(sender_input_type)`.
205214
// The minfeerate parameter is set if the contribution is available in change.
206215
//
207216
// This method fails if no recommendation can be made or if the PSBT is malformed.
208-
pub fn with_recommended_params(
217+
pub fn build_recommended(
209218
self,
210-
payout_scripts: impl IntoIterator<Item = ScriptBuf>,
211219
min_fee_rate: FeeRate,
212-
) -> Result<Self, ConfigurationError> {
213-
let mut payout_scripts = payout_scripts.into_iter();
220+
) -> Result<(Request, Context), CreateRequestError> {
221+
// TODO support optional batched payout scripts. This would require a change to
222+
// build() which now checks for a single payee.
223+
let mut payout_scripts = std::iter::once(self.uri.address.script_pubkey());
214224
if let Some((additional_fee_index, fee_available)) = self
215225
.psbt
216226
.unsigned_tx
@@ -226,13 +236,13 @@ impl<'a> RequestBuilder<'a> {
226236
.input_pairs()
227237
.map(|input| {
228238
let txo =
229-
input.previous_txout().map_err(InternalConfigurationError::PrevTxOut)?;
239+
input.previous_txout().map_err(InternalCreateRequestError::PrevTxOut)?;
230240
Ok(InputType::from_spent_input(txo, input.psbtin)
231-
.map_err(InternalConfigurationError::InputType)?)
241+
.map_err(InternalCreateRequestError::InputType)?)
232242
})
233-
.collect::<Result<Vec<InputType>, ConfigurationError>>()?;
243+
.collect::<Result<Vec<InputType>, InternalCreateRequestError>>()?;
234244

235-
let first_type = input_types.first().ok_or(InternalConfigurationError::NoInputs)?;
245+
let first_type = input_types.first().ok_or(InternalCreateRequestError::NoInputs)?;
236246
// use cheapest default if mixed input types
237247
let mut input_vsize = InputType::Taproot.expected_input_weight();
238248
// Check if all inputs are the same type
@@ -243,16 +253,21 @@ impl<'a> RequestBuilder<'a> {
243253
let recommended_additional_fee = min_fee_rate * input_vsize;
244254
if fee_available < recommended_additional_fee {
245255
log::warn!("Insufficient funds to maintain specified minimum feerate.");
246-
return Ok(self
247-
.with_fee_contribution(fee_available, Some(additional_fee_index))
248-
.clamp_fee_contribution(true));
256+
return self.build_with_additional_fee(
257+
fee_available,
258+
Some(additional_fee_index),
259+
min_fee_rate,
260+
true,
261+
);
249262
}
250-
return Ok(self
251-
.with_fee_contribution(recommended_additional_fee, Some(additional_fee_index))
252-
.clamp_fee_contribution(false)
253-
.min_fee_rate(min_fee_rate));
263+
return self.build_with_additional_fee(
264+
recommended_additional_fee,
265+
Some(additional_fee_index),
266+
min_fee_rate,
267+
false,
268+
);
254269
}
255-
Ok(self.non_incentivizing())
270+
self.build_non_incentivizing()
256271
}
257272

258273
/// Offer the receiver contribution to pay for his input.
@@ -262,62 +277,39 @@ impl<'a> RequestBuilder<'a> {
262277
///
263278
/// `change_index` specifies which output can be used to pay fee. If `None` is provided, then
264279
/// the output is auto-detected unless the supplied transaction has more than two outputs.
265-
pub fn with_fee_contribution(
266-
self,
280+
///
281+
/// `clamp_fee_contribution` decreases fee contribution instead of erroring.
282+
///
283+
/// If this option is true and a transaction with change amount lower than fee
284+
/// contribution is provided then instead of returning error the fee contribution will
285+
/// be just lowered in the request to match the change amount.
286+
pub fn build_with_additional_fee(
287+
mut self,
267288
max_fee_contribution: bitcoin::Amount,
268289
change_index: Option<usize>,
269-
) -> Self {
270-
Self {
271-
disable_output_substitution: false,
272-
fee_contribution: Some((max_fee_contribution, change_index)),
273-
clamp_fee_contribution: false,
274-
min_fee_rate: FeeRate::ZERO,
275-
..self
276-
}
290+
min_fee_rate: FeeRate,
291+
clamp_fee_contribution: bool,
292+
) -> Result<(Request, Context), CreateRequestError> {
293+
self.fee_contribution = Some((max_fee_contribution, change_index));
294+
self.clamp_fee_contribution = clamp_fee_contribution;
295+
self.min_fee_rate = min_fee_rate;
296+
self.build()
277297
}
278298

279299
/// Perform Payjoin without incentivizing the payee to cooperate.
280300
///
281301
/// While it's generally better to offer some contribution some users may wish not to.
282302
/// This function disables contribution.
283-
pub fn non_incentivizing(self) -> Self {
284-
Self {
285-
disable_output_substitution: false,
286-
fee_contribution: None,
287-
clamp_fee_contribution: false,
288-
min_fee_rate: FeeRate::ZERO,
289-
..self
290-
}
291-
}
292-
293-
/// Disable output substitution even if the receiver didn't.
294-
///
295-
/// This forbids receiver switching output or decreasing amount.
296-
/// It is generally **not** recommended to set this as it may prevent the receiver from
297-
/// doing advanced operations such as opening LN channels and it also guarantees the
298-
/// receiver will **not** reward the sender with a discount.
299-
pub fn always_disable_output_substitution(mut self, disable: bool) -> Self {
300-
self.disable_output_substitution = disable;
301-
self
302-
}
303-
304-
/// Decrease fee contribution instead of erroring.
305-
///
306-
/// If this option is set and a transaction with change amount lower than fee
307-
/// contribution is provided then instead of returning error the fee contribution will
308-
/// be just lowered to match the change amount.
309-
pub fn clamp_fee_contribution(mut self, clamp: bool) -> Self {
310-
self.clamp_fee_contribution = clamp;
311-
self
312-
}
313-
314-
/// Sets minimum fee rate required by the sender.
315-
pub fn min_fee_rate(mut self, fee_rate: FeeRate) -> Self {
316-
self.min_fee_rate = fee_rate;
317-
self
303+
pub fn build_non_incentivizing(mut self) -> Result<(Request, Context), CreateRequestError> {
304+
// since this is a builder, these should already be cleared
305+
// but we'll reset them to be sure
306+
self.fee_contribution = None;
307+
self.clamp_fee_contribution = false;
308+
self.min_fee_rate = FeeRate::ZERO;
309+
self.build()
318310
}
319311

320-
pub fn build(self) -> Result<(Request, Context), CreateRequestError> {
312+
fn build(self) -> Result<(Request, Context), CreateRequestError> {
321313
let mut psbt =
322314
self.psbt.validate().map_err(InternalCreateRequestError::InconsistentOriginalPsbt)?;
323315
psbt.validate_input_utxos(true)

payjoin/tests/integration.rs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,8 +79,12 @@ mod integration {
7979
debug!("Original psbt: {:#?}", psbt);
8080
let (req, ctx) = RequestBuilder::from_psbt_and_uri(psbt, pj_uri)
8181
.unwrap()
82-
.build_with_fee_contribution(payjoin::bitcoin::Amount::from_sat(10000), None)
83-
.build()
82+
.build_with_additional_fee(
83+
payjoin::bitcoin::Amount::from_sat(10000),
84+
None,
85+
bitcoin::FeeRate::ZERO,
86+
false,
87+
)
8488
.unwrap();
8589
let headers = HeaderMock::from_vec(&req.body);
8690

0 commit comments

Comments
 (0)