Module payjoin::receive

source ·
Expand description

Receive Payjoin

This module contains types and methods used to receive payjoin via BIP78. Usage is pretty simple:

  1. Generate a pj_uri BIP 21 using payjoin::Uri::from_str
  2. Listen for a sender’s request on the pj endpoint
  3. Parse the request using UncheckedProposal::from_request()
  4. Validate the proposal using the check methods to guide you.
  5. Assuming the proposal is valid, augment it into a payjoin with the available try_preserving_privacy and contribute methods
  6. Extract the payjoin PSBT and sign it
  7. Respond to the sender’s http request with the signed PSBT as payload.

Receive a Payjoin

The receive 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 bip 21 request URI.

The reference implementation annotated below uses rouille sync http server and Bitcoin Core RPC.

fn receive_payjoin(
    bitcoind: bitcoincore_rpc::Client,
    amount_arg: &str,
    endpoint_arg: &str,
) -> Result<()>

1. Generate a pj_uri BIP21 using payjoin::Uri::from_str

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))?;

2. Listen for a sender’s request on the pj endpoint

Start a webserver of your choice to respond to payjoin protocol POST messages. The reference payjoin-cli implementation uses rouille sync http server.

rouille::start_server(self.config.pj_host.clone(), move |req| self.handle_web_request(req));
// ...
fn handle_web_request(&self, req: &Request) -> Response {
    log::debug!("Received request: {:?}", req);
    match (req.method(), req.url().as_ref()) {
        // ...
        ("POST", _) => self
            .handle_payjoin_post(req)
            .map_err(|e| match e {
                Error::BadRequest(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),
        _ => Response::empty_404(),
    }
}

3. Parse an incoming request using UncheckedProposal::from_request()

Parse the incoming HTTP request and check that it follows protocol.

let headers = Headers(req.headers());
let proposal = payjoin::receive::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::receive::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)
    }
}

4. Validate the proposal using the check methods

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.extract_tx_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"))),
};
Check 1: Can the Original PSBT be Broadcast?

We need to know this transaction is consensus-valid.

let checked_1 = proposal.check_can_broadcast(|tx| {
        let raw_tx = bitcoin::consensus::encode::serialize(&tx).to_hex();
        let mempool_results = self
            .bitcoind
            .test_mempool_accept(&[raw_tx])
            .map_err(|e| Error::Server(e.into()))?;
        match mempool_results.first() {
            Some(result) => Ok(result.allowed),
            None => Err(Error::Server(
                anyhow!("No mempool results returned on broadcast check").into(),
            )),
        }
    })?;

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.

Check 2: Is the sender trying to make us sign our own inputs?
let checked_2 = checked_1.check_inputs_not_owned(|input| {
        if let Ok(address) = bitcoin::Address::from_script(input, network) {
            self.bitcoind
                .get_address_info(&address)
                .map(|info| info.is_mine.unwrap_or(false))
                .map_err(|e| Error::Server(e.into()))
        } else {
            Ok(false)
        }
    })?;
Check 3: Are there mixed input scripts, breaking stenographic privacy?
let checked_3 = checked_2.check_no_mixed_input_scripts()?;
Check 4: Have we seen this input before?

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 checked_4 = checked_3.check_no_inputs_seen_before(|input| {
    Ok(!self.insert_input_seen_before(*input).map_err(|e| Error::Server(e.into()))?)
})?;

5. Augment a valid proposal to preserve privacy

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 and break common input heuristic but fail to preserve privacy because of Unnecessary Input Heuristic (UIH). Until February 2023, even BTCPay occasionally made these errors. Privacy preserving coin selection as implemented in try_preserving_privacy is precarious to implement yourself may be the most sensitive and valuable part of this kit.

Output substitution is another way to improve privacy and increase functionality. 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 fulfill exchange orders, open lightning channels, and more.

// Distinguish our outputs to augment with input amount
let mut payjoin = checked_4.identify_receiver_outputs(|output_script| {
    if let Ok(address) = bitcoin::Address::from_script(output_script, network) {
        self.bitcoind
            .get_address_info(&address)
            .map(|info| info.is_mine.unwrap_or(false))
            .map_err(|e| Error::Server(e.into()))
    } else {
        Ok(false)
    }
})?;
// 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(())
}

Using methods for coin selection not provided by this library may have dire implications for privacy. Significant in-depth research and careful implementation iteration has gone into privacy preserving transaction construction. Here’s a good starting point from the JoinMarket repo to being a deep dive of your own.

6. Extract the payjoin PSBT and sign it

Fees are applied to the augmented Payjoin Proposal PSBT using calculation factoring both receiver’s 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 (solved, more complicated than it sounds, but unimplemented in rust-bitcoin), this apply_fee should be improved.

let min_feerate_sat_per_vb = 1;
let payjoin_proposal_psbt = payjoin.apply_fee(Some(min_feerate_sat_per_vb))?;

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 =
    Psbt::from_str(&payjoin_proposal_psbt).with_context(|| "Failed to parse PSBT")?;

7. Respond to the sender’s http request with the signed PSBT as payload

BIP 78 senders require specific PSBT validation constraints regulated by prepare_psbt. PSBTv0 was not designed to support input/output modification, so the protocol requires this precise preparation step. A future PSBTv2 payjoin protocol may not.

It is critical to pay special care when returning error response messages. Responding with internal errors can make a receiver vulnerable to sender probing attacks which cluster 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.

Structs

  • Typestate to validate that the Original PSBT has no receiver-owned inputs.
  • Typestate to validate that the Original PSBT has no inputs that have been seen before.
  • Typestate to validate that the Original PSBT has no mixed input types.
  • The receiver has not yet identified which outputs belong to the receiver.
  • A mutable checked proposal that the receiver may contribute inputs to to make a payjoin.
  • A mutable checked proposal that the receiver may contribute inputs to to make a payjoin.
  • Error that may occur when the request from sender is malformed.
  • Error that may occur when coin selection fails.
  • The sender’s original PSBT and optional parameters

Enums

Traits