1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
18pub enum PayJoinRole {
19 Sender,
21 Receiver,
23}
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
27pub enum PayJoinVersion {
28 V1,
30 V2,
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct PayJoinProposal {
37 pub id: Uuid,
39 pub original_psbt: String,
41 pub amount: u64,
43 pub receiver_address: String,
45 pub params: PayJoinParams,
47}
48
49impl PayJoinProposal {
50 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#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct PayJoinParams {
61 pub version: PayJoinVersion,
63 pub disable_output_substitution: bool,
65 pub min_confirmations: u32,
67 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, }
79 }
80}
81
82#[derive(Debug, Clone, Serialize, Deserialize)]
84pub struct PayJoinResponse {
85 pub proposal_id: Uuid,
87 pub payjoin_psbt: String,
89 pub contribution: ReceiverContribution,
91}
92
93#[derive(Debug, Clone, Serialize, Deserialize)]
95pub struct ReceiverContribution {
96 pub inputs_added: Vec<OutPoint>,
98 pub input_value: u64,
100 pub outputs_added: Vec<TxOut>,
102}
103
104pub struct PayJoinCoordinator {
106 proposals: HashMap<Uuid, PayJoinProposal>,
108}
109
110impl PayJoinCoordinator {
111 pub fn new() -> Self {
113 Self {
114 proposals: HashMap::new(),
115 }
116 }
117
118 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 pub fn get_proposal(&self, id: &Uuid) -> Option<&PayJoinProposal> {
140 self.proposals.get(id)
141 }
142
143 pub fn validate_proposal(&self, proposal: &PayJoinProposal) -> Result<(), BitcoinError> {
145 if proposal.params.version != PayJoinVersion::V1 {
147 return Err(BitcoinError::Validation(
148 "Unsupported PayJoin version".to_string(),
149 ));
150 }
151
152 if proposal.amount == 0 {
154 return Err(BitcoinError::Validation(
155 "Payment amount must be positive".to_string(),
156 ));
157 }
158
159 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 pub fn cleanup_expired(&mut self, max_age_secs: u64) {
171 let _ = max_age_secs;
174 }
175}
176
177impl Default for PayJoinCoordinator {
178 fn default() -> Self {
179 Self::new()
180 }
181}
182
183pub struct PayJoinReceiver {
185 available_utxos: Vec<ReceiverUtxo>,
187}
188
189#[derive(Debug, Clone)]
191pub struct ReceiverUtxo {
192 pub outpoint: OutPoint,
194 pub value: u64,
196 pub confirmations: u32,
198 pub script_pubkey: Vec<u8>,
200}
201
202impl PayJoinReceiver {
203 pub fn new(available_utxos: Vec<ReceiverUtxo>) -> Self {
205 Self { available_utxos }
206 }
207
208 pub fn enhance_transaction(
210 &self,
211 proposal: &PayJoinProposal,
212 change_address: Option<Address>,
213 ) -> Result<PayJoinResponse, BitcoinError> {
214 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 let selected_utxo = eligible_utxos[0];
229
230 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 let response = PayJoinResponse {
254 proposal_id: proposal.id,
255 payjoin_psbt: proposal.original_psbt.clone(), contribution,
257 };
258
259 Ok(response)
260 }
261}
262
263pub struct PayJoinSender {
265 coordinator: PayJoinCoordinator,
267}
268
269impl PayJoinSender {
270 pub fn new() -> Self {
272 Self {
273 coordinator: PayJoinCoordinator::new(),
274 }
275 }
276
277 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 pub fn finalize_payjoin(
291 &self,
292 response: &PayJoinResponse,
293 ) -> Result<Transaction, BitcoinError> {
294 let proposal = self
296 .coordinator
297 .get_proposal(&response.proposal_id)
298 .ok_or_else(|| BitcoinError::Validation("Proposal not found".to_string()))?;
299
300 self.validate_response(proposal, response)?;
302
303 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 fn validate_response(
320 &self,
321 proposal: &PayJoinProposal,
322 response: &PayJoinResponse,
323 ) -> Result<(), BitcoinError> {
324 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
350pub struct PayJoinUriBuilder {
352 address: Address,
353 amount: Option<u64>,
354 endpoint: Option<String>,
355}
356
357impl PayJoinUriBuilder {
358 pub fn new(address: Address) -> Self {
360 Self {
361 address,
362 amount: None,
363 endpoint: None,
364 }
365 }
366
367 pub fn amount(mut self, amount: u64) -> Self {
369 self.amount = Some(amount);
370 self
371 }
372
373 pub fn endpoint(mut self, endpoint: String) -> Self {
375 self.endpoint = Some(endpoint);
376 self
377 }
378
379 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(¶ms.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 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}