use crate::error::BitcoinError;
use bitcoin::{Address, Amount, OutPoint, Transaction, TxOut};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::str::FromStr;
use uuid::Uuid;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum PayJoinRole {
Sender,
Receiver,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum PayJoinVersion {
V1,
V2,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PayJoinProposal {
pub id: Uuid,
pub original_psbt: String,
pub amount: u64,
pub receiver_address: String,
pub params: PayJoinParams,
}
impl PayJoinProposal {
pub fn get_receiver_address(&self) -> Result<Address, BitcoinError> {
Address::from_str(&self.receiver_address)
.map_err(|e| BitcoinError::InvalidAddress(e.to_string()))
.map(|a| a.assume_checked())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PayJoinParams {
pub version: PayJoinVersion,
pub disable_output_substitution: bool,
pub min_confirmations: u32,
pub max_additional_fee: u64,
}
impl Default for PayJoinParams {
fn default() -> Self {
Self {
version: PayJoinVersion::V1,
disable_output_substitution: false,
min_confirmations: 1,
max_additional_fee: 1000, }
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PayJoinResponse {
pub proposal_id: Uuid,
pub payjoin_psbt: String,
pub contribution: ReceiverContribution,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReceiverContribution {
pub inputs_added: Vec<OutPoint>,
pub input_value: u64,
pub outputs_added: Vec<TxOut>,
}
pub struct PayJoinCoordinator {
proposals: HashMap<Uuid, PayJoinProposal>,
}
impl PayJoinCoordinator {
pub fn new() -> Self {
Self {
proposals: HashMap::new(),
}
}
pub fn create_proposal(
&mut self,
original_psbt: String,
amount: u64,
receiver_address: Address,
params: Option<PayJoinParams>,
) -> PayJoinProposal {
let proposal = PayJoinProposal {
id: Uuid::new_v4(),
original_psbt,
amount,
receiver_address: receiver_address.to_string(),
params: params.unwrap_or_default(),
};
self.proposals.insert(proposal.id, proposal.clone());
proposal
}
pub fn get_proposal(&self, id: &Uuid) -> Option<&PayJoinProposal> {
self.proposals.get(id)
}
pub fn validate_proposal(&self, proposal: &PayJoinProposal) -> Result<(), BitcoinError> {
if proposal.params.version != PayJoinVersion::V1 {
return Err(BitcoinError::Validation(
"Unsupported PayJoin version".to_string(),
));
}
if proposal.amount == 0 {
return Err(BitcoinError::Validation(
"Payment amount must be positive".to_string(),
));
}
if proposal.original_psbt.is_empty() {
return Err(BitcoinError::Validation(
"Original PSBT is empty".to_string(),
));
}
Ok(())
}
pub fn cleanup_expired(&mut self, max_age_secs: u64) {
let _ = max_age_secs;
}
}
impl Default for PayJoinCoordinator {
fn default() -> Self {
Self::new()
}
}
pub struct PayJoinReceiver {
available_utxos: Vec<ReceiverUtxo>,
}
#[derive(Debug, Clone)]
pub struct ReceiverUtxo {
pub outpoint: OutPoint,
pub value: u64,
pub confirmations: u32,
pub script_pubkey: Vec<u8>,
}
impl PayJoinReceiver {
pub fn new(available_utxos: Vec<ReceiverUtxo>) -> Self {
Self { available_utxos }
}
pub fn enhance_transaction(
&self,
proposal: &PayJoinProposal,
change_address: Option<Address>,
) -> Result<PayJoinResponse, BitcoinError> {
let eligible_utxos: Vec<_> = self
.available_utxos
.iter()
.filter(|u| u.confirmations >= proposal.params.min_confirmations)
.collect();
if eligible_utxos.is_empty() {
return Err(BitcoinError::Validation(
"No eligible UTXOs for PayJoin".to_string(),
));
}
let selected_utxo = eligible_utxos[0];
let contribution = ReceiverContribution {
inputs_added: vec![selected_utxo.outpoint],
input_value: selected_utxo.value,
outputs_added: if let Some(addr) = change_address {
let change_value = selected_utxo
.value
.saturating_sub(proposal.params.max_additional_fee);
vec![TxOut {
value: Amount::from_sat(change_value),
script_pubkey: addr.script_pubkey(),
}]
} else {
vec![]
},
};
let response = PayJoinResponse {
proposal_id: proposal.id,
payjoin_psbt: proposal.original_psbt.clone(), contribution,
};
Ok(response)
}
}
pub struct PayJoinSender {
coordinator: PayJoinCoordinator,
}
impl PayJoinSender {
pub fn new() -> Self {
Self {
coordinator: PayJoinCoordinator::new(),
}
}
pub fn initiate_payment(
&mut self,
original_psbt: String,
amount: u64,
receiver_address: Address,
params: Option<PayJoinParams>,
) -> PayJoinProposal {
self.coordinator
.create_proposal(original_psbt, amount, receiver_address, params)
}
pub fn finalize_payjoin(
&self,
response: &PayJoinResponse,
) -> Result<Transaction, BitcoinError> {
let proposal = self
.coordinator
.get_proposal(&response.proposal_id)
.ok_or_else(|| BitcoinError::Validation("Proposal not found".to_string()))?;
self.validate_response(proposal, response)?;
Ok(Transaction {
version: bitcoin::transaction::Version::TWO,
lock_time: bitcoin::blockdata::locktime::absolute::LockTime::ZERO,
input: vec![],
output: vec![],
})
}
fn validate_response(
&self,
proposal: &PayJoinProposal,
response: &PayJoinResponse,
) -> Result<(), BitcoinError> {
let total_input_value = response.contribution.input_value;
let total_output_value: u64 = response
.contribution
.outputs_added
.iter()
.map(|o| o.value.to_sat())
.sum();
let fee_contribution = total_input_value.saturating_sub(total_output_value);
if fee_contribution > proposal.params.max_additional_fee {
return Err(BitcoinError::Validation(
"Receiver's fee contribution exceeds maximum".to_string(),
));
}
Ok(())
}
}
impl Default for PayJoinSender {
fn default() -> Self {
Self::new()
}
}
pub struct PayJoinUriBuilder {
address: Address,
amount: Option<u64>,
endpoint: Option<String>,
}
impl PayJoinUriBuilder {
pub fn new(address: Address) -> Self {
Self {
address,
amount: None,
endpoint: None,
}
}
pub fn amount(mut self, amount: u64) -> Self {
self.amount = Some(amount);
self
}
pub fn endpoint(mut self, endpoint: String) -> Self {
self.endpoint = Some(endpoint);
self
}
pub fn build(self) -> String {
let mut uri = format!("bitcoin:{}", self.address);
let mut params = vec![];
if let Some(amt) = self.amount {
params.push(format!("amount={}", amt as f64 / 100_000_000.0));
}
if let Some(ep) = self.endpoint {
params.push(format!("pj={}", ep));
}
if !params.is_empty() {
uri.push('?');
uri.push_str(¶ms.join("&"));
}
uri
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::str::FromStr;
#[test]
fn test_payjoin_coordinator() {
let mut coordinator = PayJoinCoordinator::new();
let address = Address::from_str("bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4")
.unwrap()
.assume_checked();
let proposal =
coordinator.create_proposal("psbt_base64_here".to_string(), 100000, address, None);
assert_eq!(
coordinator.get_proposal(&proposal.id).unwrap().amount,
100000
);
}
#[test]
fn test_payjoin_params_defaults() {
let params = PayJoinParams::default();
assert_eq!(params.version, PayJoinVersion::V1);
assert!(!params.disable_output_substitution);
assert_eq!(params.min_confirmations, 1);
assert_eq!(params.max_additional_fee, 1000);
}
#[test]
fn test_payjoin_uri_builder() {
let address = Address::from_str("bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4")
.unwrap()
.assume_checked();
let uri = PayJoinUriBuilder::new(address)
.amount(100000)
.endpoint("https://example.com/payjoin".to_string())
.build();
assert!(uri.contains("bitcoin:bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4"));
assert!(uri.contains("pj=https://example.com/payjoin"));
}
#[test]
fn test_validate_proposal() {
let coordinator = PayJoinCoordinator::new();
let address = Address::from_str("bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4")
.unwrap()
.assume_checked();
let proposal = PayJoinProposal {
id: Uuid::new_v4(),
original_psbt: "psbt_data".to_string(),
amount: 50000,
receiver_address: address.to_string(),
params: PayJoinParams::default(),
};
assert!(coordinator.validate_proposal(&proposal).is_ok());
}
#[test]
fn test_validate_invalid_proposal() {
let coordinator = PayJoinCoordinator::new();
let address = Address::from_str("bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4")
.unwrap()
.assume_checked();
let proposal = PayJoinProposal {
id: Uuid::new_v4(),
original_psbt: "psbt_data".to_string(),
amount: 0,
receiver_address: address.to_string(),
params: PayJoinParams::default(),
};
assert!(coordinator.validate_proposal(&proposal).is_err());
}
#[test]
fn test_payjoin_sender() {
let mut sender = PayJoinSender::new();
let address = Address::from_str("bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4")
.unwrap()
.assume_checked();
let proposal = sender.initiate_payment("psbt_base64".to_string(), 100000, address, None);
assert_eq!(proposal.amount, 100000);
}
}