1use super::config::{ConfigPatch, RuntimeConfig};
2use super::domain::*;
3use super::limits::*;
4use crate::store::wallet::WalletMetadata;
5use serde::{Deserialize, Serialize};
6use std::collections::BTreeMap;
7
8use super::domain::deserialize_local_memo;
9
10pub const JSON_PROTOCOL_VERSION: u32 = 1;
11
12#[derive(Debug, Serialize, Clone)]
13pub struct Trace {
14 pub duration_ms: u64,
15}
16
17impl Trace {
18 pub fn from_duration(duration_ms: u64) -> Self {
19 Self { duration_ms }
20 }
21}
22
23#[derive(Debug, Serialize)]
24pub struct PongTrace {
25 pub uptime_s: u64,
26 pub requests_total: u64,
27 pub in_flight: usize,
28}
29
30#[derive(Debug, Serialize)]
31pub struct CloseTrace {
32 pub uptime_s: u64,
33 pub requests_total: u64,
34}
35
36#[derive(Debug, Serialize, Deserialize)]
37#[serde(tag = "code")]
38pub enum Input {
39 #[serde(rename = "wallet_create")]
40 WalletCreate {
41 id: String,
42 network: Network,
43 #[serde(default)]
44 label: Option<String>,
45 #[serde(default, skip_serializing_if = "Option::is_none")]
47 mint_url: Option<String>,
48 #[serde(default, skip_serializing_if = "Vec::is_empty")]
50 rpc_endpoints: Vec<String>,
51 #[serde(default, skip_serializing_if = "Option::is_none")]
53 chain_id: Option<u64>,
54 #[serde(default, skip_serializing_if = "Option::is_none")]
55 mnemonic_secret: Option<String>,
56 #[serde(default, skip_serializing_if = "Option::is_none")]
58 btc_esplora_url: Option<String>,
59 #[serde(default, skip_serializing_if = "Option::is_none")]
61 btc_network: Option<String>,
62 #[serde(default, skip_serializing_if = "Option::is_none")]
64 btc_address_type: Option<String>,
65 #[serde(default, skip_serializing_if = "Option::is_none")]
67 btc_backend: Option<BtcBackend>,
68 #[serde(default, skip_serializing_if = "Option::is_none")]
70 btc_core_url: Option<String>,
71 #[serde(default, skip_serializing_if = "Option::is_none")]
73 btc_core_auth_secret: Option<String>,
74 #[serde(default, skip_serializing_if = "Option::is_none")]
76 btc_electrum_url: Option<String>,
77 },
78 #[serde(rename = "ln_wallet_create")]
79 LnWalletCreate {
80 id: String,
81 #[serde(flatten)]
82 request: LnWalletCreateRequest,
83 },
84 #[serde(rename = "wallet_close")]
85 WalletClose {
86 id: String,
87 wallet: String,
88 #[serde(default)]
89 dangerously_skip_balance_check_and_may_lose_money: bool,
90 },
91 #[serde(rename = "wallet_list")]
92 WalletList {
93 id: String,
94 #[serde(default)]
95 network: Option<Network>,
96 },
97 #[serde(rename = "balance")]
98 Balance {
99 id: String,
100 #[serde(default)]
101 wallet: Option<String>,
102 #[serde(default, skip_serializing_if = "Option::is_none")]
103 network: Option<Network>,
104 #[serde(default)]
105 check: bool,
106 },
107 #[serde(rename = "receive")]
108 Receive {
109 id: String,
110 wallet: String,
111 #[serde(default, skip_serializing_if = "Option::is_none")]
112 network: Option<Network>,
113 #[serde(default)]
114 amount: Option<Amount>,
115 #[serde(default, skip_serializing_if = "Option::is_none")]
116 onchain_memo: Option<String>,
117 #[serde(default)]
118 wait_until_paid: bool,
119 #[serde(default)]
120 wait_timeout_s: Option<u64>,
121 #[serde(default)]
122 wait_poll_interval_ms: Option<u64>,
123 #[serde(default)]
124 wait_sync_limit: Option<usize>,
125 #[serde(default)]
126 write_qr_svg_file: bool,
127 #[serde(default, skip_serializing_if = "Option::is_none")]
128 min_confirmations: Option<u32>,
129 #[serde(default, skip_serializing_if = "Option::is_none")]
131 reference: Option<String>,
132 },
133 #[serde(rename = "receive_claim")]
134 ReceiveClaim {
135 id: String,
136 wallet: String,
137 quote_id: String,
138 },
139
140 #[serde(rename = "cashu_send")]
141 CashuSend {
142 id: String,
143 #[serde(default)]
144 wallet: Option<String>,
145 amount: Amount,
146 #[serde(default)]
147 onchain_memo: Option<String>,
148 #[serde(default, deserialize_with = "deserialize_local_memo")]
149 local_memo: Option<BTreeMap<String, String>>,
150 #[serde(default, skip_serializing_if = "Option::is_none")]
152 mints: Option<Vec<String>>,
153 },
154 #[serde(rename = "cashu_receive")]
155 CashuReceive {
156 id: String,
157 #[serde(default)]
158 wallet: Option<String>,
159 token: String,
160 },
161 #[serde(rename = "send")]
162 Send {
163 id: String,
164 #[serde(default)]
165 wallet: Option<String>,
166 #[serde(default, skip_serializing_if = "Option::is_none")]
167 network: Option<Network>,
168 to: String,
169 #[serde(default)]
170 onchain_memo: Option<String>,
171 #[serde(default, deserialize_with = "deserialize_local_memo")]
172 local_memo: Option<BTreeMap<String, String>>,
173 #[serde(default, skip_serializing_if = "Option::is_none")]
175 mints: Option<Vec<String>>,
176 },
177
178 #[serde(rename = "restore")]
179 Restore { id: String, wallet: String },
180 #[serde(rename = "local_wallet_show_seed")]
181 WalletShowSeed { id: String, wallet: String },
182
183 #[serde(rename = "history")]
184 HistoryList {
185 id: String,
186 #[serde(default)]
187 wallet: Option<String>,
188 #[serde(default, skip_serializing_if = "Option::is_none")]
189 network: Option<Network>,
190 #[serde(default, skip_serializing_if = "Option::is_none")]
191 onchain_memo: Option<String>,
192 #[serde(default)]
193 limit: Option<usize>,
194 #[serde(default)]
195 offset: Option<usize>,
196 #[serde(default, skip_serializing_if = "Option::is_none")]
198 since_epoch_s: Option<u64>,
199 #[serde(default, skip_serializing_if = "Option::is_none")]
201 until_epoch_s: Option<u64>,
202 },
203 #[serde(rename = "history_status")]
204 HistoryStatus { id: String, transaction_id: String },
205 #[serde(rename = "history_update")]
206 HistoryUpdate {
207 id: String,
208 #[serde(default)]
209 wallet: Option<String>,
210 #[serde(default, skip_serializing_if = "Option::is_none")]
211 network: Option<Network>,
212 #[serde(default)]
213 limit: Option<usize>,
214 },
215
216 #[serde(rename = "limit_add")]
217 LimitAdd { id: String, limit: SpendLimit },
218 #[serde(rename = "limit_remove")]
219 LimitRemove { id: String, rule_id: String },
220 #[serde(rename = "limit_list")]
221 LimitList { id: String },
222 #[serde(rename = "limit_set")]
223 LimitSet { id: String, limits: Vec<SpendLimit> },
224
225 #[serde(rename = "wallet_config_show")]
226 WalletConfigShow { id: String, wallet: String },
227 #[serde(rename = "wallet_config_set")]
228 WalletConfigSet {
229 id: String,
230 wallet: String,
231 #[serde(default, skip_serializing_if = "Option::is_none")]
232 label: Option<String>,
233 #[serde(default, skip_serializing_if = "Vec::is_empty")]
234 rpc_endpoints: Vec<String>,
235 #[serde(default, skip_serializing_if = "Option::is_none")]
236 chain_id: Option<u64>,
237 },
238 #[serde(rename = "wallet_config_token_add")]
239 WalletConfigTokenAdd {
240 id: String,
241 wallet: String,
242 symbol: String,
243 address: String,
244 decimals: u8,
245 },
246 #[serde(rename = "wallet_config_token_remove")]
247 WalletConfigTokenRemove {
248 id: String,
249 wallet: String,
250 symbol: String,
251 },
252
253 #[serde(rename = "config")]
254 Config(ConfigPatch),
255 #[serde(rename = "config_show")]
256 ConfigShow { id: String },
257 #[serde(rename = "version")]
258 Version,
259 #[serde(rename = "close")]
260 Close,
261}
262
263impl Input {
264 pub fn is_local_only(&self) -> bool {
266 matches!(
267 self,
268 Input::WalletShowSeed { .. }
269 | Input::WalletClose {
270 dangerously_skip_balance_check_and_may_lose_money: true,
271 ..
272 }
273 | Input::LimitAdd { .. }
274 | Input::LimitRemove { .. }
275 | Input::LimitSet { .. }
276 | Input::WalletConfigSet { .. }
277 | Input::WalletConfigTokenAdd { .. }
278 | Input::WalletConfigTokenRemove { .. }
279 | Input::Restore { .. }
280 | Input::Config(_)
281 | Input::ConfigShow { .. }
282 )
283 }
284}
285
286#[derive(Debug, Serialize)]
291#[serde(tag = "code")]
292pub enum Output {
293 #[serde(rename = "wallet_created")]
294 WalletCreated {
295 id: String,
296 wallet: String,
297 network: Network,
298 address: String,
299 #[serde(skip_serializing_if = "Option::is_none")]
300 mnemonic: Option<String>,
301 trace: Trace,
302 },
303 #[serde(rename = "wallet_closed")]
304 WalletClosed {
305 id: String,
306 wallet: String,
307 trace: Trace,
308 },
309 #[serde(rename = "wallet_list")]
310 WalletList {
311 id: String,
312 wallets: Vec<WalletSummary>,
313 trace: Trace,
314 },
315 #[serde(rename = "wallet_balances")]
316 WalletBalances {
317 id: String,
318 wallets: Vec<WalletBalanceItem>,
319 #[serde(default, skip_serializing_if = "Vec::is_empty")]
320 summary: Vec<NetworkBalanceSummary>,
321 trace: Trace,
322 },
323 #[serde(rename = "receive_info")]
324 ReceiveInfo {
325 id: String,
326 wallet: String,
327 receive_info: ReceiveInfo,
328 trace: Trace,
329 },
330 #[serde(rename = "receive_claimed")]
331 ReceiveClaimed {
332 id: String,
333 wallet: String,
334 amount: Amount,
335 trace: Trace,
336 },
337
338 #[serde(rename = "cashu_sent")]
339 CashuSent {
340 id: String,
341 wallet: String,
342 transaction_id: String,
343 status: TxStatus,
344 #[serde(skip_serializing_if = "Option::is_none")]
345 fee: Option<Amount>,
346 token: String,
347 trace: Trace,
348 },
349
350 #[serde(rename = "history")]
351 History {
352 id: String,
353 items: Vec<HistoryRecord>,
354 trace: Trace,
355 },
356 #[serde(rename = "history_status")]
357 HistoryStatus {
358 id: String,
359 transaction_id: String,
360 status: TxStatus,
361 #[serde(skip_serializing_if = "Option::is_none")]
362 confirmations: Option<u32>,
363 #[serde(skip_serializing_if = "Option::is_none")]
364 preimage: Option<String>,
365 #[serde(skip_serializing_if = "Option::is_none")]
366 item: Option<HistoryRecord>,
367 trace: Trace,
368 },
369 #[serde(rename = "history_updated")]
370 HistoryUpdated {
371 id: String,
372 wallets_synced: usize,
373 records_scanned: usize,
374 records_added: usize,
375 records_updated: usize,
376 trace: Trace,
377 },
378
379 #[serde(rename = "limit_added")]
380 LimitAdded {
381 id: String,
382 rule_id: String,
383 trace: Trace,
384 },
385 #[serde(rename = "limit_removed")]
386 LimitRemoved {
387 id: String,
388 rule_id: String,
389 trace: Trace,
390 },
391 #[serde(rename = "limit_status")]
392 LimitStatus {
393 id: String,
394 limits: Vec<SpendLimitStatus>,
395 #[serde(default, skip_serializing_if = "Vec::is_empty")]
396 downstream: Vec<DownstreamLimitNode>,
397 trace: Trace,
398 },
399 #[serde(rename = "limit_exceeded")]
400 #[allow(dead_code)]
401 LimitExceeded {
402 id: String,
403 rule_id: String,
404 scope: SpendScope,
405 scope_key: String,
406 spent: u64,
407 max_spend: u64,
408 #[serde(skip_serializing_if = "Option::is_none")]
409 token: Option<String>,
410 remaining_s: u64,
411 #[serde(skip_serializing_if = "Option::is_none")]
412 origin: Option<String>,
413 trace: Trace,
414 },
415
416 #[serde(rename = "cashu_received")]
417 CashuReceived {
418 id: String,
419 wallet: String,
420 amount: Amount,
421 #[serde(skip_serializing_if = "Option::is_none")]
422 memo: Option<String>,
423 trace: Trace,
424 },
425 #[serde(rename = "restored")]
426 Restored {
427 id: String,
428 wallet: String,
429 unspent: u64,
430 spent: u64,
431 pending: u64,
432 unit: String,
433 trace: Trace,
434 },
435 #[serde(rename = "wallet_seed")]
436 WalletSeed {
437 id: String,
438 wallet: String,
439 mnemonic_secret: String,
440 trace: Trace,
441 },
442
443 #[serde(rename = "sent")]
444 Sent {
445 id: String,
446 wallet: String,
447 transaction_id: String,
448 amount: Amount,
449 #[serde(skip_serializing_if = "Option::is_none")]
450 fee: Option<Amount>,
451 #[serde(skip_serializing_if = "Option::is_none")]
452 preimage: Option<String>,
453 trace: Trace,
454 },
455
456 #[serde(rename = "wallet_config")]
457 WalletConfig {
458 id: String,
459 wallet: String,
460 config: WalletMetadata,
461 trace: Trace,
462 },
463 #[serde(rename = "wallet_config_updated")]
464 WalletConfigUpdated {
465 id: String,
466 wallet: String,
467 trace: Trace,
468 },
469 #[serde(rename = "wallet_config_token_added")]
470 WalletConfigTokenAdded {
471 id: String,
472 wallet: String,
473 symbol: String,
474 address: String,
475 decimals: u8,
476 trace: Trace,
477 },
478 #[serde(rename = "wallet_config_token_removed")]
479 WalletConfigTokenRemoved {
480 id: String,
481 wallet: String,
482 symbol: String,
483 trace: Trace,
484 },
485
486 #[serde(rename = "data_backed_up")]
487 #[cfg_attr(not(feature = "backup"), allow(dead_code))]
488 DataBackedUp {
489 data_dir: String,
490 path: String,
491 created_at_utc: String,
492 trace: Trace,
493 },
494 #[serde(rename = "data_restored")]
495 #[cfg_attr(not(feature = "backup"), allow(dead_code))]
496 DataRestored {
497 data_dir: String,
498 path: String,
499 trace: Trace,
500 },
501
502 #[serde(rename = "network_data_backed_up")]
503 #[cfg_attr(not(feature = "backup"), allow(dead_code))]
504 NetworkDataBackedUp {
505 network: String,
506 data_dir: String,
507 path: String,
508 created_at_utc: String,
509 trace: Trace,
510 },
511 #[serde(rename = "network_data_restored")]
512 #[cfg_attr(not(feature = "backup"), allow(dead_code))]
513 NetworkDataRestored {
514 network: String,
515 data_dir: String,
516 path: String,
517 trace: Trace,
518 },
519
520 #[serde(rename = "error")]
521 Error {
522 #[serde(skip_serializing_if = "Option::is_none")]
523 id: Option<String>,
524 error_code: String,
525 error: String,
526 #[serde(skip_serializing_if = "Option::is_none")]
527 hint: Option<String>,
528 retryable: bool,
529 trace: Trace,
530 },
531
532 #[serde(rename = "dry_run")]
533 DryRun {
534 #[serde(skip_serializing_if = "Option::is_none")]
535 id: Option<String>,
536 command: String,
537 params: serde_json::Value,
538 trace: Trace,
539 },
540
541 #[serde(rename = "config")]
542 Config(RuntimeConfig),
543 #[serde(rename = "version")]
544 Version {
545 version: String,
546 protocol_version: u32,
547 trace: PongTrace,
548 },
549 #[serde(rename = "close")]
550 Close { message: String, trace: CloseTrace },
551 #[serde(rename = "log")]
552 Log {
553 event: String,
554 #[serde(skip_serializing_if = "Option::is_none")]
555 request_id: Option<String>,
556 #[serde(skip_serializing_if = "Option::is_none")]
557 version: Option<String>,
558 #[serde(skip_serializing_if = "Option::is_none")]
559 argv: Option<Vec<String>>,
560 #[serde(skip_serializing_if = "Option::is_none")]
561 config: Option<serde_json::Value>,
562 #[serde(skip_serializing_if = "Option::is_none")]
563 args: Option<serde_json::Value>,
564 #[serde(skip_serializing_if = "Option::is_none")]
565 env: Option<serde_json::Value>,
566 trace: Trace,
567 },
568}
569
570#[allow(dead_code)]
573pub fn is_bolt12_offer(s: &str) -> bool {
574 s.len() >= 4 && s[..4].eq_ignore_ascii_case("lno1")
575}
576
577#[allow(dead_code)]
580pub fn parse_bolt12_offer_parts(s: &str) -> (String, Option<u64>) {
581 if let Some(idx) = s.find("?amount=") {
582 let offer = s[..idx].to_string();
583 let amt = s[idx + 8..].parse::<u64>().ok();
584 (offer, amt)
585 } else {
586 (s.to_string(), None)
587 }
588}
589
590#[cfg(test)]
591#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
592mod tests {
593 use super::*;
594 use crate::types::*;
595
596 #[test]
597 fn bolt12_offer_detection() {
598 assert!(is_bolt12_offer("lno1qgsqvgjwcf6qqz9"));
599 assert!(is_bolt12_offer("lno1qgsqvgjwcf6qqz9?amount=1000"));
600 assert!(is_bolt12_offer("LNO1QGSQVGJWCF6QQZ9"));
601 assert!(is_bolt12_offer("Lno1MixedCase"));
602 assert!(!is_bolt12_offer("lnbc1qgsqvgjwcf6qqz9"));
603 assert!(!is_bolt12_offer("lno"));
604 assert!(!is_bolt12_offer(""));
605 }
606
607 #[test]
608 fn bolt12_offer_parts_parsing() {
609 let (offer, amt) = parse_bolt12_offer_parts("lno1abc123");
610 assert_eq!(offer, "lno1abc123");
611 assert_eq!(amt, None);
612
613 let (offer, amt) = parse_bolt12_offer_parts("lno1abc123?amount=500");
614 assert_eq!(offer, "lno1abc123");
615 assert_eq!(amt, Some(500));
616
617 let (offer, amt) = parse_bolt12_offer_parts("LNO1ABC?amount=42");
618 assert_eq!(offer, "LNO1ABC");
619 assert_eq!(amt, Some(42));
620 }
621
622 #[test]
623 fn local_only_checks() {
624 assert!(Input::WalletShowSeed {
626 id: "t".into(),
627 wallet: "w".into(),
628 }
629 .is_local_only());
630
631 assert!(Input::WalletClose {
632 id: "t".into(),
633 wallet: "w".into(),
634 dangerously_skip_balance_check_and_may_lose_money: true,
635 }
636 .is_local_only());
637
638 assert!(!Input::WalletClose {
639 id: "t".into(),
640 wallet: "w".into(),
641 dangerously_skip_balance_check_and_may_lose_money: false,
642 }
643 .is_local_only());
644
645 assert!(Input::LimitAdd {
647 id: "t".into(),
648 limit: SpendLimit {
649 rule_id: None,
650 scope: SpendScope::GlobalUsdCents,
651 network: None,
652 wallet: None,
653 window_s: 3600,
654 max_spend: 1000,
655 token: None,
656 },
657 }
658 .is_local_only());
659
660 assert!(Input::LimitRemove {
661 id: "t".into(),
662 rule_id: "r_1".into(),
663 }
664 .is_local_only());
665
666 assert!(Input::LimitSet {
667 id: "t".into(),
668 limits: vec![],
669 }
670 .is_local_only());
671
672 assert!(!Input::LimitList { id: "t".into() }.is_local_only());
674
675 assert!(Input::WalletConfigSet {
677 id: "t".into(),
678 wallet: "w".into(),
679 label: None,
680 rpc_endpoints: vec![],
681 chain_id: None,
682 }
683 .is_local_only());
684
685 assert!(Input::WalletConfigTokenAdd {
686 id: "t".into(),
687 wallet: "w".into(),
688 symbol: "dai".into(),
689 address: "0x".into(),
690 decimals: 18,
691 }
692 .is_local_only());
693
694 assert!(Input::WalletConfigTokenRemove {
695 id: "t".into(),
696 wallet: "w".into(),
697 symbol: "dai".into(),
698 }
699 .is_local_only());
700
701 assert!(!Input::WalletConfigShow {
703 id: "t".into(),
704 wallet: "w".into(),
705 }
706 .is_local_only());
707
708 assert!(Input::Restore {
710 id: "t".into(),
711 wallet: "w".into(),
712 }
713 .is_local_only());
714 }
715
716 #[test]
717 fn wallet_seed_output_uses_mnemonic_secret_field() {
718 let out = Output::WalletSeed {
719 id: "t_1".to_string(),
720 wallet: "w_1".to_string(),
721 mnemonic_secret: "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about".to_string(),
722 trace: Trace::from_duration(0),
723 };
724 let value = serde_json::to_value(out).expect("serialize wallet_seed output");
725 assert_eq!(
726 value.get("mnemonic_secret").and_then(|v| v.as_str()),
727 Some(
728 "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
729 )
730 );
731 assert!(value.get("mnemonic").is_none());
732 }
733
734 #[test]
735 fn version_output_includes_json_protocol_version() {
736 let out = Output::Version {
737 version: "0.1.0".to_string(),
738 protocol_version: JSON_PROTOCOL_VERSION,
739 trace: PongTrace {
740 uptime_s: 1,
741 requests_total: 2,
742 in_flight: 0,
743 },
744 };
745 let value = serde_json::to_value(out).expect("serialize version output");
746 assert_eq!(
747 value.get("protocol_version").and_then(|v| v.as_u64()),
748 Some(JSON_PROTOCOL_VERSION as u64)
749 );
750 }
751
752 #[test]
753 fn debug_output_redacts_config_secrets() {
754 let mut afpay_rpc = std::collections::HashMap::new();
755 afpay_rpc.insert(
756 "wallet-server".to_string(),
757 AfpayRpcConfig {
758 endpoint: "http://127.0.0.1:9400".to_string(),
759 endpoint_secret: Some("downstream-secret-value".to_string()),
760 },
761 );
762 let config = RuntimeConfig {
763 rpc_secret: Some("rpc-secret-value".to_string()),
764 postgres_url_secret: Some("postgres-secret-value".to_string()),
765 exchange_rate: Some(ExchangeRateConfig {
766 ttl_s: 60,
767 sources: vec![ExchangeRateSource {
768 source_type: ExchangeRateSourceType::Generic,
769 endpoint: "https://rates.example".to_string(),
770 api_key_secret: Some("exchange-secret-value".to_string()),
771 }],
772 }),
773 afpay_rpc,
774 ..RuntimeConfig::default()
775 };
776 let rendered = format!("{config:?}");
777 assert!(!rendered.contains("rpc-secret-value"));
778 assert!(!rendered.contains("postgres-secret-value"));
779 assert!(!rendered.contains("downstream-secret-value"));
780 assert!(!rendered.contains("exchange-secret-value"));
781 assert!(rendered.contains("***"));
782 }
783
784 #[test]
785 fn debug_output_redacts_wallet_request_secrets() {
786 let wallet_request = WalletCreateRequest {
787 label: "default".to_string(),
788 mint_url: None,
789 rpc_endpoints: vec![],
790 chain_id: None,
791 mnemonic_secret: Some("wallet-seed-secret".to_string()),
792 btc_esplora_url: None,
793 btc_network: None,
794 btc_address_type: None,
795 btc_backend: None,
796 btc_core_url: None,
797 btc_core_auth_secret: Some("btc-core-secret".to_string()),
798 btc_electrum_url: None,
799 };
800 let ln_request = LnWalletCreateRequest {
801 backend: LnWalletBackend::Nwc,
802 label: Some("ln".to_string()),
803 nwc_uri_secret: Some("nwc-uri-secret".to_string()),
804 endpoint: None,
805 password_secret: Some("password-secret".to_string()),
806 admin_key_secret: Some("admin-secret".to_string()),
807 };
808 let rendered = format!("{wallet_request:?} {ln_request:?}");
809 assert!(!rendered.contains("wallet-seed-secret"));
810 assert!(!rendered.contains("btc-core-secret"));
811 assert!(!rendered.contains("nwc-uri-secret"));
812 assert!(!rendered.contains("password-secret"));
813 assert!(!rendered.contains("admin-secret"));
814 assert!(rendered.contains("***"));
815 }
816
817 #[test]
818 fn history_list_parses_time_range_fields() {
819 let json = r#"{
820 "code": "history",
821 "id": "t_1",
822 "wallet": "w_1",
823 "limit": 10,
824 "offset": 0,
825 "since_epoch_s": 1700000000,
826 "until_epoch_s": 1700100000
827 }"#;
828 let input: Input = serde_json::from_str(json).expect("parse history_list with time range");
829 match input {
830 Input::HistoryList {
831 since_epoch_s,
832 until_epoch_s,
833 ..
834 } => {
835 assert_eq!(since_epoch_s, Some(1_700_000_000));
836 assert_eq!(until_epoch_s, Some(1_700_100_000));
837 }
838 other => panic!("expected HistoryList, got {other:?}"),
839 }
840 }
841
842 #[test]
843 fn history_list_time_range_fields_default_to_none() {
844 let json = r#"{
845 "code": "history",
846 "id": "t_1",
847 "limit": 10,
848 "offset": 0
849 }"#;
850 let input: Input =
851 serde_json::from_str(json).expect("parse history_list without time range");
852 match input {
853 Input::HistoryList {
854 since_epoch_s,
855 until_epoch_s,
856 ..
857 } => {
858 assert_eq!(since_epoch_s, None);
859 assert_eq!(until_epoch_s, None);
860 }
861 other => panic!("expected HistoryList, got {other:?}"),
862 }
863 }
864
865 #[test]
866 fn history_update_parses_sync_fields() {
867 let json = r#"{
868 "code": "history_update",
869 "id": "t_2",
870 "wallet": "w_1",
871 "network": "sol",
872 "limit": 150
873 }"#;
874 let input: Input = serde_json::from_str(json).expect("parse history_update");
875 match input {
876 Input::HistoryUpdate {
877 wallet,
878 network,
879 limit,
880 ..
881 } => {
882 assert_eq!(wallet.as_deref(), Some("w_1"));
883 assert_eq!(network, Some(Network::Sol));
884 assert_eq!(limit, Some(150));
885 }
886 other => panic!("expected HistoryUpdate, got {other:?}"),
887 }
888 }
889
890 #[test]
891 fn history_update_fields_default_to_none() {
892 let json = r#"{
893 "code": "history_update",
894 "id": "t_3"
895 }"#;
896 let input: Input = serde_json::from_str(json).expect("parse history_update defaults");
897 match input {
898 Input::HistoryUpdate {
899 wallet,
900 network,
901 limit,
902 ..
903 } => {
904 assert_eq!(wallet, None);
905 assert_eq!(network, None);
906 assert_eq!(limit, None);
907 }
908 other => panic!("expected HistoryUpdate, got {other:?}"),
909 }
910 }
911}