diff --git a/payjoin/src/receive/mod.rs b/payjoin/src/receive/mod.rs index 24e6dc68..b81b4bf9 100644 --- a/payjoin/src/receive/mod.rs +++ b/payjoin/src/receive/mod.rs @@ -409,6 +409,8 @@ impl WantsOutputs { owned_vouts.push(outputs.len()) } outputs.push(txo); + // Additional outputs must also be added to the PSBT outputs data structure + payjoin_psbt.outputs.push(Default::default()); } payjoin_psbt.unsigned_tx.output = outputs; } @@ -556,7 +558,7 @@ impl WantsInputs { self.owned_vouts.choose(&mut rand::thread_rng()).expect("owned_vouts is empty"); payjoin_psbt.unsigned_tx.output[*vout_to_augment].value += change_amount; } else { - todo!("Return an error?"); + todo!("Input amount is not enough to cover additional output value"); } ProvisionalProposal { @@ -581,7 +583,7 @@ impl WantsInputs { .output .iter() .fold(Amount::ZERO, |acc, output| acc + output.value); - max(Amount::ZERO, output_amount - original_output_amount) + output_amount.checked_sub(original_output_amount).unwrap_or(Amount::ZERO) } } diff --git a/payjoin/tests/integration.rs b/payjoin/tests/integration.rs index 3689f878..11aff7b8 100644 --- a/payjoin/tests/integration.rs +++ b/payjoin/tests/integration.rs @@ -5,7 +5,7 @@ mod integration { use std::str::FromStr; use bitcoin::psbt::Psbt; - use bitcoin::{Amount, FeeRate, OutPoint}; + use bitcoin::{Amount, FeeRate, OutPoint, TxOut}; use bitcoind::bitcoincore_rpc::json::{AddressType, WalletProcessPsbtResult}; use bitcoind::bitcoincore_rpc::{self, RpcApi}; use log::{log_enabled, Level}; @@ -57,7 +57,7 @@ mod integration { // ********************** // Inside the Receiver: // this data would transit from one party to another over the network in production - let response = handle_v1_pj_request(req, headers, &receiver); + let response = handle_v1_pj_request(req, headers, &receiver, None, None); // this response would be returned as http response to the sender // ********************** @@ -353,7 +353,7 @@ mod integration { // ********************** // Inside the Receiver: // this data would transit from one party to another over the network in production - let response = handle_v1_pj_request(req, headers, &receiver); + let response = handle_v1_pj_request(req, headers, &receiver, None, None); // this response would be returned as http response to the sender // ********************** @@ -714,6 +714,170 @@ mod integration { } } + #[cfg(not(feature = "v2"))] + mod batching { + use payjoin::UriExt; + + use super::*; + + // In this test the receiver consolidates a bunch of UTXOs into the destination output + #[test] + fn receiver_consolidates_utxos() -> Result<(), BoxError> { + init_tracing(); + let (bitcoind, sender, receiver) = init_bitcoind_sender_receiver()?; + // Generate more UTXOs for the receiver + let receiver_address = + receiver.get_new_address(None, Some(AddressType::Bech32))?.assume_checked(); + bitcoind.client.generate_to_address(101, &receiver_address)?; + let receiver_utxos = receiver.list_unspent(None, None, None, None, None).unwrap(); + // TODO: do this with more utxos. currently it fails due to min_fee_rate not being met + assert_eq!(2, receiver_utxos.len(), "receiver doesn't have enough UTXOs"); + assert_eq!( + Amount::from_btc(100.0)?, + receiver.get_balances()?.mine.trusted, + "receiver doesn't have enough bitcoin" + ); + + // Receiver creates the payjoin URI + let pj_receiver_address = receiver.get_new_address(None, None)?.assume_checked(); + let pj_uri = PjUriBuilder::new(pj_receiver_address, EXAMPLE_URL.to_owned()) + .amount(Amount::ONE_BTC) + .build(); + + // ********************** + // Inside the Sender: + // Sender create a funded PSBT (not broadcasted) to address with amount given in the pj_uri + let uri = Uri::from_str(&pj_uri.to_string()) + .unwrap() + .assume_checked() + .check_pj_supported() + .unwrap(); + let psbt = build_original_psbt(&sender, &uri)?; + log::debug!("Original psbt: {:#?}", psbt); + let (req, ctx) = RequestBuilder::from_psbt_and_uri(psbt, uri)? + .build_with_additional_fee(Amount::from_sat(10000), None, FeeRate::ZERO, false)? + .extract_v1()?; + let headers = HeaderMock::new(&req.body, req.content_type); + + // ********************** + // Inside the Receiver: + // this data would transit from one party to another over the network in production + let outputs = vec![( + TxOut { + value: Amount::from_btc(100.0)?, + script_pubkey: receiver + .get_new_address(None, None)? + .assume_checked() + .script_pubkey(), + }, + true, // owned by receiver + )]; + let inputs = receiver_utxos + .iter() + .map(|utxo| { + let outpoint = OutPoint { txid: utxo.txid, vout: utxo.vout }; + let txo = bitcoin::TxOut { + value: utxo.amount, + script_pubkey: utxo.script_pub_key.clone(), + }; + (outpoint, txo) + }) + .collect(); + let response = + handle_v1_pj_request(req, headers, &receiver, Some(outputs), Some(inputs)); + // this response would be returned as http response to the sender + + // ********************** + // Inside the Sender: + // Sender checks, signs, finalizes, extracts, and broadcasts + let checked_payjoin_proposal_psbt = ctx.process_response(&mut response.as_bytes())?; + let payjoin_tx = extract_pj_tx(&sender, checked_payjoin_proposal_psbt)?; + dbg!(&payjoin_tx); + sender.send_raw_transaction(&payjoin_tx)?; + assert_eq!(receiver.get_balances()?.mine.untrusted_pending, Amount::from_btc(101.0)?); + assert_eq!( + sender.get_balances()?.mine.untrusted_pending, + Amount::from_btc(49.0)? - Amount::from_sat(282) // subtract the network fee + ); + Ok(()) + } + + // In this test the receiver forwards part of the sender payment to another payee + #[test] + fn receiver_forwards_payment() -> Result<(), BoxError> { + init_tracing(); + let (bitcoind, sender, receiver) = init_bitcoind_sender_receiver()?; + let third_party = bitcoind.create_wallet("third-party")?; + + // Receiver creates the payjoin URI + let pj_receiver_address = receiver.get_new_address(None, None)?.assume_checked(); + let pj_uri = PjUriBuilder::new(pj_receiver_address, EXAMPLE_URL.to_owned()) + .amount(Amount::ONE_BTC) + .build(); + + // ********************** + // Inside the Sender: + // Sender create a funded PSBT (not broadcasted) to address with amount given in the pj_uri + let uri = Uri::from_str(&pj_uri.to_string()) + .unwrap() + .assume_checked() + .check_pj_supported() + .unwrap(); + let psbt = build_original_psbt(&sender, &uri)?; + log::debug!("Original psbt: {:#?}", psbt); + let (req, ctx) = RequestBuilder::from_psbt_and_uri(psbt, uri)? + .build_with_additional_fee(Amount::from_sat(10000), None, FeeRate::ZERO, false)? + .extract_v1()?; + let headers = HeaderMock::new(&req.body, req.content_type); + + // ********************** + // Inside the Receiver: + // this data would transit from one party to another over the network in production + let outputs = vec![ + ( + TxOut { + value: Amount::from_sat(10000000), + script_pubkey: third_party + .get_new_address(None, None)? + .assume_checked() + .script_pubkey(), + }, + false, // not owned by receiver + ), + ( + TxOut { + value: Amount::from_sat(90000000), + script_pubkey: receiver + .get_new_address(None, None)? + .assume_checked() + .script_pubkey(), + }, + true, // owned by receiver + ), + ]; + let inputs = vec![]; + let response = + handle_v1_pj_request(req, headers, &receiver, Some(outputs), Some(inputs)); + // this response would be returned as http response to the sender + + // ********************** + // Inside the Sender: + // Sender checks, signs, finalizes, extracts, and broadcasts + let checked_payjoin_proposal_psbt = ctx.process_response(&mut response.as_bytes())?; + let payjoin_tx = extract_pj_tx(&sender, checked_payjoin_proposal_psbt)?; + sender.send_raw_transaction(&payjoin_tx)?; + assert_eq!(receiver.get_balances()?.mine.untrusted_pending, Amount::from_btc(0.9)?); + assert_eq!(third_party.get_balances()?.mine.untrusted_pending, Amount::from_btc(0.1)?); + assert_eq!( + // sender balance is considered "trusted" because all inputs in the transaction were + // created by their wallet + sender.get_balances()?.mine.trusted, + Amount::from_btc(49.0)? - Amount::from_sat(282) // subtract the network fee + ); + Ok(()) + } + } + fn init_tracing() { INIT_TRACING.get_or_init(|| { let subscriber = FmtSubscriber::builder() @@ -787,6 +951,8 @@ mod integration { req: Request, headers: impl payjoin::receive::Headers, receiver: &bitcoincore_rpc::Client, + custom_outputs: Option>, + custom_inputs: Option>, ) -> String { // Receiver receive payjoin proposal, IRL it will be an HTTP request (over ssl or onion) let proposal = payjoin::receive::UncheckedProposal::from_request( @@ -795,7 +961,7 @@ mod integration { headers, ) .unwrap(); - let proposal = handle_proposal(proposal, receiver); + let proposal = handle_proposal(proposal, receiver, custom_outputs, custom_inputs); assert!(!proposal.is_output_substitution_disabled()); let psbt = proposal.psbt(); tracing::debug!("Receiver's Payjoin proposal PSBT: {:#?}", &psbt); @@ -805,6 +971,8 @@ mod integration { fn handle_proposal( proposal: payjoin::receive::UncheckedProposal, receiver: &bitcoincore_rpc::Client, + custom_outputs: Option>, + custom_inputs: Option>, ) -> payjoin::receive::PayjoinProposal { // in a payment processor where the sender could go offline, this is where you schedule to broadcast the original_tx let _to_broadcast_in_failure_case = proposal.extract_tx_to_schedule_broadcast(); @@ -845,31 +1013,46 @@ mod integration { }) .expect("Receiver should have at least one output"); - let payjoin = payjoin - .try_substitute_receiver_output(|| { - Ok(receiver.get_new_address(None, None).unwrap().assume_checked().script_pubkey()) - }) - .expect("Could not substitute outputs"); - - // Select receiver payjoin inputs. TODO Lock them. - let available_inputs = receiver.list_unspent(None, None, None, None, None).unwrap(); - let candidate_inputs: HashMap = available_inputs - .iter() - .map(|i| (i.amount, OutPoint { txid: i.txid, vout: i.vout })) - .collect(); - - let selected_outpoint = payjoin.try_preserving_privacy(candidate_inputs).expect("gg"); - let selected_utxo = available_inputs - .iter() - .find(|i| i.txid == selected_outpoint.txid && i.vout == selected_outpoint.vout) - .unwrap(); - let txo_to_contribute = bitcoin::TxOut { - value: selected_utxo.amount, - script_pubkey: selected_utxo.script_pub_key.clone(), + let payjoin = match custom_outputs { + Some(_) => payjoin + .try_substitute_receiver_outputs(custom_outputs) + .expect("Could not substitute outputs"), + None => payjoin + .try_substitute_receiver_output(|| { + Ok(receiver + .get_new_address(None, None) + .unwrap() + .assume_checked() + .script_pubkey()) + }) + .expect("Could not substitute outputs"), + }; + + let inputs = match custom_inputs { + Some(inputs) => inputs, + None => { + // Select receiver payjoin inputs. TODO Lock them. + let available_inputs = receiver.list_unspent(None, None, None, None, None).unwrap(); + let candidate_inputs: HashMap = available_inputs + .iter() + .map(|i| (i.amount, OutPoint { txid: i.txid, vout: i.vout })) + .collect(); + + let selected_outpoint = + payjoin.try_preserving_privacy(candidate_inputs).expect("gg"); + let selected_utxo = available_inputs + .iter() + .find(|i| i.txid == selected_outpoint.txid && i.vout == selected_outpoint.vout) + .unwrap(); + let txo_to_contribute = bitcoin::TxOut { + value: selected_utxo.amount, + script_pubkey: selected_utxo.script_pub_key.clone(), + }; + vec![(selected_outpoint, txo_to_contribute)] + } }; - let payjoin = - payjoin.contribute_witness_inputs(vec![(selected_outpoint, txo_to_contribute)]); + let payjoin = payjoin.contribute_witness_inputs(inputs); let payjoin_proposal = payjoin .finalize_proposal(