Skip to main content

useragent_coinpayment/
lib.rs

1mod error;
2mod types;
3
4use std::collections::{HashMap, HashSet};
5use std::sync::{Arc, Mutex};
6
7use aes_gcm::aead::{Aead, KeyInit};
8use aes_gcm::{Aes256Gcm, Nonce};
9use rand_core::{OsRng, RngCore};
10use serde::{Deserialize, Serialize};
11use sha2::{Digest, Sha256};
12use x25519_dalek::{PublicKey, StaticSecret};
13use zeroize::Zeroizing;
14
15pub use error::{CoinPaymentErrCode, CoinPaymentError, Result};
16pub use types::*;
17
18const CHEQUE_VERSION: u8 = 0;
19const ENCRYPTED_CHEQUE_VERSION: u8 = 0;
20const NONCE_LEN: usize = 12;
21const PUBLIC_KEY_LEN: usize = 32;
22
23#[derive(Clone)]
24pub struct CoinPaymentRuntime {
25    state: Arc<Mutex<State>>,
26}
27
28struct State {
29    now_ms: Timestamp,
30    next_purse_id: PurseId,
31    purses: HashMap<PurseId, PurseRecord>,
32    receivables: HashMap<Receivable, ReceivableRecord>,
33    pending_cheques: HashMap<Receivable, Cheque>,
34    deposited_by_receivable: HashMap<Receivable, Balance>,
35    deposited_cheques: HashSet<[u8; 32]>,
36    return_hints: HashMap<Receivable, Receivable>,
37}
38
39#[derive(Debug, Serialize, Deserialize)]
40struct StateSnapshot {
41    version: u8,
42    now_ms: Timestamp,
43    next_purse_id: PurseId,
44    purses: Vec<SnapshotPurse>,
45    receivables: Vec<SnapshotReceivable>,
46    pending_cheques: Vec<Cheque>,
47    deposited_by_receivable: Vec<SnapshotDepositedBalance>,
48    #[serde(default)]
49    deposited_cheques: Vec<[u8; 32]>,
50    #[serde(default)]
51    return_hints: Vec<SnapshotReturnHint>,
52}
53
54#[derive(Debug, Serialize, Deserialize)]
55struct SnapshotPurse {
56    id: PurseId,
57    info: PurseInfo,
58}
59
60#[derive(Debug, Serialize, Deserialize)]
61struct SnapshotReceivable {
62    receivable: Receivable,
63    into: PurseId,
64    secret: [u8; 32],
65    channel: Option<TransmissionChannel>,
66}
67
68#[derive(Debug, Serialize, Deserialize)]
69struct SnapshotDepositedBalance {
70    receivable: Receivable,
71    balance: Balance,
72}
73
74#[derive(Debug, Serialize, Deserialize)]
75struct SnapshotReturnHint {
76    receivable: Receivable,
77    return_hint: Receivable,
78}
79
80#[derive(Debug, Clone)]
81struct PurseRecord {
82    info: PurseInfo,
83}
84
85#[derive(Clone)]
86struct ReceivableRecord {
87    into: PurseId,
88    secret: StaticSecret,
89    channel: Option<TransmissionChannel>,
90}
91
92#[derive(Debug, Serialize, Deserialize)]
93struct ChequePayload {
94    version: u8,
95    receivable: Receivable,
96    amount: Balance,
97    return_hint: Option<Receivable>,
98}
99
100impl CoinPaymentRuntime {
101    pub fn capability(&self) -> CoinPaymentCapability {
102        CoinPaymentCapability {
103            version: 0,
104            profile: CoinPaymentProfile::Staged,
105            supports_statement_store_channel: true,
106            supports_refund: true,
107            supports_finality: false,
108        }
109    }
110
111    pub fn new(now_ms: Timestamp, main_balance: Balance) -> Self {
112        let mut purses = HashMap::new();
113        purses.insert(
114            MAIN_PURSE,
115            PurseRecord {
116                info: PurseInfo {
117                    name: "Main purse".to_string(),
118                    created: now_ms,
119                    creator: "user-agent".to_string(),
120                    balance: main_balance,
121                },
122            },
123        );
124        Self {
125            state: Arc::new(Mutex::new(State {
126                now_ms,
127                next_purse_id: 1,
128                purses,
129                receivables: HashMap::new(),
130                pending_cheques: HashMap::new(),
131                deposited_by_receivable: HashMap::new(),
132                deposited_cheques: HashSet::new(),
133                return_hints: HashMap::new(),
134            })),
135        }
136    }
137
138    pub fn from_state_json(state_json: impl AsRef<str>) -> Result<Self> {
139        let snapshot: StateSnapshot =
140            serde_json::from_str(state_json.as_ref()).map_err(|error| {
141                CoinPaymentError::internal(format!("could not decode coinpayment state: {error}"))
142            })?;
143        State::from_snapshot(snapshot).map(|state| Self {
144            state: Arc::new(Mutex::new(state)),
145        })
146    }
147
148    pub fn export_state_json(&self) -> Result<String> {
149        let state = self.lock_state();
150        serde_json::to_string(&state.to_snapshot()).map_err(|error| {
151            CoinPaymentError::internal(format!("could not encode coinpayment state: {error}"))
152        })
153    }
154
155    pub fn create_purse(
156        &self,
157        product_id: impl Into<ProductId>,
158        name: impl Into<String>,
159    ) -> PurseId {
160        let mut state = self.lock_state();
161        let purse = state.allocate_purse_id();
162        let record = PurseRecord {
163            info: PurseInfo {
164                name: name.into(),
165                created: state.now_ms,
166                creator: product_id.into(),
167                balance: 0,
168            },
169        };
170        state.purses.insert(purse, record);
171        purse
172    }
173
174    pub fn query_purse(&self, purse: PurseId) -> Result<PurseInfo> {
175        let state = self.lock_state();
176        Ok(state.purse(purse)?.info.clone())
177    }
178
179    pub fn rebalance_purse(
180        &self,
181        from: PurseId,
182        to: PurseId,
183        amount: Balance,
184    ) -> Result<Vec<Status>> {
185        let mut state = self.lock_state();
186        state.transfer_balance(from, to, amount)?;
187        Ok(done_statuses(amount))
188    }
189
190    pub fn delete_purse(&self, target: PurseId, drain_into: PurseId) -> Result<Vec<Status>> {
191        if target == MAIN_PURSE {
192            return Err(CoinPaymentError::new(
193                CoinPaymentErrCode::Denied,
194                "MAIN_PURSE cannot be deleted",
195            ));
196        }
197        let mut state = self.lock_state();
198        let amount = state.purse(target)?.info.balance;
199        state.transfer_balance(target, drain_into, amount)?;
200        state.purses.remove(&target);
201        Ok(done_statuses(amount))
202    }
203
204    pub fn create_receivable(&self, into: PurseId) -> Result<Receivable> {
205        let mut state = self.lock_state();
206        state.purse(into)?;
207        let secret = StaticSecret::random_from_rng(OsRng);
208        let receivable = PublicKey::from(&secret).to_bytes();
209        state.receivables.insert(
210            receivable,
211            ReceivableRecord {
212                into,
213                secret,
214                channel: None,
215            },
216        );
217        Ok(receivable)
218    }
219
220    pub fn listen_for(&self, receivable: Receivable) -> Result<TransmissionChannel> {
221        let mut state = self.lock_state();
222        let record = state.receivable_mut(&receivable)?;
223        if let Some(channel) = &record.channel {
224            return Ok(channel.clone());
225        }
226        let channel = TransmissionChannel::Standard {
227            sss_topic: random_32(),
228        };
229        record.channel = Some(channel.clone());
230        Ok(channel)
231    }
232
233    pub fn invoice_for(&self, receiver: Receivable, amount: Balance) -> Result<Invoice> {
234        let handoff = self.listen_for(receiver)?;
235        Ok(Invoice {
236            version: CHEQUE_VERSION,
237            handoff,
238            receiver,
239            amount,
240        })
241    }
242
243    pub fn create_cheque(&self, from: PurseId, to: Receivable, amount: Balance) -> Result<Cheque> {
244        let mut state = self.lock_state();
245        if state.purse(from)?.info.balance < amount {
246            return Err(CoinPaymentError::new(
247                CoinPaymentErrCode::BalanceLow,
248                "source purse balance is too low",
249            ));
250        }
251        let return_secret = StaticSecret::random_from_rng(OsRng);
252        let return_hint = PublicKey::from(&return_secret).to_bytes();
253        state.receivables.insert(
254            return_hint,
255            ReceivableRecord {
256                into: from,
257                secret: return_secret,
258                channel: None,
259            },
260        );
261        state.purse_mut(from)?.info.balance -= amount;
262        drop(state);
263        encrypt_cheque_payload(to, amount, Some(return_hint))
264    }
265
266    pub fn pay_invoice(&self, invoice: &Invoice) -> Result<Cheque> {
267        if invoice.version != CHEQUE_VERSION {
268            return Err(CoinPaymentError::new(
269                CoinPaymentErrCode::BadCoins,
270                "unsupported invoice version",
271            ));
272        }
273        match &invoice.handoff {
274            TransmissionChannel::Standard { .. } => {
275                self.create_cheque(MAIN_PURSE, invoice.receiver, invoice.amount)
276            }
277        }
278    }
279
280    pub fn receive_cheque(&self, cheque: Cheque) -> Result<()> {
281        if cheque.version != CHEQUE_VERSION {
282            return Err(CoinPaymentError::new(
283                CoinPaymentErrCode::BadCoins,
284                "unsupported cheque version",
285            ));
286        }
287        let mut state = self.lock_state();
288        state.pending_cheques.insert(cheque.id, cheque);
289        Ok(())
290    }
291
292    pub fn pending_cheque(&self, receivable: Receivable) -> Option<Cheque> {
293        self.lock_state().pending_cheques.get(&receivable).cloned()
294    }
295
296    pub fn deposit(&self, cheque: Cheque) -> Result<Vec<Status>> {
297        if cheque.version != CHEQUE_VERSION {
298            return Err(CoinPaymentError::new(
299                CoinPaymentErrCode::BadCoins,
300                "unsupported cheque version",
301            ));
302        }
303        let mut state = self.lock_state();
304        let receivable = state.receivable(&cheque.id)?.clone();
305        let payload = decrypt_cheque_payload(&receivable.secret, &cheque)?;
306        if payload.receivable != cheque.id || payload.amount != cheque.amount {
307            return Err(CoinPaymentError::new(
308                CoinPaymentErrCode::BadCoins,
309                "cheque payload does not match public cheque fields",
310            ));
311        }
312        let cheque_hash = cheque_id(&cheque);
313        if state.deposited_cheques.contains(&cheque_hash) {
314            return Ok(vec![failed_status(
315                CoinPaymentErrCode::SnipedCoins,
316                state
317                    .deposited_by_receivable
318                    .get(&cheque.id)
319                    .copied()
320                    .unwrap_or(0),
321            )]);
322        }
323        let purse = state.purse_mut(receivable.into)?;
324        purse.info.balance = purse
325            .info
326            .balance
327            .checked_add(payload.amount)
328            .ok_or_else(|| {
329                CoinPaymentError::new(CoinPaymentErrCode::Internal, "purse balance overflow")
330            })?;
331        let deposited = state.deposited_by_receivable.entry(cheque.id).or_insert(0);
332        *deposited = deposited.checked_add(payload.amount).ok_or_else(|| {
333            CoinPaymentError::new(
334                CoinPaymentErrCode::Internal,
335                "deposited receivable balance overflow",
336            )
337        })?;
338        if let Some(return_hint) = payload.return_hint {
339            state.return_hints.insert(cheque.id, return_hint);
340        }
341        state.deposited_cheques.insert(cheque_hash);
342        state.pending_cheques.remove(&cheque.id);
343        Ok(done_statuses(payload.amount))
344    }
345
346    pub fn refund(&self, receivable: Receivable) -> Result<Vec<Status>> {
347        let mut state = self.lock_state();
348        let record = state.receivable(&receivable)?.clone();
349        let amount = *state.deposited_by_receivable.get(&receivable).unwrap_or(&0);
350        if amount == 0 {
351            return Ok(vec![failed_status(
352                CoinPaymentErrCode::ReceivableNotFound,
353                0,
354            )]);
355        }
356        let Some(return_hint) = state.return_hints.get(&receivable).copied() else {
357            return Ok(vec![failed_status(
358                CoinPaymentErrCode::UnsupportedChannel,
359                0,
360            )]);
361        };
362        if let Ok(return_record) = state.receivable(&return_hint).cloned() {
363            state.transfer_balance(record.into, return_record.into, amount)?;
364        } else {
365            let source = state.purse_mut(record.into)?;
366            if source.info.balance < amount {
367                return Err(CoinPaymentError::new(
368                    CoinPaymentErrCode::BalanceLow,
369                    "source purse balance is too low",
370                ));
371            }
372            source.info.balance -= amount;
373        }
374        state.deposited_by_receivable.insert(receivable, 0);
375        Ok(done_statuses(amount))
376    }
377
378    fn lock_state(&self) -> std::sync::MutexGuard<'_, State> {
379        self.state.lock().unwrap_or_else(|e| e.into_inner())
380    }
381}
382
383impl State {
384    fn from_snapshot(snapshot: StateSnapshot) -> Result<Self> {
385        if snapshot.version != 0 {
386            return Err(CoinPaymentError::internal(
387                "unsupported coinpayment state version",
388            ));
389        }
390
391        let mut purses = HashMap::new();
392        for purse in snapshot.purses {
393            purses.insert(purse.id, PurseRecord { info: purse.info });
394        }
395        if !purses.contains_key(&MAIN_PURSE) {
396            return Err(CoinPaymentError::internal(
397                "coinpayment state is missing MAIN_PURSE",
398            ));
399        }
400
401        let mut receivables = HashMap::new();
402        for receivable in snapshot.receivables {
403            let secret = StaticSecret::from(receivable.secret);
404            let public = PublicKey::from(&secret).to_bytes();
405            if public != receivable.receivable {
406                return Err(CoinPaymentError::internal(
407                    "coinpayment state receivable secret does not match public key",
408                ));
409            }
410            if !purses.contains_key(&receivable.into) {
411                return Err(CoinPaymentError::internal(
412                    "coinpayment state receivable targets a missing purse",
413                ));
414            }
415            receivables.insert(
416                receivable.receivable,
417                ReceivableRecord {
418                    into: receivable.into,
419                    secret,
420                    channel: receivable.channel,
421                },
422            );
423        }
424
425        let pending_cheques = snapshot
426            .pending_cheques
427            .into_iter()
428            .map(|cheque| (cheque.id, cheque))
429            .collect();
430        let deposited_by_receivable = snapshot
431            .deposited_by_receivable
432            .into_iter()
433            .map(|entry| (entry.receivable, entry.balance))
434            .collect();
435
436        Ok(Self {
437            now_ms: snapshot.now_ms,
438            next_purse_id: snapshot.next_purse_id.max(1),
439            purses,
440            receivables,
441            pending_cheques,
442            deposited_by_receivable,
443            deposited_cheques: snapshot.deposited_cheques.into_iter().collect(),
444            return_hints: snapshot
445                .return_hints
446                .into_iter()
447                .map(|entry| (entry.receivable, entry.return_hint))
448                .collect(),
449        })
450    }
451
452    fn to_snapshot(&self) -> StateSnapshot {
453        StateSnapshot {
454            version: 0,
455            now_ms: self.now_ms,
456            next_purse_id: self.next_purse_id,
457            purses: self
458                .purses
459                .iter()
460                .map(|(id, record)| SnapshotPurse {
461                    id: *id,
462                    info: record.info.clone(),
463                })
464                .collect(),
465            receivables: self
466                .receivables
467                .iter()
468                .map(|(receivable, record)| SnapshotReceivable {
469                    receivable: *receivable,
470                    into: record.into,
471                    secret: record.secret.to_bytes(),
472                    channel: record.channel.clone(),
473                })
474                .collect(),
475            pending_cheques: self.pending_cheques.values().cloned().collect(),
476            deposited_by_receivable: self
477                .deposited_by_receivable
478                .iter()
479                .map(|(receivable, balance)| SnapshotDepositedBalance {
480                    receivable: *receivable,
481                    balance: *balance,
482                })
483                .collect(),
484            deposited_cheques: self.deposited_cheques.iter().copied().collect(),
485            return_hints: self
486                .return_hints
487                .iter()
488                .map(|(receivable, return_hint)| SnapshotReturnHint {
489                    receivable: *receivable,
490                    return_hint: *return_hint,
491                })
492                .collect(),
493        }
494    }
495
496    fn allocate_purse_id(&mut self) -> PurseId {
497        loop {
498            let id = random_purse_id();
499            if id != MAIN_PURSE && !self.purses.contains_key(&id) {
500                return id;
501            }
502        }
503    }
504
505    fn purse(&self, purse: PurseId) -> Result<&PurseRecord> {
506        self.purses.get(&purse).ok_or_else(|| {
507            CoinPaymentError::new(CoinPaymentErrCode::PurseNotFound, "purse was not found")
508        })
509    }
510
511    fn purse_mut(&mut self, purse: PurseId) -> Result<&mut PurseRecord> {
512        self.purses.get_mut(&purse).ok_or_else(|| {
513            CoinPaymentError::new(CoinPaymentErrCode::PurseNotFound, "purse was not found")
514        })
515    }
516
517    fn receivable(&self, receivable: &Receivable) -> Result<&ReceivableRecord> {
518        self.receivables.get(receivable).ok_or_else(|| {
519            CoinPaymentError::new(
520                CoinPaymentErrCode::ReceivableNotFound,
521                "receivable was not found",
522            )
523        })
524    }
525
526    fn receivable_mut(&mut self, receivable: &Receivable) -> Result<&mut ReceivableRecord> {
527        self.receivables.get_mut(receivable).ok_or_else(|| {
528            CoinPaymentError::new(
529                CoinPaymentErrCode::ReceivableNotFound,
530                "receivable was not found",
531            )
532        })
533    }
534
535    fn transfer_balance(&mut self, from: PurseId, to: PurseId, amount: Balance) -> Result<()> {
536        if from == to || amount == 0 {
537            self.purse(from)?;
538            self.purse(to)?;
539            return Ok(());
540        }
541        let source_balance = self.purse(from)?.info.balance;
542        if source_balance < amount {
543            return Err(CoinPaymentError::new(
544                CoinPaymentErrCode::BalanceLow,
545                "source purse balance is too low",
546            ));
547        }
548        self.purse_mut(from)?.info.balance -= amount;
549        let target = self.purse_mut(to)?;
550        target.info.balance = target.info.balance.checked_add(amount).ok_or_else(|| {
551            CoinPaymentError::new(CoinPaymentErrCode::Internal, "purse balance overflow")
552        })?;
553        Ok(())
554    }
555}
556
557fn encrypt_cheque_payload(
558    receivable: Receivable,
559    amount: Balance,
560    return_hint: Option<Receivable>,
561) -> Result<Cheque> {
562    let secret = StaticSecret::random_from_rng(OsRng);
563    let ephemeral_public = PublicKey::from(&secret);
564    let receiver_public = PublicKey::from(receivable);
565    let shared = secret.diffie_hellman(&receiver_public);
566    let key = cheque_key(shared.as_bytes(), &receivable, ephemeral_public.as_bytes());
567    let cipher = Aes256Gcm::new_from_slice(&key)
568        .map_err(|_| CoinPaymentError::internal("could not initialize cheque encryption cipher"))?;
569    let nonce_bytes = random_nonce();
570    let nonce = Nonce::from_slice(&nonce_bytes);
571    let payload = ChequePayload {
572        version: CHEQUE_VERSION,
573        receivable,
574        amount,
575        return_hint,
576    };
577    let payload_bytes = Zeroizing::new(serde_json::to_vec(&payload).map_err(|error| {
578        CoinPaymentError::internal(format!("could not encode cheque payload: {error}"))
579    })?);
580    let ciphertext = cipher
581        .encrypt(nonce, payload_bytes.as_ref())
582        .map_err(|_| CoinPaymentError::internal("could not encrypt cheque payload"))?;
583    let mut encrypted_secrets =
584        Vec::with_capacity(1 + PUBLIC_KEY_LEN + NONCE_LEN + ciphertext.len());
585    encrypted_secrets.push(ENCRYPTED_CHEQUE_VERSION);
586    encrypted_secrets.extend_from_slice(ephemeral_public.as_bytes());
587    encrypted_secrets.extend_from_slice(&nonce_bytes);
588    encrypted_secrets.extend_from_slice(&ciphertext);
589    Ok(Cheque {
590        version: CHEQUE_VERSION,
591        id: receivable,
592        amount,
593        encrypted_secrets,
594    })
595}
596
597fn decrypt_cheque_payload(secret: &StaticSecret, cheque: &Cheque) -> Result<ChequePayload> {
598    let encrypted = &cheque.encrypted_secrets;
599    if encrypted.len() <= 1 + PUBLIC_KEY_LEN + NONCE_LEN || encrypted[0] != ENCRYPTED_CHEQUE_VERSION
600    {
601        return Err(CoinPaymentError::new(
602            CoinPaymentErrCode::BadCoins,
603            "invalid cheque encrypted payload",
604        ));
605    }
606    let mut public = [0u8; PUBLIC_KEY_LEN];
607    public.copy_from_slice(&encrypted[1..1 + PUBLIC_KEY_LEN]);
608    let nonce_start = 1 + PUBLIC_KEY_LEN;
609    let nonce_end = nonce_start + NONCE_LEN;
610    let nonce = Nonce::from_slice(&encrypted[nonce_start..nonce_end]);
611    let ciphertext = &encrypted[nonce_end..];
612    let ephemeral_public = PublicKey::from(public);
613    let shared = secret.diffie_hellman(&ephemeral_public);
614    let key = cheque_key(shared.as_bytes(), &cheque.id, &public);
615    let cipher = Aes256Gcm::new_from_slice(&key)
616        .map_err(|_| CoinPaymentError::internal("could not initialize cheque decryption cipher"))?;
617    let payload = cipher.decrypt(nonce, ciphertext).map_err(|_| {
618        CoinPaymentError::new(CoinPaymentErrCode::BadCoins, "could not decrypt cheque")
619    })?;
620    serde_json::from_slice(&payload).map_err(|error| {
621        CoinPaymentError::new(
622            CoinPaymentErrCode::BadCoins,
623            format!("could not decode cheque payload: {error}"),
624        )
625    })
626}
627
628fn cheque_key(shared: &[u8; 32], receivable: &Receivable, ephemeral_public: &[u8; 32]) -> [u8; 32] {
629    let mut hasher = Sha256::new();
630    hasher.update(b"useragent-kit:coinpayment:rfc17:cheque:v0");
631    hasher.update(shared);
632    hasher.update(receivable);
633    hasher.update(ephemeral_public);
634    hasher.finalize().into()
635}
636
637fn cheque_id(cheque: &Cheque) -> [u8; 32] {
638    let mut hasher = Sha256::new();
639    hasher.update(b"useragent-kit:coinpayment:rfc17:cheque-id:v0");
640    hasher.update([cheque.version]);
641    hasher.update(cheque.id);
642    hasher.update(cheque.amount.to_le_bytes());
643    hasher.update(&cheque.encrypted_secrets);
644    hasher.finalize().into()
645}
646
647fn done_statuses(amount: Balance) -> Vec<Status> {
648    vec![
649        Status::Clearing {
650            clearing: amount,
651            cleared: 0,
652        },
653        Status::Done {
654            cleared: amount,
655            reference: clearing_reference(amount),
656        },
657    ]
658}
659
660fn failed_status(error: CoinPaymentErrCode, cleared: Balance) -> Status {
661    Status::Failed {
662        error: error.as_str().to_string(),
663        cleared,
664        reference: clearing_reference(cleared),
665    }
666}
667
668fn clearing_reference(amount: Balance) -> ClearingReference {
669    let mut root_hasher = Sha256::new();
670    root_hasher.update(b"useragent-kit:coinpayment:rfc17:clearing");
671    root_hasher.update(amount.to_le_bytes());
672    let root: [u8; 32] = root_hasher.finalize().into();
673    let leaf_hash = Sha256::digest(root);
674    let transaction_hash = Sha256::digest(leaf_hash);
675    ClearingReference {
676        root,
677        leaves: vec![(leaf_hash.into(), transaction_hash.into())],
678    }
679}
680
681fn random_32() -> [u8; 32] {
682    let mut out = [0u8; 32];
683    OsRng.fill_bytes(&mut out);
684    out
685}
686
687fn random_purse_id() -> PurseId {
688    let mut bytes = [0u8; 4];
689    OsRng.fill_bytes(&mut bytes);
690    u32::from_le_bytes(bytes)
691}
692
693fn random_nonce() -> [u8; NONCE_LEN] {
694    let mut out = [0u8; NONCE_LEN];
695    OsRng.fill_bytes(&mut out);
696    out
697}
698
699#[cfg(test)]
700mod tests {
701    use super::*;
702
703    #[test]
704    fn purse_receivable_cheque_deposit_flow() {
705        let runtime = CoinPaymentRuntime::new(1_700_000_000_000, 500);
706        let capability = runtime.capability();
707        assert_eq!(capability.profile, CoinPaymentProfile::Staged);
708        assert!(capability.supports_statement_store_channel);
709        assert!(capability.supports_refund);
710        assert!(!capability.supports_finality);
711
712        let merchant = runtime.create_purse("terminal", "Terminal");
713        let receivable = runtime.create_receivable(merchant).unwrap();
714        let handoff = runtime.listen_for(receivable).unwrap();
715        assert!(matches!(handoff, TransmissionChannel::Standard { .. }));
716
717        let cheque = runtime.create_cheque(MAIN_PURSE, receivable, 125).unwrap();
718        assert_eq!(runtime.query_purse(MAIN_PURSE).unwrap().balance, 375);
719
720        let statuses = runtime.deposit(cheque).unwrap();
721        assert!(matches!(
722            statuses.last(),
723            Some(Status::Done { cleared: 125, .. })
724        ));
725        assert_eq!(runtime.query_purse(merchant).unwrap().balance, 125);
726    }
727
728    #[test]
729    fn state_snapshot_restores_purses_and_receivable_secret() {
730        let runtime = CoinPaymentRuntime::new(1, 100);
731        let merchant = runtime.create_purse("terminal", "Terminal");
732        let receivable = runtime.create_receivable(merchant).unwrap();
733        let snapshot = runtime.export_state_json().unwrap();
734        let restored = CoinPaymentRuntime::from_state_json(snapshot).unwrap();
735
736        let payer = CoinPaymentRuntime::new(1, 50);
737        let cheque = payer.create_cheque(MAIN_PURSE, receivable, 25).unwrap();
738        restored.deposit(cheque).unwrap();
739
740        assert_eq!(restored.query_purse(merchant).unwrap().balance, 25);
741    }
742
743    #[test]
744    fn state_snapshot_preserves_deposited_cheque_replay_guard() {
745        let runtime = CoinPaymentRuntime::new(1, 100);
746        let merchant = runtime.create_purse("terminal", "Terminal");
747        let receivable = runtime.create_receivable(merchant).unwrap();
748        let cheque = runtime.create_cheque(MAIN_PURSE, receivable, 25).unwrap();
749        runtime.deposit(cheque.clone()).unwrap();
750        let snapshot = runtime.export_state_json().unwrap();
751        let restored = CoinPaymentRuntime::from_state_json(snapshot).unwrap();
752
753        let statuses = restored.deposit(cheque).unwrap();
754        assert!(matches!(
755            statuses.last(),
756            Some(Status::Failed { error, .. }) if error == CoinPaymentErrCode::SnipedCoins.as_str()
757        ));
758        assert_eq!(restored.query_purse(merchant).unwrap().balance, 25);
759    }
760
761    #[test]
762    fn cannot_deposit_cheque_without_receivable_secret() {
763        let sender = CoinPaymentRuntime::new(1, 100);
764        let receiver = CoinPaymentRuntime::new(1, 0);
765        let unknown_receivable = [7u8; 32];
766        let cheque = sender
767            .create_cheque(MAIN_PURSE, unknown_receivable, 10)
768            .unwrap();
769
770        let err = receiver.deposit(cheque).unwrap_err();
771        assert_eq!(err.code, CoinPaymentErrCode::ReceivableNotFound);
772    }
773
774    #[test]
775    fn invoice_payment_uses_main_purse() {
776        let merchant_host = CoinPaymentRuntime::new(1, 0);
777        let payer_host = CoinPaymentRuntime::new(1, 50);
778        let purse = merchant_host.create_purse("terminal", "Terminal");
779        let receivable = merchant_host.create_receivable(purse).unwrap();
780        let invoice = merchant_host.invoice_for(receivable, 20).unwrap();
781
782        let cheque = payer_host.pay_invoice(&invoice).unwrap();
783        merchant_host.receive_cheque(cheque.clone()).unwrap();
784        assert_eq!(
785            merchant_host.pending_cheque(receivable),
786            Some(cheque.clone())
787        );
788        merchant_host.deposit(cheque).unwrap();
789
790        assert_eq!(payer_host.query_purse(MAIN_PURSE).unwrap().balance, 30);
791        assert_eq!(merchant_host.query_purse(purse).unwrap().balance, 20);
792    }
793
794    #[test]
795    fn pay_invoice_rejects_unsupported_version_before_debit() {
796        let payer_host = CoinPaymentRuntime::new(1, 50);
797        let invoice = Invoice {
798            version: 1,
799            handoff: TransmissionChannel::Standard {
800                sss_topic: [3u8; 32],
801            },
802            receiver: [4u8; 32],
803            amount: 20,
804        };
805
806        let err = payer_host.pay_invoice(&invoice).unwrap_err();
807        assert_eq!(err.code, CoinPaymentErrCode::BadCoins);
808        assert_eq!(payer_host.query_purse(MAIN_PURSE).unwrap().balance, 50);
809    }
810
811    #[test]
812    fn duplicate_deposit_fails_without_second_credit() {
813        let runtime = CoinPaymentRuntime::new(1, 100);
814        let merchant = runtime.create_purse("terminal", "Terminal");
815        let receivable = runtime.create_receivable(merchant).unwrap();
816        let cheque = runtime.create_cheque(MAIN_PURSE, receivable, 40).unwrap();
817
818        runtime.deposit(cheque.clone()).unwrap();
819        let statuses = runtime.deposit(cheque).unwrap();
820
821        assert!(matches!(
822            statuses.last(),
823            Some(Status::Failed { error, .. }) if error == CoinPaymentErrCode::SnipedCoins.as_str()
824        ));
825        assert_eq!(runtime.query_purse(merchant).unwrap().balance, 40);
826    }
827
828    #[test]
829    fn deposit_rejects_balance_overflow() {
830        let receiver = CoinPaymentRuntime::new(1, Balance::MAX);
831        let receivable = receiver.create_receivable(MAIN_PURSE).unwrap();
832        let payer = CoinPaymentRuntime::new(1, 50);
833        let cheque = payer.create_cheque(MAIN_PURSE, receivable, 1).unwrap();
834
835        let err = receiver.deposit(cheque).unwrap_err();
836        assert_eq!(err.code, CoinPaymentErrCode::Internal);
837        assert_eq!(
838            receiver.query_purse(MAIN_PURSE).unwrap().balance,
839            Balance::MAX
840        );
841    }
842
843    #[test]
844    fn refund_moves_deposited_amount_back_to_main_purse() {
845        let runtime = CoinPaymentRuntime::new(1, 100);
846        let merchant = runtime.create_purse("terminal", "Terminal");
847        let receivable = runtime.create_receivable(merchant).unwrap();
848        let cheque = runtime.create_cheque(MAIN_PURSE, receivable, 40).unwrap();
849        runtime.deposit(cheque).unwrap();
850        runtime.refund(receivable).unwrap();
851
852        assert_eq!(runtime.query_purse(merchant).unwrap().balance, 0);
853        assert_eq!(runtime.query_purse(MAIN_PURSE).unwrap().balance, 100);
854    }
855
856    #[test]
857    fn refund_to_remote_return_hint_debits_receiver_purse() {
858        let merchant_host = CoinPaymentRuntime::new(1, 0);
859        let payer_host = CoinPaymentRuntime::new(1, 100);
860        let merchant = merchant_host.create_purse("terminal", "Terminal");
861        let receivable = merchant_host.create_receivable(merchant).unwrap();
862        let cheque = payer_host
863            .create_cheque(MAIN_PURSE, receivable, 40)
864            .unwrap();
865        merchant_host.deposit(cheque).unwrap();
866
867        let statuses = merchant_host.refund(receivable).unwrap();
868
869        assert!(matches!(
870            statuses.last(),
871            Some(Status::Done { cleared: 40, .. })
872        ));
873        assert_eq!(merchant_host.query_purse(merchant).unwrap().balance, 0);
874        assert_eq!(payer_host.query_purse(MAIN_PURSE).unwrap().balance, 60);
875    }
876}