kaccy_bitcoin/
payjoin.rs

1//! PayJoin (P2EP) Implementation
2//!
3//! PayJoin is a privacy-enhancing technique where the sender and receiver
4//! collaborate to create a transaction that breaks the common-input-ownership
5//! heuristic used for blockchain analysis.
6//!
7//! This implementation follows BIP 78 (PayJoin).
8
9use crate::error::BitcoinError;
10use bitcoin::{Address, Amount, OutPoint, Transaction, TxOut};
11use serde::{Deserialize, Serialize};
12use std::collections::HashMap;
13use std::str::FromStr;
14use uuid::Uuid;
15
16/// PayJoin role - sender or receiver
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
18pub enum PayJoinRole {
19    /// Sender initiates the payment
20    Sender,
21    /// Receiver accepts and enhances the transaction
22    Receiver,
23}
24
25/// PayJoin version (BIP 78 compatibility)
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
27pub enum PayJoinVersion {
28    /// Version 1 (current standard)
29    V1,
30    /// Version 2 (future)
31    V2,
32}
33
34/// PayJoin proposal from sender
35#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct PayJoinProposal {
37    /// Unique proposal ID
38    pub id: Uuid,
39    /// Original transaction (unsigned or partially signed)
40    pub original_psbt: String,
41    /// Amount to pay (in satoshis)
42    pub amount: u64,
43    /// Receiver's address (as string)
44    pub receiver_address: String,
45    /// Additional parameters
46    pub params: PayJoinParams,
47}
48
49impl PayJoinProposal {
50    /// Get the receiver address as an Address type
51    pub fn get_receiver_address(&self) -> Result<Address, BitcoinError> {
52        Address::from_str(&self.receiver_address)
53            .map_err(|e| BitcoinError::InvalidAddress(e.to_string()))
54            .map(|a| a.assume_checked())
55    }
56}
57
58/// PayJoin parameters
59#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct PayJoinParams {
61    /// Version of PayJoin protocol
62    pub version: PayJoinVersion,
63    /// Disable output substitution (for testing)
64    pub disable_output_substitution: bool,
65    /// Minimum confirmations for receiver inputs
66    pub min_confirmations: u32,
67    /// Maximum additional fee contribution from receiver
68    pub max_additional_fee: u64,
69}
70
71impl Default for PayJoinParams {
72    fn default() -> Self {
73        Self {
74            version: PayJoinVersion::V1,
75            disable_output_substitution: false,
76            min_confirmations: 1,
77            max_additional_fee: 1000, // 1000 sats
78        }
79    }
80}
81
82/// PayJoin response from receiver
83#[derive(Debug, Clone, Serialize, Deserialize)]
84pub struct PayJoinResponse {
85    /// Proposal ID this responds to
86    pub proposal_id: Uuid,
87    /// Enhanced PSBT with receiver's inputs
88    pub payjoin_psbt: String,
89    /// Receiver's contribution details
90    pub contribution: ReceiverContribution,
91}
92
93/// Receiver's contribution to the PayJoin transaction
94#[derive(Debug, Clone, Serialize, Deserialize)]
95pub struct ReceiverContribution {
96    /// Inputs added by receiver
97    pub inputs_added: Vec<OutPoint>,
98    /// Total value of added inputs (satoshis)
99    pub input_value: u64,
100    /// Additional outputs added (change)
101    pub outputs_added: Vec<TxOut>,
102}
103
104/// PayJoin coordinator - manages the PayJoin flow
105pub struct PayJoinCoordinator {
106    /// Active proposals
107    proposals: HashMap<Uuid, PayJoinProposal>,
108}
109
110impl PayJoinCoordinator {
111    /// Create a new PayJoin coordinator
112    pub fn new() -> Self {
113        Self {
114            proposals: HashMap::new(),
115        }
116    }
117
118    /// Create a PayJoin proposal (sender side)
119    pub fn create_proposal(
120        &mut self,
121        original_psbt: String,
122        amount: u64,
123        receiver_address: Address,
124        params: Option<PayJoinParams>,
125    ) -> PayJoinProposal {
126        let proposal = PayJoinProposal {
127            id: Uuid::new_v4(),
128            original_psbt,
129            amount,
130            receiver_address: receiver_address.to_string(),
131            params: params.unwrap_or_default(),
132        };
133
134        self.proposals.insert(proposal.id, proposal.clone());
135        proposal
136    }
137
138    /// Get a proposal by ID
139    pub fn get_proposal(&self, id: &Uuid) -> Option<&PayJoinProposal> {
140        self.proposals.get(id)
141    }
142
143    /// Validate a PayJoin proposal (receiver side)
144    pub fn validate_proposal(&self, proposal: &PayJoinProposal) -> Result<(), BitcoinError> {
145        // Check version compatibility
146        if proposal.params.version != PayJoinVersion::V1 {
147            return Err(BitcoinError::Validation(
148                "Unsupported PayJoin version".to_string(),
149            ));
150        }
151
152        // Check amount is positive
153        if proposal.amount == 0 {
154            return Err(BitcoinError::Validation(
155                "Payment amount must be positive".to_string(),
156            ));
157        }
158
159        // Validate PSBT format (basic check)
160        if proposal.original_psbt.is_empty() {
161            return Err(BitcoinError::Validation(
162                "Original PSBT is empty".to_string(),
163            ));
164        }
165
166        Ok(())
167    }
168
169    /// Remove expired proposals
170    pub fn cleanup_expired(&mut self, max_age_secs: u64) {
171        // Implementation would check timestamps and remove old proposals
172        // For now, keep all proposals
173        let _ = max_age_secs;
174    }
175}
176
177impl Default for PayJoinCoordinator {
178    fn default() -> Self {
179        Self::new()
180    }
181}
182
183/// PayJoin receiver - handles the receiver side of PayJoin
184pub struct PayJoinReceiver {
185    /// Available UTXOs for PayJoin
186    available_utxos: Vec<ReceiverUtxo>,
187}
188
189/// UTXO available for PayJoin enhancement
190#[derive(Debug, Clone)]
191pub struct ReceiverUtxo {
192    /// Outpoint
193    pub outpoint: OutPoint,
194    /// Value in satoshis
195    pub value: u64,
196    /// Number of confirmations
197    pub confirmations: u32,
198    /// Script pubkey
199    pub script_pubkey: Vec<u8>,
200}
201
202impl PayJoinReceiver {
203    /// Create a new PayJoin receiver
204    pub fn new(available_utxos: Vec<ReceiverUtxo>) -> Self {
205        Self { available_utxos }
206    }
207
208    /// Enhance a transaction with receiver inputs (core PayJoin logic)
209    pub fn enhance_transaction(
210        &self,
211        proposal: &PayJoinProposal,
212        change_address: Option<Address>,
213    ) -> Result<PayJoinResponse, BitcoinError> {
214        // Select appropriate UTXOs based on min confirmations
215        let eligible_utxos: Vec<_> = self
216            .available_utxos
217            .iter()
218            .filter(|u| u.confirmations >= proposal.params.min_confirmations)
219            .collect();
220
221        if eligible_utxos.is_empty() {
222            return Err(BitcoinError::Validation(
223                "No eligible UTXOs for PayJoin".to_string(),
224            ));
225        }
226
227        // For simplicity, select one UTXO
228        let selected_utxo = eligible_utxos[0];
229
230        // Build contribution
231        let contribution = ReceiverContribution {
232            inputs_added: vec![selected_utxo.outpoint],
233            input_value: selected_utxo.value,
234            outputs_added: if let Some(addr) = change_address {
235                let change_value = selected_utxo
236                    .value
237                    .saturating_sub(proposal.params.max_additional_fee);
238                vec![TxOut {
239                    value: Amount::from_sat(change_value),
240                    script_pubkey: addr.script_pubkey(),
241                }]
242            } else {
243                vec![]
244            },
245        };
246
247        // In a real implementation, we would:
248        // 1. Parse the original PSBT
249        // 2. Add receiver's inputs
250        // 3. Adjust outputs (add change if needed)
251        // 4. Re-serialize to PSBT
252
253        let response = PayJoinResponse {
254            proposal_id: proposal.id,
255            payjoin_psbt: proposal.original_psbt.clone(), // Placeholder
256            contribution,
257        };
258
259        Ok(response)
260    }
261}
262
263/// PayJoin sender - handles the sender side
264pub struct PayJoinSender {
265    /// Coordinator reference
266    coordinator: PayJoinCoordinator,
267}
268
269impl PayJoinSender {
270    /// Create a new PayJoin sender
271    pub fn new() -> Self {
272        Self {
273            coordinator: PayJoinCoordinator::new(),
274        }
275    }
276
277    /// Initiate a PayJoin payment
278    pub fn initiate_payment(
279        &mut self,
280        original_psbt: String,
281        amount: u64,
282        receiver_address: Address,
283        params: Option<PayJoinParams>,
284    ) -> PayJoinProposal {
285        self.coordinator
286            .create_proposal(original_psbt, amount, receiver_address, params)
287    }
288
289    /// Verify and finalize a PayJoin response
290    pub fn finalize_payjoin(
291        &self,
292        response: &PayJoinResponse,
293    ) -> Result<Transaction, BitcoinError> {
294        // Get original proposal
295        let proposal = self
296            .coordinator
297            .get_proposal(&response.proposal_id)
298            .ok_or_else(|| BitcoinError::Validation("Proposal not found".to_string()))?;
299
300        // Validate the response
301        self.validate_response(proposal, response)?;
302
303        // In a real implementation:
304        // 1. Parse the PayJoin PSBT
305        // 2. Verify all inputs and outputs
306        // 3. Sign the transaction
307        // 4. Finalize and extract
308
309        // Placeholder: return empty transaction
310        Ok(Transaction {
311            version: bitcoin::transaction::Version::TWO,
312            lock_time: bitcoin::blockdata::locktime::absolute::LockTime::ZERO,
313            input: vec![],
314            output: vec![],
315        })
316    }
317
318    /// Validate a PayJoin response
319    fn validate_response(
320        &self,
321        proposal: &PayJoinProposal,
322        response: &PayJoinResponse,
323    ) -> Result<(), BitcoinError> {
324        // Check that receiver didn't add too much fee
325        let total_input_value = response.contribution.input_value;
326        let total_output_value: u64 = response
327            .contribution
328            .outputs_added
329            .iter()
330            .map(|o| o.value.to_sat())
331            .sum();
332
333        let fee_contribution = total_input_value.saturating_sub(total_output_value);
334        if fee_contribution > proposal.params.max_additional_fee {
335            return Err(BitcoinError::Validation(
336                "Receiver's fee contribution exceeds maximum".to_string(),
337            ));
338        }
339
340        Ok(())
341    }
342}
343
344impl Default for PayJoinSender {
345    fn default() -> Self {
346        Self::new()
347    }
348}
349
350/// PayJoin URI builder (BIP 21 with PayJoin extension)
351pub struct PayJoinUriBuilder {
352    address: Address,
353    amount: Option<u64>,
354    endpoint: Option<String>,
355}
356
357impl PayJoinUriBuilder {
358    /// Create a new PayJoin URI builder
359    pub fn new(address: Address) -> Self {
360        Self {
361            address,
362            amount: None,
363            endpoint: None,
364        }
365    }
366
367    /// Set payment amount
368    pub fn amount(mut self, amount: u64) -> Self {
369        self.amount = Some(amount);
370        self
371    }
372
373    /// Set PayJoin endpoint URL
374    pub fn endpoint(mut self, endpoint: String) -> Self {
375        self.endpoint = Some(endpoint);
376        self
377    }
378
379    /// Build the URI string
380    pub fn build(self) -> String {
381        let mut uri = format!("bitcoin:{}", self.address);
382        let mut params = vec![];
383
384        if let Some(amt) = self.amount {
385            params.push(format!("amount={}", amt as f64 / 100_000_000.0));
386        }
387
388        if let Some(ep) = self.endpoint {
389            params.push(format!("pj={}", ep));
390        }
391
392        if !params.is_empty() {
393            uri.push('?');
394            uri.push_str(&params.join("&"));
395        }
396
397        uri
398    }
399}
400
401#[cfg(test)]
402mod tests {
403    use super::*;
404    use std::str::FromStr;
405
406    #[test]
407    fn test_payjoin_coordinator() {
408        let mut coordinator = PayJoinCoordinator::new();
409        let address = Address::from_str("bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4")
410            .unwrap()
411            .assume_checked();
412
413        let proposal =
414            coordinator.create_proposal("psbt_base64_here".to_string(), 100000, address, None);
415
416        assert_eq!(
417            coordinator.get_proposal(&proposal.id).unwrap().amount,
418            100000
419        );
420    }
421
422    #[test]
423    fn test_payjoin_params_defaults() {
424        let params = PayJoinParams::default();
425        assert_eq!(params.version, PayJoinVersion::V1);
426        assert!(!params.disable_output_substitution);
427        assert_eq!(params.min_confirmations, 1);
428        assert_eq!(params.max_additional_fee, 1000);
429    }
430
431    #[test]
432    fn test_payjoin_uri_builder() {
433        let address = Address::from_str("bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4")
434            .unwrap()
435            .assume_checked();
436
437        let uri = PayJoinUriBuilder::new(address)
438            .amount(100000)
439            .endpoint("https://example.com/payjoin".to_string())
440            .build();
441
442        assert!(uri.contains("bitcoin:bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4"));
443        assert!(uri.contains("pj=https://example.com/payjoin"));
444    }
445
446    #[test]
447    fn test_validate_proposal() {
448        let coordinator = PayJoinCoordinator::new();
449        let address = Address::from_str("bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4")
450            .unwrap()
451            .assume_checked();
452
453        let proposal = PayJoinProposal {
454            id: Uuid::new_v4(),
455            original_psbt: "psbt_data".to_string(),
456            amount: 50000,
457            receiver_address: address.to_string(),
458            params: PayJoinParams::default(),
459        };
460
461        assert!(coordinator.validate_proposal(&proposal).is_ok());
462    }
463
464    #[test]
465    fn test_validate_invalid_proposal() {
466        let coordinator = PayJoinCoordinator::new();
467        let address = Address::from_str("bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4")
468            .unwrap()
469            .assume_checked();
470
471        // Zero amount
472        let proposal = PayJoinProposal {
473            id: Uuid::new_v4(),
474            original_psbt: "psbt_data".to_string(),
475            amount: 0,
476            receiver_address: address.to_string(),
477            params: PayJoinParams::default(),
478        };
479
480        assert!(coordinator.validate_proposal(&proposal).is_err());
481    }
482
483    #[test]
484    fn test_payjoin_sender() {
485        let mut sender = PayJoinSender::new();
486        let address = Address::from_str("bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4")
487            .unwrap()
488            .assume_checked();
489
490        let proposal = sender.initiate_payment("psbt_base64".to_string(), 100000, address, None);
491
492        assert_eq!(proposal.amount, 100000);
493    }
494}