Skip to content

Commit 3e616b3

Browse files
Abstract common v1,v2 SenderBuilder logic
Introduce PsbtContextBuilder which has no ouptut_substitution field, allowing parent consumer SenderBuilders to handle output_substitution and the creation of types relevant to their independent state machines. Move common v1 logic used in both v1 and v2 flows to the send module.
1 parent 896e9d0 commit 3e616b3

File tree

7 files changed

+404
-295
lines changed

7 files changed

+404
-295
lines changed

payjoin-ffi/src/send/mod.rs

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -93,10 +93,10 @@ impl InitInputsTransition {
9393
///
9494
///These parameters define how client wants to handle Payjoin.
9595
#[derive(Clone)]
96-
pub struct SenderBuilder(payjoin::send::v2::SenderBuilder<'static>);
96+
pub struct SenderBuilder(payjoin::send::v2::SenderBuilder);
9797

98-
impl From<payjoin::send::v2::SenderBuilder<'static>> for SenderBuilder {
99-
fn from(value: payjoin::send::v2::SenderBuilder<'static>) -> Self { Self(value) }
98+
impl From<payjoin::send::v2::SenderBuilder> for SenderBuilder {
99+
fn from(value: payjoin::send::v2::SenderBuilder) -> Self { Self(value) }
100100
}
101101

102102
impl SenderBuilder {
@@ -259,16 +259,16 @@ impl WithReplyKey {
259259
/// Data required for validation of response.
260260
/// This type is used to process the response. Get it from SenderBuilder's build methods. Then you only need to call .process_response() on it to continue BIP78 flow.
261261
#[derive(Clone)]
262-
pub struct V1Context(Arc<payjoin::send::v1::V1Context>);
263-
impl From<payjoin::send::v1::V1Context> for V1Context {
264-
fn from(value: payjoin::send::v1::V1Context) -> Self { Self(Arc::new(value)) }
262+
pub struct V1Context(Arc<payjoin::send::V1Context>);
263+
impl From<payjoin::send::V1Context> for V1Context {
264+
fn from(value: payjoin::send::V1Context) -> Self { Self(Arc::new(value)) }
265265
}
266266

267267
impl V1Context {
268268
///Decodes and validates the response.
269269
/// Call this method with response from receiver to continue BIP78 flow. If the response is valid you will get appropriate PSBT that you should sign and broadcast.
270270
pub fn process_response(&self, response: &[u8]) -> Result<String, ResponseError> {
271-
<payjoin::send::v1::V1Context as Clone>::clone(&self.0.clone())
271+
<payjoin::send::V1Context as Clone>::clone(&self.0.clone())
272272
.process_response(response)
273273
.map(|e| e.to_string())
274274
.map_err(Into::into)

payjoin/src/core/output_substitution.rs

Lines changed: 0 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,3 @@ pub enum OutputSubstitution {
44
Enabled,
55
Disabled,
66
}
7-
8-
impl OutputSubstitution {
9-
/// Combine two output substitution flags.
10-
///
11-
/// If both are enabled, the result is enabled.
12-
/// If one is disabled, the result is disabled.
13-
pub(crate) fn combine(self, other: Self) -> Self {
14-
match (self, other) {
15-
(Self::Enabled, Self::Enabled) => Self::Enabled,
16-
_ => Self::Disabled,
17-
}
18-
}
19-
}

payjoin/src/core/send/mod.rs

Lines changed: 230 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ use url::Url;
2626

2727
use crate::output_substitution::OutputSubstitution;
2828
use crate::psbt::PsbtExt;
29-
use crate::Version;
29+
use crate::{Request, Version, MAX_CONTENT_LENGTH};
3030

3131
// See usize casts
3232
#[cfg(not(any(target_pointer_width = "32", target_pointer_width = "64")))]
@@ -37,20 +37,195 @@ mod error;
3737
#[cfg(feature = "v1")]
3838
#[cfg_attr(docsrs, doc(cfg(feature = "v1")))]
3939
pub mod v1;
40-
#[cfg(not(feature = "v1"))]
41-
pub(crate) mod v1;
4240

4341
#[cfg(feature = "v2")]
4442
#[cfg_attr(docsrs, doc(cfg(feature = "v2")))]
4543
pub mod v2;
46-
#[cfg(all(feature = "v2", not(feature = "v1")))]
47-
pub use v1::V1Context;
4844

4945
#[cfg(feature = "_multiparty")]
5046
pub mod multiparty;
5147

5248
type InternalResult<T> = Result<T, InternalProposalError>;
5349

50+
/// A builder to construct the properties of a `PsbtContext`.
51+
#[derive(Clone)]
52+
pub(crate) struct PsbtContextBuilder {
53+
pub(crate) psbt: Psbt,
54+
pub(crate) payee: ScriptBuf,
55+
pub(crate) amount: Option<bitcoin::Amount>,
56+
pub(crate) fee_contribution: Option<(bitcoin::Amount, Option<usize>)>,
57+
/// Decreases the fee contribution instead of erroring.
58+
///
59+
/// If this option is true and a transaction with change amount lower than fee
60+
/// contribution is provided then instead of returning error the fee contribution will
61+
/// be just lowered in the request to match the change amount.
62+
pub(crate) clamp_fee_contribution: bool,
63+
pub(crate) min_fee_rate: FeeRate,
64+
}
65+
66+
/// We only need to add the weight of the txid: 32, index: 4 and sequence: 4 as rust_bitcoin
67+
/// already accounts for the scriptsig length when calculating InputWeightPrediction
68+
/// <https://docs.rs/bitcoin/latest/src/bitcoin/blockdata/transaction.rs.html#1621>
69+
const NON_WITNESS_INPUT_WEIGHT: bitcoin::Weight = Weight::from_non_witness_data_size(32 + 4 + 4);
70+
71+
impl PsbtContextBuilder {
72+
/// Prepare the context from which to make Sender requests
73+
///
74+
/// Call [`PsbtContextBuilder::build_recommended()`] or other `build` methods
75+
/// to create a [`PsbtContext`]
76+
pub fn new(psbt: Psbt, payee: ScriptBuf, amount: Option<bitcoin::Amount>) -> Self {
77+
Self {
78+
psbt,
79+
payee,
80+
amount,
81+
// Sender's optional parameters
82+
fee_contribution: None,
83+
clamp_fee_contribution: false,
84+
min_fee_rate: FeeRate::ZERO,
85+
}
86+
}
87+
88+
// Calculate the recommended fee contribution for an Original PSBT.
89+
//
90+
// BIP 78 recommends contributing `originalPSBTFeeRate * vsize(sender_input_type)`.
91+
// The minfeerate parameter is set if the contribution is available in change.
92+
//
93+
// This method fails if no recommendation can be made or if the PSBT is malformed.
94+
pub fn build_recommended(
95+
self,
96+
min_fee_rate: FeeRate,
97+
output_substitution: OutputSubstitution,
98+
) -> Result<PsbtContext, BuildSenderError> {
99+
// TODO support optional batched payout scripts. This would require a change to
100+
// build() which now checks for a single payee.
101+
let mut payout_scripts = std::iter::once(self.payee.clone());
102+
103+
// Check if the PSBT is a sweep transaction with only one output that's a payout script and no change
104+
if self.psbt.unsigned_tx.output.len() == 1
105+
&& payout_scripts.all(|script| script == self.psbt.unsigned_tx.output[0].script_pubkey)
106+
{
107+
return self.build_non_incentivizing(min_fee_rate, output_substitution);
108+
}
109+
110+
if let Some((additional_fee_index, fee_available)) = self
111+
.psbt
112+
.unsigned_tx
113+
.output
114+
.clone()
115+
.into_iter()
116+
.enumerate()
117+
.find(|(_, txo)| payout_scripts.all(|script| script != txo.script_pubkey))
118+
.map(|(i, txo)| (i, txo.value))
119+
{
120+
let mut input_pairs = self.psbt.input_pairs();
121+
let first_input_pair = input_pairs.next().ok_or(InternalBuildSenderError::NoInputs)?;
122+
let mut input_weight = first_input_pair
123+
.expected_input_weight()
124+
.map_err(InternalBuildSenderError::InputWeight)?;
125+
for input_pair in input_pairs {
126+
// use cheapest default if mixed input types
127+
if input_pair.address_type()? != first_input_pair.address_type()? {
128+
input_weight =
129+
bitcoin::transaction::InputWeightPrediction::P2TR_KEY_NON_DEFAULT_SIGHASH
130+
.weight()
131+
+ NON_WITNESS_INPUT_WEIGHT;
132+
break;
133+
}
134+
}
135+
136+
let recommended_additional_fee = min_fee_rate * input_weight;
137+
if fee_available < recommended_additional_fee {
138+
log::warn!("Insufficient funds to maintain specified minimum feerate.");
139+
return self.build_with_additional_fee(
140+
fee_available,
141+
Some(additional_fee_index),
142+
min_fee_rate,
143+
true,
144+
output_substitution,
145+
);
146+
}
147+
return self.build_with_additional_fee(
148+
recommended_additional_fee,
149+
Some(additional_fee_index),
150+
min_fee_rate,
151+
false,
152+
output_substitution,
153+
);
154+
}
155+
self.build_non_incentivizing(min_fee_rate, output_substitution)
156+
}
157+
158+
/// Offer the receiver contribution to pay for his input.
159+
///
160+
/// These parameters will allow the receiver to take `max_fee_contribution` from given change
161+
/// output to pay for additional inputs. The recommended fee is `size_of_one_input * fee_rate`.
162+
///
163+
/// `change_index` specifies which output can be used to pay fee. If `None` is provided, then
164+
/// the output is auto-detected unless the supplied transaction has more than two outputs.
165+
///
166+
/// `clamp_fee_contribution` decreases fee contribution instead of erroring.
167+
///
168+
/// If this option is true and a transaction with change amount lower than fee
169+
/// contribution is provided then instead of returning error the fee contribution will
170+
/// be just lowered in the request to match the change amount.
171+
pub fn build_with_additional_fee(
172+
mut self,
173+
max_fee_contribution: bitcoin::Amount,
174+
change_index: Option<usize>,
175+
min_fee_rate: FeeRate,
176+
clamp_fee_contribution: bool,
177+
output_substitution: OutputSubstitution,
178+
) -> Result<PsbtContext, BuildSenderError> {
179+
self.fee_contribution = Some((max_fee_contribution, change_index));
180+
self.clamp_fee_contribution = clamp_fee_contribution;
181+
self.min_fee_rate = min_fee_rate;
182+
self.build(output_substitution)
183+
}
184+
185+
/// Perform Payjoin without incentivizing the payee to cooperate.
186+
///
187+
/// While it's generally better to offer some contribution some users may wish not to.
188+
/// This function disables contribution.
189+
pub fn build_non_incentivizing(
190+
mut self,
191+
min_fee_rate: FeeRate,
192+
output_substitution: OutputSubstitution,
193+
) -> Result<PsbtContext, BuildSenderError> {
194+
// since this is a builder, these should already be cleared
195+
// but we'll reset them to be sure
196+
self.fee_contribution = None;
197+
self.clamp_fee_contribution = false;
198+
self.min_fee_rate = min_fee_rate;
199+
self.build(output_substitution)
200+
}
201+
202+
fn build(
203+
self,
204+
output_substitution: OutputSubstitution,
205+
) -> Result<PsbtContext, BuildSenderError> {
206+
let mut psbt =
207+
self.psbt.validate().map_err(InternalBuildSenderError::InconsistentOriginalPsbt)?;
208+
psbt.validate_input_utxos().map_err(InternalBuildSenderError::InvalidOriginalInput)?;
209+
210+
check_single_payee(&psbt, &self.payee, self.amount)?;
211+
let fee_contribution = determine_fee_contribution(
212+
&psbt,
213+
&self.payee,
214+
self.fee_contribution,
215+
self.clamp_fee_contribution,
216+
)?;
217+
clear_unneeded_fields(&mut psbt);
218+
219+
Ok(PsbtContext {
220+
original_psbt: psbt,
221+
output_substitution,
222+
fee_contribution,
223+
min_fee_rate: self.min_fee_rate,
224+
payee: self.payee,
225+
})
226+
}
227+
}
228+
54229
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55230
#[cfg_attr(feature = "v2", derive(serde::Serialize, serde::Deserialize))]
56231
pub(crate) struct AdditionalFeeContribution {
@@ -478,6 +653,56 @@ fn serialize_url(
478653
url
479654
}
480655

656+
/// Construct serialized V1 Request and Context from a Payjoin Proposal
657+
pub(crate) fn create_v1_post_request(endpoint: Url, psbt_ctx: PsbtContext) -> (Request, V1Context) {
658+
let url = serialize_url(
659+
endpoint.clone(),
660+
psbt_ctx.output_substitution,
661+
psbt_ctx.fee_contribution,
662+
psbt_ctx.min_fee_rate,
663+
Version::One,
664+
);
665+
let body = psbt_ctx.original_psbt.to_string().as_bytes().to_vec();
666+
(
667+
Request::new_v1(&url, &body),
668+
V1Context {
669+
psbt_context: PsbtContext {
670+
original_psbt: psbt_ctx.original_psbt.clone(),
671+
output_substitution: psbt_ctx.output_substitution,
672+
fee_contribution: psbt_ctx.fee_contribution,
673+
payee: psbt_ctx.payee.clone(),
674+
min_fee_rate: psbt_ctx.min_fee_rate,
675+
},
676+
},
677+
)
678+
}
679+
680+
/// Data required to validate the response.
681+
///
682+
/// This type is used to process a BIP78 response.
683+
/// Call [`Self::process_response`] on it to continue the BIP78 flow.
684+
#[derive(Debug, Clone)]
685+
pub struct V1Context {
686+
psbt_context: PsbtContext,
687+
}
688+
689+
impl V1Context {
690+
/// Decodes and validates the response.
691+
///
692+
/// Call this method with response from receiver to continue BIP78 flow. If the response is
693+
/// valid you will get appropriate PSBT that you should sign and broadcast.
694+
#[inline]
695+
pub fn process_response(self, response: &[u8]) -> Result<Psbt, ResponseError> {
696+
if response.len() > MAX_CONTENT_LENGTH {
697+
return Err(ResponseError::from(InternalValidationError::ContentTooLarge));
698+
}
699+
700+
let res_str = std::str::from_utf8(response).map_err(|_| InternalValidationError::Parse)?;
701+
let proposal = Psbt::from_str(res_str).map_err(|_| ResponseError::parse(res_str))?;
702+
self.psbt_context.process_proposal(proposal).map_err(Into::into)
703+
}
704+
}
705+
481706
#[cfg(test)]
482707
mod test {
483708
use std::str::FromStr;

payjoin/src/core/send/multiparty/mod.rs

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,10 @@ use crate::{ImplementationError, IntoUrl, PjUri, Request, Version};
1818
mod error;
1919

2020
#[derive(Clone)]
21-
pub struct SenderBuilder<'a>(v2::SenderBuilder<'a>);
21+
pub struct SenderBuilder(v2::SenderBuilder);
2222

23-
impl<'a> SenderBuilder<'a> {
24-
pub fn new(psbt: Psbt, uri: PjUri<'a>) -> Self { Self(v2::SenderBuilder::new(psbt, uri)) }
23+
impl SenderBuilder {
24+
pub fn new(psbt: Psbt, uri: PjUri) -> Self { Self(v2::SenderBuilder::new(psbt, uri)) }
2525

2626
pub fn build_recommended(self, min_fee_rate: FeeRate) -> Result<Sender, BuildSenderError> {
2727
let noop_persister = NoopSessionPersister::default();
@@ -56,10 +56,10 @@ impl Sender {
5656
.ohttp()
5757
.map_err(|_| InternalCreateRequestError::MissingOhttpConfig)?;
5858
let body = serialize_v2_body(
59-
&self.0.v1.psbt_ctx.original_psbt,
60-
self.0.v1.psbt_ctx.output_substitution,
61-
self.0.v1.psbt_ctx.fee_contribution,
62-
self.0.v1.psbt_ctx.min_fee_rate,
59+
&self.0.state.psbt_ctx.original_psbt,
60+
self.0.state.psbt_ctx.output_substitution,
61+
self.0.state.psbt_ctx.fee_contribution,
62+
self.0.state.psbt_ctx.min_fee_rate,
6363
)?;
6464
let (request, ohttp_ctx) = extract_request(
6565
ohttp_relay,
@@ -72,7 +72,7 @@ impl Sender {
7272
.map_err(InternalCreateRequestError::V2CreateRequest)?;
7373
let v2_post_ctx = V2PostContext {
7474
endpoint: self.0.endpoint().clone(),
75-
psbt_ctx: self.0.v1.psbt_ctx.clone(),
75+
psbt_ctx: self.0.state.psbt_ctx.clone(),
7676
hpke_ctx: HpkeContext::new(rs, &self.0.reply_key),
7777
ohttp_ctx,
7878
};

0 commit comments

Comments
 (0)