Skip to content

Commit

Permalink
Add batching tests
Browse files Browse the repository at this point in the history
Adds two integration tests for transaction cut-through:
- Receiver UTXO consolidation
- Receiver forwarding payment to a third-party

Also fixes some issues in outputs and inputs contribution logic that
became apparent while testing.
  • Loading branch information
spacebear21 committed Aug 15, 2024
1 parent 0e04afd commit d0c3b92
Show file tree
Hide file tree
Showing 2 changed files with 214 additions and 29 deletions.
6 changes: 4 additions & 2 deletions payjoin/src/receive/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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 {
Expand All @@ -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)
}
}

Expand Down
237 changes: 210 additions & 27 deletions payjoin/tests/integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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

// **********************
Expand Down Expand Up @@ -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

// **********************
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -787,6 +951,8 @@ mod integration {
req: Request,
headers: impl payjoin::receive::Headers,
receiver: &bitcoincore_rpc::Client,
custom_outputs: Option<Vec<(TxOut, bool)>>,
custom_inputs: Option<Vec<(OutPoint, TxOut)>>,
) -> String {
// Receiver receive payjoin proposal, IRL it will be an HTTP request (over ssl or onion)
let proposal = payjoin::receive::UncheckedProposal::from_request(
Expand All @@ -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);
Expand All @@ -805,6 +971,8 @@ mod integration {
fn handle_proposal(
proposal: payjoin::receive::UncheckedProposal,
receiver: &bitcoincore_rpc::Client,
custom_outputs: Option<Vec<(TxOut, bool)>>,
custom_inputs: Option<Vec<(OutPoint, TxOut)>>,
) -> 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();
Expand Down Expand Up @@ -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<Amount, OutPoint> = 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<Amount, OutPoint> = 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(
Expand Down

0 comments on commit d0c3b92

Please sign in to comment.