diff --git a/payjoin-cli/src/app.rs b/payjoin-cli/src/app.rs index 5dec4d5e1..55691cb4b 100644 --- a/payjoin-cli/src/app.rs +++ b/payjoin-cli/src/app.rs @@ -42,6 +42,8 @@ impl App { } pub fn send_payjoin(&self, bip21: &str) -> Result<()> { + use payjoin::send::Configuration; + let link = payjoin::Uri::try_from(bip21) .map_err(|e| anyhow!("Failed to create URI from BIP21: {}", e))?; @@ -50,16 +52,22 @@ impl App { .check_pj_supported() .map_err(|e| anyhow!("The provided URI doesn't support payjoin (BIP78): {}", e))?; - let amount = link - .amount - .ok_or_else(|| anyhow!("please specify the amount in the Uri")) - .map(|amt| Amount::from_sat(amt.to_sat()))?; + let amount = link.amount.ok_or_else(|| anyhow!("please specify the amount in the Uri"))?; + + // wallet_create_funded_psbt requires a HashMap let mut outputs = HashMap::with_capacity(1); outputs.insert(link.address.to_string(), amount); + // TODO: make payjoin-cli send feerate configurable + // 2.1 sat/vB == 525 sat/kwu for testing purposes. + let fee_rate = bitcoin::FeeRate::from_sat_per_kwu(525); + let fee_sat_per_kvb = + fee_rate.to_sat_per_kwu().checked_mul(4).ok_or(anyhow!("Invalid fee rate"))?; + let fee_per_kvb = Amount::from_sat(fee_sat_per_kvb); + log::debug!("Fee rate sat/kvb: {}", fee_per_kvb.display_in(bitcoin::Denomination::Satoshi)); let options = bitcoincore_rpc::json::WalletCreateFundedPsbtOptions { lock_unspent: Some(true), - fee_rate: Some(Amount::from_sat(2000)), + fee_rate: Some(fee_per_kvb), ..Default::default() }; let psbt = self @@ -80,10 +88,12 @@ impl App { .psbt; let psbt = Psbt::from_str(&psbt).with_context(|| "Failed to load PSBT from base64")?; log::debug!("Original psbt: {:#?}", psbt); - let pj_params = payjoin::send::Configuration::with_fee_contribution( - payjoin::bitcoin::Amount::from_sat(10000), - None, - ); + + let payout_scripts = std::iter::once(link.address.script_pubkey()); + // recommendation or bust for this simple reference implementation + let pj_params = Configuration::recommended(&psbt, payout_scripts, fee_rate) + .unwrap_or_else(|_| Configuration::non_incentivizing()); + let (req, ctx) = link .create_pj_request(psbt, pj_params) .with_context(|| "Failed to create payjoin request")?; diff --git a/payjoin/src/receive/optional_parameters.rs b/payjoin/src/receive/optional_parameters.rs index 20d75cd68..711d84f87 100644 --- a/payjoin/src/receive/optional_parameters.rs +++ b/payjoin/src/receive/optional_parameters.rs @@ -69,9 +69,13 @@ impl Params { } }, ("minfeerate", feerate) => - params.min_feerate = match feerate.parse::() { - Ok(rate) => FeeRate::from_sat_per_vb(rate) - .ok_or_else(|| Error::FeeRate(rate.to_string()))?, + params.min_feerate = match feerate.parse::() { + Ok(fee_rate_sat_per_vb) => { + // TODO Parse with serde when rust-bitcoin supports it + let fee_rate_sat_per_kwu = fee_rate_sat_per_vb * 250.0_f32; + // since it's a minnimum, we want to round up + FeeRate::from_sat_per_kwu(fee_rate_sat_per_kwu.ceil() as u64) + } Err(e) => return Err(Error::FeeRate(e.to_string())), }, ("disableoutputsubstitution", v) => diff --git a/payjoin/src/send/error.rs b/payjoin/src/send/error.rs index fc75ae155..1c7cdea5c 100644 --- a/payjoin/src/send/error.rs +++ b/payjoin/src/send/error.rs @@ -127,6 +127,20 @@ impl std::error::Error for ValidationError { } } +#[derive(Debug)] +pub struct ConfigurationError(InternalConfigurationError); + +#[derive(Debug)] +pub(crate) enum InternalConfigurationError { + PrevTxOut(crate::psbt::PrevTxOutError), + InputType(crate::input_type::InputTypeError), + NoInputs, +} + +impl From for ConfigurationError { + fn from(value: InternalConfigurationError) -> Self { ConfigurationError(value) } +} + /// Error returned when request could not be created. /// /// This error can currently only happen due to programmer mistake. diff --git a/payjoin/src/send/mod.rs b/payjoin/src/send/mod.rs index 387a9b01f..411ac002b 100644 --- a/payjoin/src/send/mod.rs +++ b/payjoin/src/send/mod.rs @@ -149,8 +149,10 @@ pub use error::{CreateRequestError, ValidationError}; pub(crate) use error::{InternalCreateRequestError, InternalValidationError}; use url::Url; +use self::error::ConfigurationError; use crate::input_type::InputType; use crate::psbt::PsbtExt; +use crate::send::error::InternalConfigurationError; use crate::weight::{varint_size, ComputeWeight}; // See usize casts @@ -172,6 +174,64 @@ pub struct Configuration { } impl Configuration { + // Calculate the recommended fee contribution for an Original PSBT. + // + // BIP 78 recommends contributing `originalPSBTFeeRate * vsize(sender_input_type)`. + // The minfeerate parameter is set if the contribution is available in change. + // + // This method fails if no recommendation can be made or if the PSBT is malformed. + pub fn recommended( + psbt: &Psbt, + payout_scripts: impl IntoIterator, + min_fee_rate: FeeRate, + ) -> Result { + let mut payout_scripts = payout_scripts.into_iter(); + if let Some((additional_fee_index, fee_available)) = psbt + .unsigned_tx + .output + .clone() + .into_iter() + .enumerate() + .find(|(_, txo)| payout_scripts.all(|script| script != txo.script_pubkey)) + .map(|(i, txo)| (i, bitcoin::Amount::from_sat(txo.value))) + { + let input_types = psbt + .input_pairs() + .map(|input| { + let txo = + input.previous_txout().map_err(InternalConfigurationError::PrevTxOut)?; + Ok(InputType::from_spent_input(txo, input.psbtin) + .map_err(InternalConfigurationError::InputType)?) + }) + .collect::, ConfigurationError>>()?; + + let first_type = input_types.first().ok_or(InternalConfigurationError::NoInputs)?; + // use cheapest default if mixed input types + let mut input_vsize = InputType::Taproot.expected_input_weight(); + // Check if all inputs are the same type + if input_types.iter().all(|input_type| input_type == first_type) { + input_vsize = first_type.expected_input_weight(); + } + + let recommended_additional_fee = min_fee_rate * input_vsize; + if fee_available < recommended_additional_fee { + log::warn!("Insufficient funds to maintain specified minimum feerate."); + return Ok(Configuration::with_fee_contribution( + fee_available, + Some(additional_fee_index), + ) + .clamp_fee_contribution(true)); + } + return Ok(Configuration::with_fee_contribution( + recommended_additional_fee, + Some(additional_fee_index), + ) + .clamp_fee_contribution(false) + .min_fee_rate(min_fee_rate)); + } + Ok(Configuration::non_incentivizing()) + } + /// Offer the receiver contribution to pay for his input. /// /// These parameters will allow the receiver to take `max_fee_contribution` from given change @@ -226,8 +286,8 @@ impl Configuration { } /// Sets minimum fee rate required by the sender. - pub fn min_fee_rate_sat_per_vb(mut self, fee_rate: u64) -> Self { - self.min_fee_rate = FeeRate::from_sat_per_vb_unchecked(fee_rate); + pub fn min_fee_rate(mut self, fee_rate: FeeRate) -> Self { + self.min_fee_rate = fee_rate; self } } @@ -667,8 +727,9 @@ fn serialize_url( .append_pair("maxadditionalfeecontribution", &amount.to_sat().to_string()); } if min_fee_rate > FeeRate::ZERO { - url.query_pairs_mut() - .append_pair("minfeerate", &min_fee_rate.to_sat_per_vb_floor().to_string()); + // TODO serialize in rust-bitcoin + let float_fee_rate = min_fee_rate.to_sat_per_kwu() as f32 / 250.0_f32; + url.query_pairs_mut().append_pair("minfeerate", &float_fee_rate.to_string()); } Ok(url) }