Skip to content

Instantly share code, notes, and snippets.

@DanGould
Created May 23, 2023 02:12
Show Gist options
  • Save DanGould/595865688cabcfec42eb0e2a36f65490 to your computer and use it in GitHub Desktop.
Save DanGould/595865688cabcfec42eb0e2a36f65490 to your computer and use it in GitHub Desktop.
payjoin-mental-model-audit-0.7.0

What is the Payjoin SDK and How does it work?

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.

Send a Payjoin

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.

1. Parse BIP21 as payjoin::Uri

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

2. Construct URI request parameters, a finalized "Original PSBT" paying .amount to .address

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.

4. Construct the request with the PSBT and parameters

let (req, ctx) = link
    .create_pj_request(psbt, pj_params)
    .with_context(|| "Failed to create payjoin request")?;

5. Send the request and receive response

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

6. Process the response

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.

7. Sign and finalize the Payjoin Proposal PSBT

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

8. Broadcast the Payjoin Transaction

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.

Receive a Payjoin

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<()>

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 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)
}

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

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)
    }
}

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.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"))),
};

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| {
    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.

Check 2: Is the sender trying to make us sign our own inputs?

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

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

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 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.

6. Extract the payjoin PSBT and sign it

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

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

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment