kaccy_bitcoin/
coinjoin.rs

1//! CoinJoin Implementation
2//!
3//! CoinJoin is a privacy technique where multiple users combine their
4//! transactions into a single transaction, making it difficult for observers
5//! to determine which inputs correspond to which outputs.
6//!
7//! This implementation provides basic CoinJoin coordination and participation.
8
9use crate::error::BitcoinError;
10use bitcoin::{Address, Amount, OutPoint, Transaction, TxIn, TxOut};
11use chrono::{DateTime, Utc};
12use serde::{Deserialize, Serialize};
13use std::collections::HashMap;
14use std::str::FromStr;
15use uuid::Uuid;
16
17/// CoinJoin session state
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
19pub enum SessionState {
20    /// Waiting for participants to register
21    Registration,
22    /// Collecting inputs from participants
23    InputCollection,
24    /// Collecting outputs from participants
25    OutputCollection,
26    /// Signing phase
27    Signing,
28    /// Broadcasting the transaction
29    Broadcasting,
30    /// Session completed successfully
31    Completed,
32    /// Session failed or cancelled
33    Failed,
34}
35
36/// CoinJoin session configuration
37#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct SessionConfig {
39    /// Minimum number of participants
40    pub min_participants: usize,
41    /// Maximum number of participants
42    pub max_participants: usize,
43    /// Standard denomination (in satoshis)
44    pub denomination: u64,
45    /// Coordinator fee per participant (in satoshis)
46    pub coordinator_fee: u64,
47    /// Mining fee per participant (in satoshis)
48    pub mining_fee_per_participant: u64,
49    /// Timeout for registration (seconds)
50    pub registration_timeout: u64,
51    /// Timeout for signing (seconds)
52    pub signing_timeout: u64,
53}
54
55impl Default for SessionConfig {
56    fn default() -> Self {
57        Self {
58            min_participants: 3,
59            max_participants: 100,
60            denomination: 100_000,           // 0.001 BTC
61            coordinator_fee: 1000,           // 1000 sats
62            mining_fee_per_participant: 500, // 500 sats
63            registration_timeout: 600,       // 10 minutes
64            signing_timeout: 300,            // 5 minutes
65        }
66    }
67}
68
69/// CoinJoin participant
70#[derive(Debug, Clone, Serialize, Deserialize)]
71pub struct Participant {
72    /// Unique participant ID
73    pub id: Uuid,
74    /// Input being contributed
75    pub input: ParticipantInput,
76    /// Output address to receive funds (as string)
77    pub output_address: String,
78    /// Change address (if needed, as string)
79    pub change_address: Option<String>,
80    /// Registration timestamp
81    pub registered_at: DateTime<Utc>,
82    /// Whether the participant has signed
83    pub has_signed: bool,
84}
85
86impl Participant {
87    /// Get the output address as an Address type
88    pub fn get_output_address(&self) -> Result<Address, BitcoinError> {
89        Address::from_str(&self.output_address)
90            .map_err(|e| BitcoinError::InvalidAddress(e.to_string()))
91            .map(|a| a.assume_checked())
92    }
93
94    /// Get the change address as an Address type
95    pub fn get_change_address(&self) -> Result<Option<Address>, BitcoinError> {
96        self.change_address
97            .as_ref()
98            .map(|addr| {
99                Address::from_str(addr)
100                    .map_err(|e| BitcoinError::InvalidAddress(e.to_string()))
101                    .map(|a| a.assume_checked())
102            })
103            .transpose()
104    }
105}
106
107/// Input contributed by a participant
108#[derive(Debug, Clone, Serialize, Deserialize)]
109pub struct ParticipantInput {
110    /// Outpoint being spent
111    pub outpoint: OutPoint,
112    /// Value of the input (satoshis)
113    pub value: u64,
114    /// Script pubkey
115    pub script_pubkey: Vec<u8>,
116}
117
118/// CoinJoin session
119#[derive(Debug, Clone, Serialize, Deserialize)]
120pub struct CoinJoinSession {
121    /// Session ID
122    pub id: Uuid,
123    /// Session configuration
124    pub config: SessionConfig,
125    /// Current state
126    pub state: SessionState,
127    /// Participants
128    pub participants: Vec<Participant>,
129    /// Created timestamp
130    pub created_at: DateTime<Utc>,
131    /// Transaction being built
132    pub transaction: Option<Transaction>,
133    /// Signatures collected
134    pub signatures: HashMap<Uuid, Vec<u8>>,
135}
136
137impl CoinJoinSession {
138    /// Create a new CoinJoin session
139    pub fn new(config: SessionConfig) -> Self {
140        Self {
141            id: Uuid::new_v4(),
142            config,
143            state: SessionState::Registration,
144            participants: Vec::new(),
145            created_at: Utc::now(),
146            transaction: None,
147            signatures: HashMap::new(),
148        }
149    }
150
151    /// Add a participant to the session
152    pub fn add_participant(
153        &mut self,
154        input: ParticipantInput,
155        output_address: Address,
156        change_address: Option<Address>,
157    ) -> Result<Uuid, BitcoinError> {
158        if self.state != SessionState::Registration {
159            return Err(BitcoinError::Validation(
160                "Session is not in registration state".to_string(),
161            ));
162        }
163
164        if self.participants.len() >= self.config.max_participants {
165            return Err(BitcoinError::Validation("Session is full".to_string()));
166        }
167
168        // Validate input value meets minimum requirements
169        let required_input = self.config.denomination
170            + self.config.coordinator_fee
171            + self.config.mining_fee_per_participant;
172
173        if input.value < required_input {
174            return Err(BitcoinError::Validation(format!(
175                "Input value {} is less than required {}",
176                input.value, required_input
177            )));
178        }
179
180        let participant = Participant {
181            id: Uuid::new_v4(),
182            input,
183            output_address: output_address.to_string(),
184            change_address: change_address.map(|a| a.to_string()),
185            registered_at: Utc::now(),
186            has_signed: false,
187        };
188
189        let participant_id = participant.id;
190        self.participants.push(participant);
191
192        // Check if we can move to next phase
193        if self.participants.len() >= self.config.min_participants {
194            self.state = SessionState::InputCollection;
195        }
196
197        Ok(participant_id)
198    }
199
200    /// Build the CoinJoin transaction
201    pub fn build_transaction(&mut self) -> Result<(), BitcoinError> {
202        if self.state != SessionState::InputCollection {
203            return Err(BitcoinError::Validation(
204                "Cannot build transaction in current state".to_string(),
205            ));
206        }
207
208        if self.participants.len() < self.config.min_participants {
209            return Err(BitcoinError::Validation(
210                "Not enough participants".to_string(),
211            ));
212        }
213
214        // Collect inputs
215        let mut inputs = Vec::new();
216        for participant in &self.participants {
217            inputs.push(TxIn {
218                previous_output: participant.input.outpoint,
219                script_sig: bitcoin::blockdata::script::ScriptBuf::new(),
220                sequence: bitcoin::Sequence::MAX,
221                witness: bitcoin::Witness::new(),
222            });
223        }
224
225        // Collect outputs (equal denomination outputs + change)
226        let mut outputs = Vec::new();
227
228        // Add equal denomination outputs for each participant
229        for participant in &self.participants {
230            let addr = participant.get_output_address()?;
231            outputs.push(TxOut {
232                value: Amount::from_sat(self.config.denomination),
233                script_pubkey: addr.script_pubkey(),
234            });
235        }
236
237        // Add change outputs if needed
238        for participant in &self.participants {
239            let total_input = participant.input.value;
240            let total_output = self.config.denomination
241                + self.config.coordinator_fee
242                + self.config.mining_fee_per_participant;
243
244            let change = total_input.saturating_sub(total_output);
245            if change > 0 {
246                if let Some(change_addr) = participant.get_change_address()? {
247                    outputs.push(TxOut {
248                        value: Amount::from_sat(change),
249                        script_pubkey: change_addr.script_pubkey(),
250                    });
251                }
252            }
253        }
254
255        // Shuffle outputs for privacy
256        // (In a real implementation, use a verifiable shuffle)
257
258        let transaction = Transaction {
259            version: bitcoin::transaction::Version::TWO,
260            lock_time: bitcoin::blockdata::locktime::absolute::LockTime::ZERO,
261            input: inputs,
262            output: outputs,
263        };
264
265        self.transaction = Some(transaction);
266        self.state = SessionState::Signing;
267
268        Ok(())
269    }
270
271    /// Add a signature from a participant
272    pub fn add_signature(
273        &mut self,
274        participant_id: Uuid,
275        signature: Vec<u8>,
276    ) -> Result<(), BitcoinError> {
277        if self.state != SessionState::Signing {
278            return Err(BitcoinError::Validation(
279                "Session is not in signing state".to_string(),
280            ));
281        }
282
283        // Find participant
284        let participant = self
285            .participants
286            .iter_mut()
287            .find(|p| p.id == participant_id)
288            .ok_or_else(|| BitcoinError::Validation("Participant not found".to_string()))?;
289
290        participant.has_signed = true;
291        self.signatures.insert(participant_id, signature);
292
293        // Check if all participants have signed
294        if self.participants.iter().all(|p| p.has_signed) {
295            self.state = SessionState::Broadcasting;
296        }
297
298        Ok(())
299    }
300
301    /// Get the number of signatures collected
302    pub fn signature_count(&self) -> usize {
303        self.signatures.len()
304    }
305
306    /// Check if session is ready to broadcast
307    pub fn is_ready_to_broadcast(&self) -> bool {
308        self.state == SessionState::Broadcasting && self.signatures.len() == self.participants.len()
309    }
310
311    /// Mark session as completed
312    pub fn complete(&mut self) {
313        self.state = SessionState::Completed;
314    }
315
316    /// Mark session as failed
317    pub fn fail(&mut self) {
318        self.state = SessionState::Failed;
319    }
320}
321
322/// CoinJoin coordinator - manages sessions
323pub struct CoinJoinCoordinator {
324    /// Active sessions
325    sessions: HashMap<Uuid, CoinJoinSession>,
326    /// Default configuration
327    default_config: SessionConfig,
328}
329
330impl CoinJoinCoordinator {
331    /// Create a new coordinator
332    pub fn new(default_config: SessionConfig) -> Self {
333        Self {
334            sessions: HashMap::new(),
335            default_config,
336        }
337    }
338
339    /// Create a new session
340    pub fn create_session(&mut self, config: Option<SessionConfig>) -> Uuid {
341        let session = CoinJoinSession::new(config.unwrap_or_else(|| self.default_config.clone()));
342        let session_id = session.id;
343        self.sessions.insert(session_id, session);
344        session_id
345    }
346
347    /// Get a session
348    pub fn get_session(&self, session_id: &Uuid) -> Option<&CoinJoinSession> {
349        self.sessions.get(session_id)
350    }
351
352    /// Get a mutable session
353    pub fn get_session_mut(&mut self, session_id: &Uuid) -> Option<&mut CoinJoinSession> {
354        self.sessions.get_mut(session_id)
355    }
356
357    /// Join a session as a participant
358    pub fn join_session(
359        &mut self,
360        session_id: Uuid,
361        input: ParticipantInput,
362        output_address: Address,
363        change_address: Option<Address>,
364    ) -> Result<Uuid, BitcoinError> {
365        let session = self
366            .sessions
367            .get_mut(&session_id)
368            .ok_or_else(|| BitcoinError::Validation("Session not found".to_string()))?;
369
370        session.add_participant(input, output_address, change_address)
371    }
372
373    /// List active sessions
374    pub fn list_active_sessions(&self) -> Vec<&CoinJoinSession> {
375        self.sessions
376            .values()
377            .filter(|s| {
378                matches!(
379                    s.state,
380                    SessionState::Registration
381                        | SessionState::InputCollection
382                        | SessionState::OutputCollection
383                        | SessionState::Signing
384                )
385            })
386            .collect()
387    }
388
389    /// Clean up old sessions
390    pub fn cleanup_old_sessions(&mut self, max_age_secs: i64) {
391        let now = Utc::now();
392        self.sessions.retain(|_, session| {
393            let age = now.signed_duration_since(session.created_at).num_seconds();
394            age < max_age_secs || session.state == SessionState::Broadcasting
395        });
396    }
397}
398
399/// CoinJoin participant client
400pub struct CoinJoinClient {
401    /// Participant ID (if registered)
402    participant_id: Option<Uuid>,
403}
404
405impl CoinJoinClient {
406    /// Create a new client
407    pub fn new() -> Self {
408        Self {
409            participant_id: None,
410        }
411    }
412
413    /// Register for a CoinJoin session
414    pub fn register(
415        &mut self,
416        coordinator: &mut CoinJoinCoordinator,
417        session_id: Uuid,
418        input: ParticipantInput,
419        output_address: Address,
420        change_address: Option<Address>,
421    ) -> Result<Uuid, BitcoinError> {
422        let participant_id =
423            coordinator.join_session(session_id, input, output_address, change_address)?;
424
425        self.participant_id = Some(participant_id);
426        Ok(participant_id)
427    }
428
429    /// Submit a signature
430    pub fn submit_signature(
431        &self,
432        coordinator: &mut CoinJoinCoordinator,
433        session_id: Uuid,
434        signature: Vec<u8>,
435    ) -> Result<(), BitcoinError> {
436        let participant_id = self
437            .participant_id
438            .ok_or_else(|| BitcoinError::Validation("Not registered".to_string()))?;
439
440        let session = coordinator
441            .get_session_mut(&session_id)
442            .ok_or_else(|| BitcoinError::Validation("Session not found".to_string()))?;
443
444        session.add_signature(participant_id, signature)
445    }
446}
447
448impl Default for CoinJoinClient {
449    fn default() -> Self {
450        Self::new()
451    }
452}
453
454#[cfg(test)]
455mod tests {
456    use super::*;
457    use bitcoin::Txid;
458    use bitcoin::hashes::Hash;
459    use std::str::FromStr;
460
461    #[test]
462    fn test_session_creation() {
463        let config = SessionConfig::default();
464        let session = CoinJoinSession::new(config);
465
466        assert_eq!(session.state, SessionState::Registration);
467        assert_eq!(session.participants.len(), 0);
468    }
469
470    #[test]
471    fn test_add_participant() {
472        let mut session = CoinJoinSession::new(SessionConfig::default());
473        let address = Address::from_str("bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4")
474            .unwrap()
475            .assume_checked();
476
477        let input = ParticipantInput {
478            outpoint: OutPoint {
479                txid: Txid::all_zeros(),
480                vout: 0,
481            },
482            value: 200_000,
483            script_pubkey: vec![],
484        };
485
486        let result = session.add_participant(input, address, None);
487        assert!(result.is_ok());
488        assert_eq!(session.participants.len(), 1);
489    }
490
491    #[test]
492    fn test_coordinator() {
493        let mut coordinator = CoinJoinCoordinator::new(SessionConfig::default());
494        let session_id = coordinator.create_session(None);
495
496        assert!(coordinator.get_session(&session_id).is_some());
497    }
498
499    #[test]
500    fn test_session_state_progression() {
501        let mut session = CoinJoinSession::new(SessionConfig {
502            min_participants: 2,
503            ..Default::default()
504        });
505
506        assert_eq!(session.state, SessionState::Registration);
507
508        let address = Address::from_str("bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4")
509            .unwrap()
510            .assume_checked();
511
512        // Add first participant
513        let input1 = ParticipantInput {
514            outpoint: OutPoint {
515                txid: Txid::all_zeros(),
516                vout: 0,
517            },
518            value: 200_000,
519            script_pubkey: vec![],
520        };
521        session
522            .add_participant(input1, address.clone(), None)
523            .unwrap();
524
525        // Still in registration
526        assert_eq!(session.state, SessionState::Registration);
527
528        // Add second participant
529        let input2 = ParticipantInput {
530            outpoint: OutPoint {
531                txid: Txid::all_zeros(),
532                vout: 1,
533            },
534            value: 200_000,
535            script_pubkey: vec![],
536        };
537        session.add_participant(input2, address, None).unwrap();
538
539        // Should move to input collection
540        assert_eq!(session.state, SessionState::InputCollection);
541    }
542
543    #[test]
544    fn test_insufficient_input_value() {
545        let mut session = CoinJoinSession::new(SessionConfig::default());
546        let address = Address::from_str("bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4")
547            .unwrap()
548            .assume_checked();
549
550        let input = ParticipantInput {
551            outpoint: OutPoint {
552                txid: Txid::all_zeros(),
553                vout: 0,
554            },
555            value: 1000, // Too small
556            script_pubkey: vec![],
557        };
558
559        let result = session.add_participant(input, address, None);
560        assert!(result.is_err());
561    }
562
563    #[test]
564    fn test_coinjoin_client() {
565        let mut client = CoinJoinClient::new();
566        let mut coordinator = CoinJoinCoordinator::new(SessionConfig::default());
567        let session_id = coordinator.create_session(None);
568
569        let address = Address::from_str("bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4")
570            .unwrap()
571            .assume_checked();
572
573        let input = ParticipantInput {
574            outpoint: OutPoint {
575                txid: Txid::all_zeros(),
576                vout: 0,
577            },
578            value: 200_000,
579            script_pubkey: vec![],
580        };
581
582        let result = client.register(&mut coordinator, session_id, input, address, None);
583        assert!(result.is_ok());
584    }
585}