Skip to main content

cdk_ffi/
lib.rs

1//! CDK FFI Bindings
2//!
3//! UniFFI bindings for the CDK Wallet and related types.
4
5#![warn(clippy::unused_async)]
6#![allow(missing_docs)]
7#![allow(missing_debug_implementations)]
8
9pub mod bip321;
10pub mod database;
11pub mod error;
12pub mod logging;
13#[cfg(feature = "npubcash")]
14pub mod npubcash;
15#[cfg(feature = "nwc")]
16pub mod nwc;
17#[cfg(feature = "postgres")]
18pub mod postgres;
19mod runtime;
20pub mod sqlite;
21#[cfg(feature = "supabase")]
22pub mod supabase;
23pub mod token;
24pub mod types;
25pub mod wallet;
26pub mod wallet_repository;
27mod wallet_trait;
28
29pub use database::*;
30pub use error::*;
31pub use logging::*;
32#[cfg(feature = "npubcash")]
33pub use npubcash::*;
34#[cfg(feature = "nwc")]
35pub use nwc::*;
36pub use types::*;
37pub use wallet::*;
38pub use wallet_repository::*;
39
40uniffi::setup_scaffolding!();
41
42#[cfg(test)]
43mod tests {
44    use std::convert::TryInto;
45
46    use super::*;
47
48    #[test]
49    fn test_amount_conversion() {
50        let amount = Amount::new(1000);
51        assert_eq!(amount.value, 1000);
52        assert!(!amount.is_zero());
53
54        let zero = Amount::zero();
55        assert!(zero.is_zero());
56    }
57
58    #[test]
59    fn test_currency_unit_conversion() {
60        use cdk::nuts::CurrencyUnit as CdkCurrencyUnit;
61
62        let unit = CurrencyUnit::Sat;
63        let cdk_unit: CdkCurrencyUnit = unit.into();
64        let back: CurrencyUnit = cdk_unit.into();
65        assert_eq!(back, CurrencyUnit::Sat);
66    }
67
68    #[test]
69    fn test_mint_url_creation() {
70        let url = MintUrl::new("https://mint.example.com".to_string());
71        assert!(url.is_ok());
72
73        let invalid_url = MintUrl::new("not-a-url".to_string());
74        assert!(invalid_url.is_err());
75    }
76
77    #[test]
78    fn test_send_options_default() {
79        let options = SendOptions::default();
80        assert!(options.memo.is_none());
81        assert!(options.conditions.is_none());
82        assert!(matches!(options.amount_split_target, SplitTarget::None));
83        assert!(matches!(options.send_kind, SendKind::OnlineExact));
84        assert!(!options.include_fee);
85        assert!(options.max_proofs.is_none());
86        assert!(options.metadata.is_empty());
87        assert!(options.p2pk_signing_keys.is_empty());
88        assert_eq!(
89            options.p2pk_locked_proof_send_mode,
90            P2PKLockedProofSendMode::Swap
91        );
92    }
93
94    #[test]
95    fn test_receive_options_default() {
96        let options = ReceiveOptions::default();
97        assert!(matches!(options.amount_split_target, SplitTarget::None));
98        assert!(options.p2pk_signing_keys.is_empty());
99        assert!(options.preimages.is_empty());
100        assert!(options.metadata.is_empty());
101    }
102
103    #[test]
104    fn test_send_memo() {
105        let memo_text = "Test memo".to_string();
106        let memo = SendMemo {
107            memo: memo_text.clone(),
108            include_memo: true,
109        };
110
111        assert_eq!(memo.memo, memo_text);
112        assert!(memo.include_memo);
113    }
114
115    #[test]
116    fn test_split_target_variants() {
117        let split_none = SplitTarget::None;
118        assert!(matches!(split_none, SplitTarget::None));
119
120        let amount = Amount::new(1000);
121        let split_value = SplitTarget::Value { amount };
122        assert!(matches!(split_value, SplitTarget::Value { .. }));
123
124        let amounts = vec![Amount::new(100), Amount::new(200)];
125        let split_values = SplitTarget::Values { amounts };
126        assert!(matches!(split_values, SplitTarget::Values { .. }));
127    }
128
129    #[test]
130    fn test_send_kind_variants() {
131        let online_exact = SendKind::OnlineExact;
132        assert!(matches!(online_exact, SendKind::OnlineExact));
133
134        let tolerance = Amount::new(50);
135        let online_tolerance = SendKind::OnlineTolerance { tolerance };
136        assert!(matches!(online_tolerance, SendKind::OnlineTolerance { .. }));
137
138        let offline_exact = SendKind::OfflineExact;
139        assert!(matches!(offline_exact, SendKind::OfflineExact));
140
141        let offline_tolerance = SendKind::OfflineTolerance { tolerance };
142        assert!(matches!(
143            offline_tolerance,
144            SendKind::OfflineTolerance { .. }
145        ));
146    }
147
148    #[test]
149    fn test_secret_key_from_hex() {
150        // Test valid hex string (64 characters)
151        let valid_hex = "a".repeat(64);
152        let secret_key = SecretKey::from_hex(valid_hex.clone());
153        assert!(secret_key.is_ok());
154        assert_eq!(secret_key.unwrap().hex, valid_hex);
155
156        // Test invalid length
157        let invalid_length = "a".repeat(32); // 32 chars instead of 64
158        let secret_key = SecretKey::from_hex(invalid_length);
159        assert!(secret_key.is_err());
160
161        // Test invalid characters
162        let invalid_chars = "g".repeat(64); // 'g' is not a valid hex character
163        let secret_key = SecretKey::from_hex(invalid_chars);
164        assert!(secret_key.is_err());
165    }
166
167    #[test]
168    fn test_secret_key_random() {
169        let key1 = SecretKey::random();
170        let key2 = SecretKey::random();
171
172        // Keys should be different
173        assert_ne!(key1.hex, key2.hex);
174
175        // Keys should be valid hex (64 characters)
176        assert_eq!(key1.hex.len(), 64);
177        assert_eq!(key2.hex.len(), 64);
178        assert!(key1.hex.chars().all(|c| c.is_ascii_hexdigit()));
179        assert!(key2.hex.chars().all(|c| c.is_ascii_hexdigit()));
180    }
181
182    #[test]
183    fn test_send_options_with_all_fields() {
184        use std::collections::HashMap;
185
186        let memo = SendMemo {
187            memo: "Test memo".to_string(),
188            include_memo: true,
189        };
190
191        let mut metadata = HashMap::new();
192        metadata.insert("key1".to_string(), "value1".to_string());
193
194        let conditions = SpendingConditions::P2PK {
195            pubkey: "02a1633cafcc01ebfb6d78e39f687a1f0995c62fc95f51ead10a02ee0be551b5dc"
196                .to_string(),
197            conditions: None,
198        };
199
200        let options = SendOptions {
201            memo: Some(memo),
202            conditions: Some(conditions),
203            amount_split_target: SplitTarget::Value {
204                amount: Amount::new(1000),
205            },
206            send_kind: SendKind::OnlineTolerance {
207                tolerance: Amount::new(50),
208            },
209            include_fee: true,
210            max_proofs: Some(10),
211            metadata,
212            use_p2bk: false,
213            p2pk_signing_keys: Vec::new(),
214            p2pk_locked_proof_send_mode: P2PKLockedProofSendMode::Swap,
215        };
216
217        assert!(options.memo.is_some());
218        assert!(options.conditions.is_some());
219        assert!(matches!(
220            options.amount_split_target,
221            SplitTarget::Value { .. }
222        ));
223        assert!(matches!(
224            options.send_kind,
225            SendKind::OnlineTolerance { .. }
226        ));
227        assert!(options.include_fee);
228        assert_eq!(options.max_proofs, Some(10));
229        assert!(!options.metadata.is_empty());
230    }
231
232    #[test]
233    fn test_receive_options_with_all_fields() {
234        use std::collections::HashMap;
235
236        let secret_key = SecretKey::random();
237        let mut metadata = HashMap::new();
238        metadata.insert("key1".to_string(), "value1".to_string());
239
240        let options = ReceiveOptions {
241            amount_split_target: SplitTarget::Values {
242                amounts: vec![Amount::new(100), Amount::new(200)],
243            },
244            p2pk_signing_keys: vec![secret_key],
245            preimages: vec!["preimage1".to_string(), "preimage2".to_string()],
246            metadata,
247        };
248
249        assert!(matches!(
250            options.amount_split_target,
251            SplitTarget::Values { .. }
252        ));
253        assert_eq!(options.p2pk_signing_keys.len(), 1);
254        assert_eq!(options.preimages.len(), 2);
255        assert!(!options.metadata.is_empty());
256    }
257
258    #[test]
259    fn test_receive_options_invalid_secret_key_returns_error() {
260        let options = ReceiveOptions {
261            amount_split_target: SplitTarget::None,
262            p2pk_signing_keys: vec![SecretKey {
263                hex: "z".repeat(64),
264            }],
265            preimages: Vec::new(),
266            metadata: Default::default(),
267        };
268
269        let result: Result<cdk::wallet::ReceiveOptions, _> = options.try_into();
270
271        assert!(result.is_err());
272    }
273
274    #[test]
275    fn test_send_options_invalid_secret_key_returns_error() {
276        let options = SendOptions {
277            p2pk_signing_keys: vec![SecretKey {
278                hex: "z".repeat(64),
279            }],
280            ..Default::default()
281        };
282
283        let result: Result<cdk::wallet::SendOptions, _> = options.try_into();
284
285        assert!(result.is_err());
286    }
287
288    #[test]
289    fn test_send_options_invalid_conditions_returns_error() {
290        let options = SendOptions {
291            conditions: Some(SpendingConditions::P2PK {
292                pubkey: "not_a_valid_pubkey".to_string(),
293                conditions: None,
294            }),
295            ..Default::default()
296        };
297
298        let result: Result<cdk::wallet::SendOptions, _> = options.try_into();
299
300        assert!(result.is_err());
301    }
302
303    #[test]
304    fn test_send_options_json_preserves_p2pk_signing_keys() {
305        let secret_hex =
306            "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f".to_string();
307        let options = SendOptions {
308            p2pk_signing_keys: vec![SecretKey {
309                hex: secret_hex.clone(),
310            }],
311            ..Default::default()
312        };
313
314        let to_json = options.to_json().unwrap();
315        let encoded = crate::types::wallet::encode_send_options(options.clone()).unwrap();
316        let debug = format!("{:?}", options);
317
318        assert!(to_json.contains(&secret_hex));
319        assert!(to_json.contains("p2pk_signing_keys"));
320        assert!(encoded.contains(&secret_hex));
321        assert!(encoded.contains("p2pk_signing_keys"));
322        assert!(!debug.contains(&secret_hex));
323        assert!(debug.contains("[redacted]"));
324
325        let decoded = crate::types::wallet::decode_send_options(encoded).unwrap();
326
327        assert_eq!(decoded.p2pk_signing_keys.len(), 1);
328        assert_eq!(decoded.p2pk_signing_keys[0].hex, secret_hex);
329    }
330
331    #[test]
332    fn test_send_options_json_still_decodes_p2pk_signing_keys() {
333        let secret_hex = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f";
334        let json = format!(
335            r#"{{
336                "memo": null,
337                "conditions": null,
338                "amount_split_target": "None",
339                "send_kind": "OnlineExact",
340                "include_fee": false,
341                "use_p2bk": false,
342                "max_proofs": null,
343                "metadata": {{}},
344                "p2pk_signing_keys": ["{}"],
345                "p2pk_locked_proof_send_mode": "SignAndSend"
346            }}"#,
347            secret_hex
348        );
349
350        let options = crate::types::wallet::decode_send_options(json).unwrap();
351
352        assert_eq!(options.p2pk_signing_keys.len(), 1);
353        assert_eq!(options.p2pk_signing_keys[0].hex, secret_hex);
354        assert_eq!(
355            options.p2pk_locked_proof_send_mode,
356            P2PKLockedProofSendMode::SignAndSend
357        );
358    }
359
360    #[test]
361    fn test_receive_options_json_preserves_p2pk_signing_keys() {
362        let secret_hex =
363            "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f".to_string();
364        let options = ReceiveOptions {
365            p2pk_signing_keys: vec![SecretKey {
366                hex: secret_hex.clone(),
367            }],
368            ..Default::default()
369        };
370
371        let to_json = options.to_json().unwrap();
372        let encoded = crate::types::wallet::encode_receive_options(options.clone()).unwrap();
373        let debug = format!("{:?}", options);
374
375        assert!(to_json.contains(&secret_hex));
376        assert!(to_json.contains("p2pk_signing_keys"));
377        assert!(encoded.contains(&secret_hex));
378        assert!(encoded.contains("p2pk_signing_keys"));
379        assert!(!debug.contains(&secret_hex));
380        assert!(debug.contains("[redacted]"));
381
382        let decoded = crate::types::wallet::decode_receive_options(encoded).unwrap();
383
384        assert_eq!(decoded.p2pk_signing_keys.len(), 1);
385        assert_eq!(decoded.p2pk_signing_keys[0].hex, secret_hex);
386    }
387
388    #[test]
389    fn test_send_options_json_defaults_new_p2pk_fields() {
390        let json = r#"{
391            "memo": null,
392            "conditions": null,
393            "amount_split_target": "None",
394            "send_kind": "OnlineExact",
395            "include_fee": false,
396            "use_p2bk": false,
397            "max_proofs": null,
398            "metadata": {}
399        }"#;
400
401        let options = crate::types::wallet::decode_send_options(json.to_string()).unwrap();
402
403        assert!(options.p2pk_signing_keys.is_empty());
404        assert_eq!(
405            options.p2pk_locked_proof_send_mode,
406            P2PKLockedProofSendMode::Swap
407        );
408    }
409
410    #[test]
411    fn test_proof_with_invalid_dleq_returns_error() {
412        let proof = Proof {
413            amount: Amount::new(1),
414            secret: "test-secret".to_string(),
415            c: "02a1633cafcc01ebfb6d78e39f687a1f0995c62fc95f51ead10a02ee0be551b5dc".to_string(),
416            keyset_id: "009a1f293253e41e".to_string(),
417            witness: None,
418            dleq: Some(ProofDleq {
419                e: "z".repeat(64),
420                s: "a".repeat(64),
421                r: "b".repeat(64),
422            }),
423            p2pk_e: None,
424        };
425
426        let result: Result<cdk::nuts::Proof, _> = proof.try_into();
427
428        assert!(result.is_err());
429    }
430
431    #[test]
432    fn test_blind_signature_dleq_invalid_hex_returns_error() {
433        let dleq = BlindSignatureDleq {
434            e: "z".repeat(64),
435            s: "a".repeat(64),
436        };
437
438        let result: Result<cdk::nuts::BlindSignatureDleq, _> = dleq.try_into();
439
440        assert!(result.is_err());
441    }
442
443    #[test]
444    fn test_transaction_invalid_saga_id_returns_error() {
445        let transaction = Transaction {
446            id: TransactionId {
447                hex: "a".repeat(64),
448            },
449            mint_url: MintUrl {
450                url: "https://mint.example.com".to_string(),
451            },
452            direction: TransactionDirection::Outgoing,
453            amount: Amount::new(100),
454            fee: Amount::new(0),
455            unit: CurrencyUnit::Sat,
456            ys: vec![],
457            timestamp: 0,
458            memo: None,
459            metadata: Default::default(),
460            quote_id: None,
461            payment_request: None,
462            payment_proof: None,
463            payment_method: None,
464            saga_id: Some("not-a-valid-uuid".to_string()),
465        };
466
467        let result: Result<cdk::wallet::types::Transaction, _> = transaction.try_into();
468
469        assert!(result.is_err());
470    }
471
472    #[test]
473    fn test_mint_quote_pending_state_does_not_inflate_mintable() {
474        let ffi_quote = MintQuote {
475            id: "test-quote".to_string(),
476            amount: Some(Amount::new(100)),
477            unit: CurrencyUnit::Sat,
478            request: "lnbc1...".to_string(),
479            state: QuoteState::Pending,
480            expiry: u64::MAX,
481            mint_url: MintUrl::new("https://mint.example.com".to_string())
482                .expect("valid mint URL should convert successfully"),
483            amount_issued: Amount::zero(),
484            amount_paid: Amount::zero(),
485            estimated_blocks: None,
486            payment_method: PaymentMethod::Bolt11,
487            secret_key: None,
488            used_by_operation: None,
489            version: 0,
490        };
491
492        let mintable =
493            mint_quote_amount_mintable(&ffi_quote).expect("valid mint quote should convert");
494
495        assert_eq!(mintable.value, 0);
496    }
497
498    #[test]
499    fn test_wallet_config() {
500        let config = WalletConfig {
501            target_proof_count: None,
502        };
503        assert!(config.target_proof_count.is_none());
504
505        let config_with_values = WalletConfig {
506            target_proof_count: Some(5),
507        };
508        assert_eq!(config_with_values.target_proof_count, Some(5));
509    }
510
511    #[test]
512    fn test_mnemonic_generation() {
513        // Test mnemonic generation
514        let mnemonic = generate_mnemonic().unwrap();
515        assert!(!mnemonic.is_empty());
516        assert_eq!(mnemonic.split_whitespace().count(), 12);
517
518        // Verify it's a valid mnemonic by trying to parse it
519        use bip39::Mnemonic;
520        let parsed = Mnemonic::parse(&mnemonic);
521        assert!(parsed.is_ok());
522    }
523
524    #[test]
525    fn test_mnemonic_validation() {
526        // Test with valid mnemonic
527        let mnemonic = generate_mnemonic().unwrap();
528        use bip39::Mnemonic;
529        let parsed = Mnemonic::parse(&mnemonic);
530        assert!(parsed.is_ok());
531
532        // Test with invalid mnemonic
533        let invalid_mnemonic = "invalid mnemonic phrase that should not work";
534        let parsed_invalid = Mnemonic::parse(invalid_mnemonic);
535        assert!(parsed_invalid.is_err());
536
537        // Test mnemonic word count variations
538        let mnemonic_12 = generate_mnemonic().unwrap();
539        assert_eq!(mnemonic_12.split_whitespace().count(), 12);
540    }
541
542    #[test]
543    fn test_mnemonic_to_entropy() {
544        // Test with generated mnemonic
545        let mnemonic = generate_mnemonic().unwrap();
546        let entropy = mnemonic_to_entropy(mnemonic.clone()).unwrap();
547
548        // For a 12-word mnemonic, entropy should be 16 bytes (128 bits)
549        assert_eq!(entropy.len(), 16);
550
551        // Test that we can recreate the mnemonic from entropy
552        use bip39::Mnemonic;
553        let recreated_mnemonic = Mnemonic::from_entropy(&entropy).unwrap();
554        assert_eq!(recreated_mnemonic.to_string(), mnemonic);
555
556        // Test with invalid mnemonic
557        let invalid_result = mnemonic_to_entropy("invalid mnemonic".to_string());
558        assert!(invalid_result.is_err());
559    }
560
561    #[test]
562    fn test_keyset_info_try_from_rejects_invalid_id() {
563        let err = <cdk::nuts::KeySetInfo as TryFrom<KeySetInfo>>::try_from(KeySetInfo {
564            id: "invalid".to_string(),
565            unit: CurrencyUnit::Sat,
566            active: true,
567            input_fee_ppk: 0,
568        })
569        .expect_err("invalid keyset ID should return an error");
570
571        assert!(err.to_string().contains("Invalid keyset ID"));
572    }
573
574    #[test]
575    fn test_keyset_info_try_from_rejects_empty_id() {
576        let err = <cdk::nuts::KeySetInfo as TryFrom<KeySetInfo>>::try_from(KeySetInfo {
577            id: String::new(),
578            unit: CurrencyUnit::Sat,
579            active: true,
580            input_fee_ppk: 0,
581        })
582        .expect_err("empty keyset ID should return an error");
583
584        assert!(err.to_string().contains("Invalid keyset ID"));
585    }
586
587    #[test]
588    fn test_id_try_from_rejects_invalid_hex() {
589        let err = <cdk::nuts::Id as TryFrom<Id>>::try_from(Id {
590            hex: "invalid".to_string(),
591        })
592        .expect_err("invalid ID hex should return an error");
593
594        assert!(err.to_string().contains("Invalid ID hex"));
595    }
596
597    #[test]
598    fn test_id_try_from_accepts_valid_hex() {
599        let id: cdk::nuts::Id = Id {
600            hex: "009a1f293253e41e".to_string(),
601        }
602        .try_into()
603        .expect("valid ID hex should convert successfully");
604
605        assert_eq!(id.to_string(), "009a1f293253e41e");
606    }
607}