Skip to main content

agent_first_pay/types/
protocol.rs

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        /// Cashu mint URL (cashu only).
46        #[serde(default, skip_serializing_if = "Option::is_none")]
47        mint_url: Option<String>,
48        /// RPC endpoints for sol/evm providers.
49        #[serde(default, skip_serializing_if = "Vec::is_empty")]
50        rpc_endpoints: Vec<String>,
51        /// EVM chain ID (evm only, default 8453 = Base).
52        #[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        /// Esplora API URL (btc only).
57        #[serde(default, skip_serializing_if = "Option::is_none")]
58        btc_esplora_url: Option<String>,
59        /// BTC sub-network: "mainnet" | "signet" (btc only).
60        #[serde(default, skip_serializing_if = "Option::is_none")]
61        btc_network: Option<String>,
62        /// BTC address type: "taproot" | "segwit" (btc only).
63        #[serde(default, skip_serializing_if = "Option::is_none")]
64        btc_address_type: Option<String>,
65        /// BTC chain-source backend (btc only).
66        #[serde(default, skip_serializing_if = "Option::is_none")]
67        btc_backend: Option<BtcBackend>,
68        /// Bitcoin Core RPC URL (btc core-rpc only).
69        #[serde(default, skip_serializing_if = "Option::is_none")]
70        btc_core_url: Option<String>,
71        /// Bitcoin Core RPC auth (btc core-rpc only).
72        #[serde(default, skip_serializing_if = "Option::is_none")]
73        btc_core_auth_secret: Option<String>,
74        /// Electrum server URL (btc electrum only).
75        #[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        /// Reference key to watch for (base58, sol only, per strain-payment-method-solana).
130        #[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        /// Restrict to wallets on these mints (tried in order).
151        #[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        /// Restrict to wallets on these mints (cashu only).
174        #[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        /// Only include records created at or after this epoch second.
197        #[serde(default, skip_serializing_if = "Option::is_none")]
198        since_epoch_s: Option<u64>,
199        /// Only include records created before this epoch second.
200        #[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    /// Returns true if this input must only be handled locally (never via RPC).
265    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// ═══════════════════════════════════════════
287// Output (Responses)
288// ═══════════════════════════════════════════
289
290#[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/// Returns true if the string looks like a BOLT12 offer (`lno1…`),
571/// optionally with a `?amount=<sats>` suffix. Case-insensitive.
572#[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/// Split a BOLT12 offer string into the raw offer and an optional amount-sats.
578/// Accepts `lno1...` or `lno1...?amount=1000`. Case-insensitive prefix detection.
579#[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        // Already local-only
625        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        // Limit write ops
646        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        // Limit read is NOT local-only
673        assert!(!Input::LimitList { id: "t".into() }.is_local_only());
674
675        // Wallet config write ops
676        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        // Wallet config read is NOT local-only
702        assert!(!Input::WalletConfigShow {
703            id: "t".into(),
704            wallet: "w".into(),
705        }
706        .is_local_only());
707
708        // Restore (seed over RPC)
709        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}