The Payjoin SDK/rust-payjoin
is the most well-tested and flexible library for BIP 78 Payjoin and related privacy practices.
The primary crate, payjoin
, is runtime-agnostic. Data persistence, chain interactions, and networking may be provided by custom implementations or copy the reference payjoin-client
+ bitcoind, nolooking
+ LND integration, or bitmask-core
+ BDK integrations.
The following is a breakdown of the existing documentation and its application to the payjoin-client
reference implementation.
The sender
feature provides the check methods and PSBT data manipulation necessary to send payjoins. Just connect your wallet and an HTTP client. The reference implementation uses reqwest
and Bitcoin Core RPC. Only a few non-default parameters are required:
fn send_payjoin(
bitcoind: bitcoincore_rpc::Client,
bip21: &str,
danger_accept_invalid_certs: bool,
) -> Result<()>
Default modules including http and a bitcoin wallet may be useful additions to this library.
The danger_accept_invalid_certs
parameter is used for testing purposes only detailed in sectino 5.
Start by parsing a valid BIP 21 uri having the pj
parameter. This is the bip21
crate under the hood.
let link = payjoin::Uri::try_from(bip21)
.map_err(|e| anyhow!("Failed to create URI from BIP21: {}", e))?;
let link = link
.check_pj_supported()
.map_err(|e| anyhow!("The provided URI doesn't support payjoin (BIP78): {}", e))?;
let mut outputs = HashMap::with_capacity(1);
outputs.insert(link.address.to_string(), amount);
let options = bitcoincore_rpc::json::WalletCreateFundedPsbtOptions {
lock_unspent: Some(true),
fee_rate: Some(Amount::from_sat(2000)), // SHOULD CHANGE TO AN OPTIONAL FEE RATE
..Default::default()
};
let psbt = bitcoind
.wallet_create_funded_psbt(
&[], // inputs
&outputs,
None, // locktime
Some(options),
None,
)
.context("Failed to create PSBT")?
.psbt;
let psbt = bitcoind
.wallet_process_psbt(&psbt, None, None, None)
.with_context(|| "Failed to process PSBT")?
.psbt;
let psbt = load_psbt_from_base64(psbt.as_bytes()) // SHOULD BE PROVIDED BY CRATE AS HELPER USING rust-bitcoin base64 feature
.with_context(|| "Failed to load PSBT from base64")?;
log::debug!("Original psbt: {:#?}", psbt);
let pj_params = payjoin::sender::Configuration::with_fee_contribution(
payjoin::bitcoin::Amount::from_sat(10000),
None,
);
3. (optional) Spawn a thread or async task that will broadcast the transaction after one minute unless canceled
I wrote this in the original docs, but I think it should be amended.
In case the payjoin goes through but you still want to pay by default. This missing payjoin-client
Writing this, I think of Signal's contributing guidelines:
The answer is not more options. If you feel compelled to add a preference that's exposed to the user, it's very possible you've made a wrong turn somewhere.
let (req, ctx) = link
.create_pj_request(psbt, pj_params)
.with_context(|| "Failed to create payjoin request")?;
Senders request a payjoin from the receiver with a payload containing the Original PSBT and optional parameters. They require a secure endpoint for authentication and message secrecy to prevent that transaction from being modified by a malicious third party during transit or being snooped on. Only https and .onion endpoints are spec-compatible payjoin endpoints.
Avoiding the secure endpoint requirement is convenient for testing.
let client = reqwest::blocking::Client::builder()
.danger_accept_invalid_certs(danger_accept_invalid_certs)
.build()
.with_context(|| "Failed to build reqwest http client")?;
let response = client
.post(req.url)
.body(req.body)
.header("Content-Type", "text/plain")
.send()
.with_context(|| "HTTP request failed")?;
An Ok
response should include a Payjoin Proposal PSBT. Check that it's signed, following protocol, not trying to steal or otherwise error.
// TODO display well-known errors and log::debug the rest
let psbt = ctx.process_response(response).with_context(|| "Failed to process response")?;
log::debug!("Proposed psbt: {:#?}", psbt);
Payjoin response errors (called receiver's errors in spec) come from a remote server and can be used to "maliciously to phish a non technical user." Only those listed as "well known" in the spec should be displayed with preset messages to prevent phishing.
Most software can handle adding the last signatures to a PSBT without issue.
let psbt = bitcoind
.wallet_process_psbt(&serialize_psbt(&psbt), None, None, None)
.with_context(|| "Failed to process PSBT")?
.psbt;
let tx = bitcoind
.finalize_psbt(&psbt, Some(true))
.with_context(|| "Failed to finalize PSBT")?
.hex
.ok_or_else(|| anyhow!("Incomplete PSBT"))?;
In order to preserve privacy between the transaction and the IP address from which it originates, transaction broadcasting should be done using Tor, a VPN, or proxy.
let txid =
bitcoind.send_raw_transaction(&tx).with_context(|| "Failed to send raw transaction")?;
log::info!("Transaction sent: {}", txid);
📤 Sending payjoin is just that simple.
The receiver
feature provides all of the check methods, PSBT data manipulation, coin selection, and transport structures to receive payjoin and handle errors in a privacy preserving way.
Receiving payjoin entails listening to a secure http endpoint for inbound requests. The endpoint is displayed in the pj
parameter of a bip21 request URI.
The reference implementation uses rouille
sync http server and Bitcoin Core RPC.
fn receive_payjoin(
bitcoind: bitcoincore_rpc::Client,
amount_arg: &str,
endpoint_arg: &str,
) -> Result<()>
A BIP 21 URI supporting payjoin contains at minimum a bitcoin address and a secure pj
endpoint.
let pj_receiver_address = bitcoind.get_new_address(None, None)?;
let amount = Amount::from_sat(amount_arg.parse()?);
let pj_uri_string = format!(
"{}?amount={}&pj={}",
pj_receiver_address.to_qr_uri(),
amount.to_btc(),
endpoint_arg
);
let pj_uri = Uri::from_str(&pj_uri_string)
.map_err(|e| anyhow!("Constructed a bad URI string from args: {}", e))?;
let _pj_uri = pj_uri
.check_pj_supported()
.map_err(|e| anyhow!("Constructed URI does not support payjoin: {}", e))?;
Start a server to respond to payjoin protocol POST messages.
rouille::start_server("0.0.0.0:3000", move |req| handle_web_request(&req, &bitcoind));
// ...
fn handle_web_request(req: &Request, bitcoind: &bitcoincore_rpc::Client) -> Response {
handle_payjoin_request(req, bitcoind)
.map_err(|e| match e {
ReceiveError::RequestError(e) => {
log::error!("Error handling request: {}", e);
Response::text(e.to_string()).with_status_code(400)
}
e => {
log::error!("Error handling request: {}", e);
Response::text(e.to_string()).with_status_code(500)
}
})
.unwrap_or_else(|err_resp| err_resp)
}
Parse incoming HTTP request and check that it follows protocol.
let headers = Headers(req.headers());
let proposal = payjoin::receiver::UncheckedProposal::from_request(
req.data().context("Failed to read request body")?,
req.raw_query_string(),
headers,
)?;
Headers are parsed using the payjoin::receiver::Headers
Trait so that the library can iterate through them, ideally without cloning.
struct Headers<'a>(rouille::HeadersIter<'a>);
impl payjoin::receiver::Headers for Headers<'_> {
fn get_header(&self, key: &str) -> Option<&str> {
let mut copy = self.0.clone(); // lol
copy.find(|(k, _)| k.eq_ignore_ascii_case(key)).map(|(_, v)| v)
}
}
Check the sender's Original PSBT to prevent it from stealing, probing to fingerprint its wallet, and to avoid privacy gotchas.
// 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.get_transaction_to_schedule_broadcast();
// The network is used for checks later
let network = match bitcoind.get_blockchain_info()?.chain.as_str() {
"main" => bitcoin::Network::Bitcoin,
"test" => bitcoin::Network::Testnet,
"regtest" => bitcoin::Network::Regtest,
_ => return Err(ReceiveError::Other(anyhow!("Unknown network"))),
};
We need to know this transaction is consensus-valid.
let checked_1 = proposal.check_can_broadcast(|tx| {
bitcoind
.test_mempool_accept(&[bitcoin::consensus::encode::serialize(&tx).to_hex()])
.unwrap()
.first()
.unwrap()
.allowed
})?;
If writing a payment processor, schedule that this transaction is broadcast as fallback if the payjoin fails after a timeout. BTCPay broadcasts fallback after two minutes.
let checked_2 = checked_1.check_inputs_not_owned(|input| {
let address = bitcoin::Address::from_script(&input, network).unwrap();
bitcoind.get_address_info(&address).unwrap().is_mine.unwrap()
})?;
let checked_3 = checked_2.check_no_mixed_input_scripts()?;
Non-interactive i.e. payment processors should be careful to keep track of request inputs or else a malicious sender may try and probe multiple responses containing the receiver utxos, clustering their wallet.
let mut payjoin = checked_3
.check_no_inputs_seen_before(|_| false)
.unwrap()
.identify_receiver_outputs(|output_script| {
let address = bitcoin::Address::from_script(&output_script, network).unwrap();
bitcoind.get_address_info(&address).unwrap().is_mine.unwrap()
})?;
Here's where the PSBT is modified. Inputs may be added to break common input ownership heurstic. There are a number of ways to select coins that break common input heuristic but violate trecherous Unnecessary Input Heuristic (UIH) so that privacy preservation is destroyed are moot. Until February 2023, even BTCPay occasionally made these errors. Privacy preserving coin selection as implemented in try_preserving_privacy
is precarious and may be the most sensitive and valuable part of this kit.
Output substitution is another way to improve privacy, for example if the Original PSBT output address paying the receiver is coming from a static URI, a new address may be generated on the fly to avoid address reuse. This can even be done from a watch-only wallet. Output substitution may also be used to consolidate incoming funds to a remote cold wallet, break an output into smaller UTXOs to fulfil exchange orders, open lightning channels, and more.
// Select receiver payjoin inputs.
_ = try_contributing_inputs(&mut payjoin, bitcoind)
.map_err(|e| log::warn!("Failed to contribute inputs: {}", e));
let receiver_substitute_address = bitcoind.get_new_address(None, None)?;
payjoin.substitute_output_address(receiver_substitute_address);
// ...
fn try_contributing_inputs(
payjoin: &mut PayjoinProposal,
bitcoind: &bitcoincore_rpc::Client,
) -> Result<()> {
use bitcoin::OutPoint;
let available_inputs = bitcoind
.list_unspent(None, None, None, None, None)
.context("Failed to list unspent from bitcoind")?;
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)
.context("This shouldn't happen. Failed to retrieve the privacy preserving utxo from those we provided to the seclector.")?;
log::debug!("selected utxo: {:#?}", selected_utxo);
// calculate receiver payjoin outputs given receiver payjoin inputs and original_psbt,
let txo_to_contribute = bitcoin::TxOut {
value: selected_utxo.amount.to_sat(),
script_pubkey: selected_utxo.script_pub_key.clone(),
};
let outpoint_to_contribute =
bitcoin::OutPoint { txid: selected_utxo.txid, vout: selected_utxo.vout };
payjoin.contribute_witness_input(txo_to_contribute, outpoint_to_contribute);
Ok(())
}
Serious, in-depth research has gone into proper transaction construction. Here's a good starting point from the JoinMarket repo. Using methods for coin selection not provided by this library may have dire implications for privacy.
Fees are applied to the augmented Payjoin Proposal PSBT using calculation factoring the receiver's own preferred feerate and the sender's fee-related optional parameters. The current apply_fee
method is primitive, disregarding PSBT fee estimation and only adding fees coming from the sender's budget. When more accurate tools are available to calculate a PSBT's fee-dependent weight (slightly more complicated than it sounds, but solved, just unimplemented in rust-bitcoin), this apply_fee
should be improved.
let payjoin_proposal_psbt = payjoin.apply_fee(min_feerate_sat_per_vb: Some(1))?;
log::debug!("Extracted PSBT: {:#?}", payjoin_proposal_psbt);
// Sign payjoin psbt
let payjoin_base64_string =
base64::encode(bitcoin::consensus::serialize(&payjoin_proposal_psbt));
// `wallet_process_psbt` adds available utxo data and finalizes
let payjoin_proposal_psbt =
bitcoind.wallet_process_psbt(&payjoin_base64_string, sign: None, sighash_type: None, bip32derivs: Some(false))?.psbt;
let payjoin_proposal_psbt =
load_psbt_from_base64(payjoin_proposal_psbt.as_bytes()).context("Failed to parse PSBT")?;
BIP 78 defines specific PSBT validation rules that the sender accept, which prepare_psbt ensures. PSBTv0 was not designed to support input/output modification, so the protocol requires this step to be carried out precisely. A future PSBTv2 payjoin protocol may not.
It is critical to pay special care in the error response messages. Without special care, a receiver could make itself vulnerable to probing attacks which cluster its UTXOs.
let payjoin_proposal_psbt = payjoin.prepare_psbt(payjoin_proposal_psbt)?;
let payload = base64::encode(bitcoin::consensus::serialize(&payjoin_proposal_psbt));
Ok(Response::text(payload))
📥 That's how one receives a payjoin.
see payjoin/rust-payjoin#52 for discussion