Skip to main content

agent_first_pay/
types.rs

1use crate::store::wallet::WalletMetadata;
2use serde::{Deserialize, Deserializer, Serialize};
3use std::collections::BTreeMap;
4
5// ═══════════════════════════════════════════
6// Core Enums
7// ═══════════════════════════════════════════
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
10#[serde(rename_all = "lowercase")]
11pub enum Network {
12    Ln,
13    Sol,
14    Evm,
15    Cashu,
16    Btc,
17}
18
19impl std::fmt::Display for Network {
20    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
21        match self {
22            Self::Ln => write!(f, "ln"),
23            Self::Sol => write!(f, "sol"),
24            Self::Evm => write!(f, "evm"),
25            Self::Cashu => write!(f, "cashu"),
26            Self::Btc => write!(f, "btc"),
27        }
28    }
29}
30
31impl std::str::FromStr for Network {
32    type Err = String;
33    fn from_str(s: &str) -> Result<Self, Self::Err> {
34        match s {
35            "ln" => Ok(Self::Ln),
36            "sol" => Ok(Self::Sol),
37            "evm" => Ok(Self::Evm),
38            "cashu" => Ok(Self::Cashu),
39            "btc" => Ok(Self::Btc),
40            _ => Err(format!(
41                "unknown network '{s}'; expected: cashu, ln, sol, evm, btc"
42            )),
43        }
44    }
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct WalletCreateRequest {
49    pub label: String,
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    pub mint_url: Option<String>,
52    #[serde(default, skip_serializing_if = "Vec::is_empty")]
53    pub rpc_endpoints: Vec<String>,
54    #[serde(default, skip_serializing_if = "Option::is_none")]
55    pub chain_id: Option<u64>,
56    #[serde(default, skip_serializing_if = "Option::is_none")]
57    pub mnemonic_secret: Option<String>,
58    /// Esplora API URL for BTC (btc only).
59    #[serde(default, skip_serializing_if = "Option::is_none")]
60    pub btc_esplora_url: Option<String>,
61    /// BTC sub-network: "mainnet" or "signet" (btc only).
62    #[serde(default, skip_serializing_if = "Option::is_none")]
63    pub btc_network: Option<String>,
64    /// BTC address type: "taproot" or "segwit" (btc only).
65    #[serde(default, skip_serializing_if = "Option::is_none")]
66    pub btc_address_type: Option<String>,
67    /// BTC chain-source backend: esplora (default), core-rpc, electrum.
68    #[serde(default, skip_serializing_if = "Option::is_none")]
69    pub btc_backend: Option<BtcBackend>,
70    /// Bitcoin Core RPC URL (btc core-rpc backend only).
71    #[serde(default, skip_serializing_if = "Option::is_none")]
72    pub btc_core_url: Option<String>,
73    /// Bitcoin Core RPC auth "user:pass" (btc core-rpc backend only).
74    #[serde(default, skip_serializing_if = "Option::is_none")]
75    pub btc_core_auth_secret: Option<String>,
76    /// Electrum server URL (btc electrum backend only).
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    pub btc_electrum_url: Option<String>,
79}
80
81#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
82#[serde(rename_all = "lowercase")]
83pub enum Direction {
84    Send,
85    Receive,
86}
87
88#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
89#[serde(rename_all = "lowercase")]
90pub enum TxStatus {
91    Pending,
92    Confirmed,
93    Failed,
94}
95
96// ═══════════════════════════════════════════
97// Value Types
98// ═══════════════════════════════════════════
99
100#[derive(Debug, Clone, Serialize, Deserialize)]
101pub struct Amount {
102    pub value: u64,
103    pub token: String,
104}
105
106#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
107#[serde(rename_all = "snake_case")]
108pub enum LnWalletBackend {
109    Nwc,
110    Phoenixd,
111    Lnbits,
112}
113
114impl LnWalletBackend {
115    #[cfg_attr(
116        not(any(feature = "ln-nwc", feature = "ln-phoenixd", feature = "ln-lnbits")),
117        allow(dead_code)
118    )]
119    pub fn as_str(self) -> &'static str {
120        match self {
121            Self::Nwc => "nwc",
122            Self::Phoenixd => "phoenixd",
123            Self::Lnbits => "lnbits",
124        }
125    }
126}
127
128#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
129#[serde(rename_all = "kebab-case")]
130pub enum BtcBackend {
131    Esplora,
132    CoreRpc,
133    Electrum,
134}
135
136impl BtcBackend {
137    #[cfg_attr(
138        not(any(
139            feature = "btc-esplora",
140            feature = "btc-core",
141            feature = "btc-electrum"
142        )),
143        allow(dead_code)
144    )]
145    pub fn as_str(self) -> &'static str {
146        match self {
147            Self::Esplora => "esplora",
148            Self::CoreRpc => "core-rpc",
149            Self::Electrum => "electrum",
150        }
151    }
152}
153
154#[derive(Debug, Clone, Serialize, Deserialize)]
155pub struct LnWalletCreateRequest {
156    pub backend: LnWalletBackend,
157    #[serde(default, skip_serializing_if = "Option::is_none")]
158    pub label: Option<String>,
159    #[serde(default, skip_serializing_if = "Option::is_none")]
160    pub nwc_uri_secret: Option<String>,
161    #[serde(default, skip_serializing_if = "Option::is_none")]
162    pub endpoint: Option<String>,
163    #[serde(default, skip_serializing_if = "Option::is_none")]
164    pub password_secret: Option<String>,
165    #[serde(default, skip_serializing_if = "Option::is_none")]
166    pub admin_key_secret: Option<String>,
167}
168
169#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
170#[serde(rename_all = "snake_case")]
171pub enum SpendScope {
172    #[serde(alias = "all")]
173    GlobalUsdCents,
174    Network,
175    Wallet,
176}
177
178fn default_spend_scope_network() -> SpendScope {
179    SpendScope::Network
180}
181
182#[derive(Debug, Clone, Serialize, Deserialize)]
183pub struct SpendLimit {
184    #[serde(default, skip_serializing_if = "Option::is_none")]
185    pub rule_id: Option<String>,
186    #[serde(default = "default_spend_scope_network")]
187    pub scope: SpendScope,
188    #[serde(default, skip_serializing_if = "Option::is_none")]
189    pub network: Option<String>,
190    #[serde(default, skip_serializing_if = "Option::is_none")]
191    pub wallet: Option<String>,
192    pub window_s: u64,
193    pub max_spend: u64,
194    #[serde(default, skip_serializing_if = "Option::is_none")]
195    pub token: Option<String>,
196}
197
198#[derive(Debug, Clone, Serialize, Deserialize)]
199pub struct SpendLimitStatus {
200    pub rule_id: String,
201    pub scope: SpendScope,
202    #[serde(default, skip_serializing_if = "Option::is_none")]
203    pub network: Option<String>,
204    #[serde(default, skip_serializing_if = "Option::is_none")]
205    pub wallet: Option<String>,
206    pub window_s: u64,
207    pub max_spend: u64,
208    pub spent: u64,
209    pub remaining: u64,
210    #[serde(default, skip_serializing_if = "Option::is_none")]
211    pub token: Option<String>,
212    pub window_reset_s: u64,
213}
214
215#[derive(Debug, Clone, Serialize, Deserialize)]
216pub struct DownstreamLimitNode {
217    pub name: String,
218    pub endpoint: String,
219    #[serde(default, skip_serializing_if = "Vec::is_empty")]
220    pub limits: Vec<SpendLimitStatus>,
221    #[serde(default, skip_serializing_if = "Option::is_none")]
222    pub error: Option<String>,
223    #[serde(default, skip_serializing_if = "Vec::is_empty")]
224    pub downstream: Vec<DownstreamLimitNode>,
225}
226
227#[derive(Debug, Clone, Serialize, Deserialize)]
228pub struct WalletInfo {
229    pub id: String,
230    pub network: Network,
231    pub address: String,
232    #[serde(skip_serializing_if = "Option::is_none")]
233    pub label: Option<String>,
234    #[serde(skip_serializing_if = "Option::is_none")]
235    pub mnemonic: Option<String>,
236}
237
238#[derive(Debug, Clone, Serialize, Deserialize)]
239pub struct WalletSummary {
240    pub id: String,
241    pub network: Network,
242    #[serde(skip_serializing_if = "Option::is_none")]
243    pub label: Option<String>,
244    pub address: String,
245    #[serde(default, skip_serializing_if = "Option::is_none")]
246    pub backend: Option<String>,
247    #[serde(default, skip_serializing_if = "Option::is_none")]
248    pub mint_url: Option<String>,
249    #[serde(default, skip_serializing_if = "Option::is_none")]
250    pub rpc_endpoints: Option<Vec<String>>,
251    #[serde(default, skip_serializing_if = "Option::is_none")]
252    pub chain_id: Option<u64>,
253    pub created_at_epoch_s: u64,
254}
255
256#[derive(Debug, Clone, Serialize, Deserialize)]
257pub struct BalanceInfo {
258    pub confirmed: u64,
259    pub pending: u64,
260    /// Native unit name: "sats", "lamports", "gwei", "token-units".
261    pub unit: String,
262    /// Provider-specific extra balance categories.
263    /// Example: `fee_credit_sats` for phoenixd.
264    #[serde(default, flatten, skip_serializing_if = "BTreeMap::is_empty")]
265    pub additional: BTreeMap<String, u64>,
266}
267
268impl BalanceInfo {
269    pub fn new(confirmed: u64, pending: u64, unit: impl Into<String>) -> Self {
270        Self {
271            confirmed,
272            pending,
273            unit: unit.into(),
274            additional: BTreeMap::new(),
275        }
276    }
277
278    #[cfg_attr(not(feature = "ln-phoenixd"), allow(dead_code))]
279    pub fn with_additional(mut self, key: impl Into<String>, value: u64) -> Self {
280        self.additional.insert(key.into(), value);
281        self
282    }
283
284    #[cfg_attr(
285        not(any(
286            feature = "cashu",
287            feature = "ln-nwc",
288            feature = "ln-phoenixd",
289            feature = "ln-lnbits",
290            feature = "sol",
291            feature = "evm",
292            feature = "btc-esplora",
293            feature = "btc-core",
294            feature = "btc-electrum"
295        )),
296        allow(dead_code)
297    )]
298    pub fn non_zero_components(&self) -> Vec<(String, u64)> {
299        let mut components = Vec::new();
300        if self.confirmed > 0 {
301            components.push((format!("confirmed_{}", self.unit), self.confirmed));
302        }
303        if self.pending > 0 {
304            components.push((format!("pending_{}", self.unit), self.pending));
305        }
306        for (key, value) in &self.additional {
307            if *value > 0 {
308                components.push((key.clone(), *value));
309            }
310        }
311        components
312    }
313}
314
315#[derive(Debug, Clone, Serialize, Deserialize)]
316pub struct WalletBalanceItem {
317    #[serde(flatten)]
318    pub wallet: WalletSummary,
319    #[serde(default, skip_serializing_if = "Option::is_none")]
320    pub balance: Option<BalanceInfo>,
321    #[serde(default, skip_serializing_if = "Option::is_none")]
322    pub error: Option<String>,
323}
324
325#[derive(Debug, Clone, Serialize, Deserialize)]
326pub struct ReceiveInfo {
327    #[serde(skip_serializing_if = "Option::is_none")]
328    pub address: Option<String>,
329    #[serde(skip_serializing_if = "Option::is_none")]
330    pub invoice: Option<String>,
331    #[serde(skip_serializing_if = "Option::is_none")]
332    pub quote_id: Option<String>,
333}
334
335#[derive(Debug, Clone, Serialize, Deserialize)]
336pub struct HistoryRecord {
337    pub transaction_id: String,
338    pub wallet: String,
339    pub network: Network,
340    pub direction: Direction,
341    pub amount: Amount,
342    pub status: TxStatus,
343    #[serde(skip_serializing_if = "Option::is_none")]
344    pub onchain_memo: Option<String>,
345    #[serde(
346        default,
347        skip_serializing_if = "Option::is_none",
348        deserialize_with = "deserialize_local_memo"
349    )]
350    pub local_memo: Option<BTreeMap<String, String>>,
351    #[serde(skip_serializing_if = "Option::is_none")]
352    pub remote_addr: Option<String>,
353    #[serde(default, skip_serializing_if = "Option::is_none")]
354    pub preimage: Option<String>,
355    pub created_at_epoch_s: u64,
356    #[serde(skip_serializing_if = "Option::is_none")]
357    pub confirmed_at_epoch_s: Option<u64>,
358    #[serde(default, skip_serializing_if = "Option::is_none")]
359    pub fee: Option<Amount>,
360}
361
362#[derive(Debug, Clone, Serialize)]
363pub struct CashuSendResult {
364    pub wallet: String,
365    pub transaction_id: String,
366    pub status: TxStatus,
367    pub fee: Option<Amount>,
368    pub token: String,
369}
370
371#[derive(Debug, Clone, Serialize)]
372pub struct CashuReceiveResult {
373    pub wallet: String,
374    pub amount: Amount,
375}
376
377#[derive(Debug, Clone, Serialize)]
378pub struct RestoreResult {
379    pub wallet: String,
380    pub unspent: u64,
381    pub spent: u64,
382    pub pending: u64,
383    pub unit: String,
384}
385
386#[cfg(feature = "interactive")]
387#[derive(Debug, Clone, Serialize)]
388pub struct CashuSendQuoteInfo {
389    pub wallet: String,
390    pub amount_native: u64,
391    pub fee_native: u64,
392    pub fee_unit: String,
393}
394
395#[derive(Debug, Clone, Serialize)]
396pub struct SendQuoteInfo {
397    pub wallet: String,
398    pub amount_native: u64,
399    pub fee_estimate_native: u64,
400    pub fee_unit: String,
401}
402
403#[derive(Debug, Clone, Serialize)]
404pub struct SendResult {
405    pub wallet: String,
406    pub transaction_id: String,
407    pub amount: Amount,
408    pub fee: Option<Amount>,
409    pub preimage: Option<String>,
410}
411
412#[derive(Debug, Clone, Serialize)]
413pub struct HistoryStatusInfo {
414    pub transaction_id: String,
415    pub status: TxStatus,
416    pub confirmations: Option<u32>,
417    pub preimage: Option<String>,
418    pub item: Option<HistoryRecord>,
419}
420
421// ═══════════════════════════════════════════
422// Trace Types
423// ═══════════════════════════════════════════
424
425#[derive(Debug, Serialize, Clone)]
426pub struct Trace {
427    pub duration_ms: u64,
428}
429
430impl Trace {
431    pub fn from_duration(duration_ms: u64) -> Self {
432        Self { duration_ms }
433    }
434}
435
436#[derive(Debug, Serialize)]
437pub struct PongTrace {
438    pub uptime_s: u64,
439    pub requests_total: u64,
440    pub in_flight: usize,
441}
442
443#[derive(Debug, Serialize)]
444pub struct CloseTrace {
445    pub uptime_s: u64,
446    pub requests_total: u64,
447}
448
449// ═══════════════════════════════════════════
450// Input (Requests)
451// ═══════════════════════════════════════════
452
453#[derive(Debug, Serialize, Deserialize)]
454#[serde(tag = "code")]
455pub enum Input {
456    #[serde(rename = "wallet_create")]
457    WalletCreate {
458        id: String,
459        network: Network,
460        #[serde(default)]
461        label: Option<String>,
462        /// Cashu mint URL (cashu only).
463        #[serde(default, skip_serializing_if = "Option::is_none")]
464        mint_url: Option<String>,
465        /// RPC endpoints for sol/evm providers.
466        #[serde(default, skip_serializing_if = "Vec::is_empty")]
467        rpc_endpoints: Vec<String>,
468        /// EVM chain ID (evm only, default 8453 = Base).
469        #[serde(default, skip_serializing_if = "Option::is_none")]
470        chain_id: Option<u64>,
471        #[serde(default, skip_serializing_if = "Option::is_none")]
472        mnemonic_secret: Option<String>,
473        /// Esplora API URL (btc only).
474        #[serde(default, skip_serializing_if = "Option::is_none")]
475        btc_esplora_url: Option<String>,
476        /// BTC sub-network: "mainnet" | "signet" (btc only).
477        #[serde(default, skip_serializing_if = "Option::is_none")]
478        btc_network: Option<String>,
479        /// BTC address type: "taproot" | "segwit" (btc only).
480        #[serde(default, skip_serializing_if = "Option::is_none")]
481        btc_address_type: Option<String>,
482        /// BTC chain-source backend (btc only).
483        #[serde(default, skip_serializing_if = "Option::is_none")]
484        btc_backend: Option<BtcBackend>,
485        /// Bitcoin Core RPC URL (btc core-rpc only).
486        #[serde(default, skip_serializing_if = "Option::is_none")]
487        btc_core_url: Option<String>,
488        /// Bitcoin Core RPC auth (btc core-rpc only).
489        #[serde(default, skip_serializing_if = "Option::is_none")]
490        btc_core_auth_secret: Option<String>,
491        /// Electrum server URL (btc electrum only).
492        #[serde(default, skip_serializing_if = "Option::is_none")]
493        btc_electrum_url: Option<String>,
494    },
495    #[serde(rename = "ln_wallet_create")]
496    LnWalletCreate {
497        id: String,
498        #[serde(flatten)]
499        request: LnWalletCreateRequest,
500    },
501    #[serde(rename = "wallet_close")]
502    WalletClose {
503        id: String,
504        wallet: String,
505        #[serde(default)]
506        dangerously_skip_balance_check_and_may_lose_money: bool,
507    },
508    #[serde(rename = "wallet_list")]
509    WalletList {
510        id: String,
511        #[serde(default)]
512        network: Option<Network>,
513    },
514    #[serde(rename = "balance")]
515    Balance {
516        id: String,
517        #[serde(default)]
518        wallet: Option<String>,
519        #[serde(default, skip_serializing_if = "Option::is_none")]
520        network: Option<Network>,
521        #[serde(default)]
522        check: bool,
523    },
524    #[serde(rename = "receive")]
525    Receive {
526        id: String,
527        wallet: String,
528        #[serde(default, skip_serializing_if = "Option::is_none")]
529        network: Option<Network>,
530        #[serde(default)]
531        amount: Option<Amount>,
532        #[serde(default, skip_serializing_if = "Option::is_none")]
533        onchain_memo: Option<String>,
534        #[serde(default)]
535        wait_until_paid: bool,
536        #[serde(default)]
537        wait_timeout_s: Option<u64>,
538        #[serde(default)]
539        wait_poll_interval_ms: Option<u64>,
540        #[serde(default)]
541        wait_sync_limit: Option<usize>,
542        #[serde(default)]
543        write_qr_svg_file: bool,
544        #[serde(default, skip_serializing_if = "Option::is_none")]
545        min_confirmations: Option<u32>,
546    },
547    #[serde(rename = "receive_claim")]
548    ReceiveClaim {
549        id: String,
550        wallet: String,
551        quote_id: String,
552    },
553
554    #[serde(rename = "cashu_send")]
555    CashuSend {
556        id: String,
557        #[serde(default)]
558        wallet: Option<String>,
559        amount: Amount,
560        #[serde(default)]
561        onchain_memo: Option<String>,
562        #[serde(default, deserialize_with = "deserialize_local_memo")]
563        local_memo: Option<BTreeMap<String, String>>,
564        /// Restrict to wallets on these mints (tried in order).
565        #[serde(default, skip_serializing_if = "Option::is_none")]
566        mints: Option<Vec<String>>,
567    },
568    #[serde(rename = "cashu_receive")]
569    CashuReceive {
570        id: String,
571        #[serde(default)]
572        wallet: Option<String>,
573        token: String,
574    },
575    #[serde(rename = "send")]
576    Send {
577        id: String,
578        #[serde(default)]
579        wallet: Option<String>,
580        #[serde(default, skip_serializing_if = "Option::is_none")]
581        network: Option<Network>,
582        to: String,
583        #[serde(default)]
584        onchain_memo: Option<String>,
585        #[serde(default, deserialize_with = "deserialize_local_memo")]
586        local_memo: Option<BTreeMap<String, String>>,
587        /// Restrict to wallets on these mints (cashu only).
588        #[serde(default, skip_serializing_if = "Option::is_none")]
589        mints: Option<Vec<String>>,
590    },
591
592    #[serde(rename = "restore")]
593    Restore { id: String, wallet: String },
594    #[serde(rename = "local_wallet_show_seed")]
595    WalletShowSeed { id: String, wallet: String },
596
597    #[serde(rename = "history")]
598    HistoryList {
599        id: String,
600        #[serde(default)]
601        wallet: Option<String>,
602        #[serde(default, skip_serializing_if = "Option::is_none")]
603        network: Option<Network>,
604        #[serde(default, skip_serializing_if = "Option::is_none")]
605        onchain_memo: Option<String>,
606        #[serde(default)]
607        limit: Option<usize>,
608        #[serde(default)]
609        offset: Option<usize>,
610        /// Only include records created at or after this epoch second.
611        #[serde(default, skip_serializing_if = "Option::is_none")]
612        since_epoch_s: Option<u64>,
613        /// Only include records created before this epoch second.
614        #[serde(default, skip_serializing_if = "Option::is_none")]
615        until_epoch_s: Option<u64>,
616    },
617    #[serde(rename = "history_status")]
618    HistoryStatus { id: String, transaction_id: String },
619    #[serde(rename = "history_update")]
620    HistoryUpdate {
621        id: String,
622        #[serde(default)]
623        wallet: Option<String>,
624        #[serde(default, skip_serializing_if = "Option::is_none")]
625        network: Option<Network>,
626        #[serde(default)]
627        limit: Option<usize>,
628    },
629
630    #[serde(rename = "limit_add")]
631    LimitAdd { id: String, limit: SpendLimit },
632    #[serde(rename = "limit_remove")]
633    LimitRemove { id: String, rule_id: String },
634    #[serde(rename = "limit_list")]
635    LimitList { id: String },
636    #[serde(rename = "limit_set")]
637    LimitSet { id: String, limits: Vec<SpendLimit> },
638
639    #[serde(rename = "wallet_config_show")]
640    WalletConfigShow { id: String, wallet: String },
641    #[serde(rename = "wallet_config_set")]
642    WalletConfigSet {
643        id: String,
644        wallet: String,
645        #[serde(default, skip_serializing_if = "Option::is_none")]
646        label: Option<String>,
647        #[serde(default, skip_serializing_if = "Vec::is_empty")]
648        rpc_endpoints: Vec<String>,
649        #[serde(default, skip_serializing_if = "Option::is_none")]
650        chain_id: Option<u64>,
651    },
652    #[serde(rename = "wallet_config_token_add")]
653    WalletConfigTokenAdd {
654        id: String,
655        wallet: String,
656        symbol: String,
657        address: String,
658        decimals: u8,
659    },
660    #[serde(rename = "wallet_config_token_remove")]
661    WalletConfigTokenRemove {
662        id: String,
663        wallet: String,
664        symbol: String,
665    },
666
667    #[serde(rename = "config")]
668    Config(ConfigPatch),
669    #[serde(rename = "version")]
670    Version,
671    #[serde(rename = "close")]
672    Close,
673}
674
675impl Input {
676    /// Returns true if this input must only be handled locally (never via RPC).
677    pub fn is_local_only(&self) -> bool {
678        matches!(
679            self,
680            Input::WalletShowSeed { .. }
681                | Input::WalletClose {
682                    dangerously_skip_balance_check_and_may_lose_money: true,
683                    ..
684                }
685                | Input::LimitAdd { .. }
686                | Input::LimitRemove { .. }
687                | Input::LimitSet { .. }
688                | Input::WalletConfigSet { .. }
689                | Input::WalletConfigTokenAdd { .. }
690                | Input::WalletConfigTokenRemove { .. }
691                | Input::Restore { .. }
692                | Input::Config(_)
693        )
694    }
695}
696
697// ═══════════════════════════════════════════
698// Output (Responses)
699// ═══════════════════════════════════════════
700
701#[derive(Debug, Serialize)]
702#[serde(tag = "code")]
703pub enum Output {
704    #[serde(rename = "wallet_created")]
705    WalletCreated {
706        id: String,
707        wallet: String,
708        network: Network,
709        address: String,
710        #[serde(skip_serializing_if = "Option::is_none")]
711        mnemonic: Option<String>,
712        trace: Trace,
713    },
714    #[serde(rename = "wallet_closed")]
715    WalletClosed {
716        id: String,
717        wallet: String,
718        trace: Trace,
719    },
720    #[serde(rename = "wallet_list")]
721    WalletList {
722        id: String,
723        wallets: Vec<WalletSummary>,
724        trace: Trace,
725    },
726    #[serde(rename = "wallet_balances")]
727    WalletBalances {
728        id: String,
729        wallets: Vec<WalletBalanceItem>,
730        trace: Trace,
731    },
732    #[serde(rename = "receive_info")]
733    ReceiveInfo {
734        id: String,
735        wallet: String,
736        receive_info: ReceiveInfo,
737        trace: Trace,
738    },
739    #[serde(rename = "receive_claimed")]
740    ReceiveClaimed {
741        id: String,
742        wallet: String,
743        amount: Amount,
744        trace: Trace,
745    },
746
747    #[serde(rename = "cashu_sent")]
748    CashuSent {
749        id: String,
750        wallet: String,
751        transaction_id: String,
752        status: TxStatus,
753        #[serde(skip_serializing_if = "Option::is_none")]
754        fee: Option<Amount>,
755        token: String,
756        trace: Trace,
757    },
758
759    #[serde(rename = "history")]
760    History {
761        id: String,
762        items: Vec<HistoryRecord>,
763        trace: Trace,
764    },
765    #[serde(rename = "history_status")]
766    HistoryStatus {
767        id: String,
768        transaction_id: String,
769        status: TxStatus,
770        #[serde(skip_serializing_if = "Option::is_none")]
771        confirmations: Option<u32>,
772        #[serde(skip_serializing_if = "Option::is_none")]
773        preimage: Option<String>,
774        #[serde(skip_serializing_if = "Option::is_none")]
775        item: Option<HistoryRecord>,
776        trace: Trace,
777    },
778    #[serde(rename = "history_updated")]
779    HistoryUpdated {
780        id: String,
781        wallets_synced: usize,
782        records_scanned: usize,
783        records_added: usize,
784        records_updated: usize,
785        trace: Trace,
786    },
787
788    #[serde(rename = "limit_added")]
789    LimitAdded {
790        id: String,
791        rule_id: String,
792        trace: Trace,
793    },
794    #[serde(rename = "limit_removed")]
795    LimitRemoved {
796        id: String,
797        rule_id: String,
798        trace: Trace,
799    },
800    #[serde(rename = "limit_status")]
801    LimitStatus {
802        id: String,
803        limits: Vec<SpendLimitStatus>,
804        #[serde(default, skip_serializing_if = "Vec::is_empty")]
805        downstream: Vec<DownstreamLimitNode>,
806        trace: Trace,
807    },
808    #[serde(rename = "limit_exceeded")]
809    #[allow(dead_code)]
810    LimitExceeded {
811        id: String,
812        rule_id: String,
813        scope: SpendScope,
814        scope_key: String,
815        spent: u64,
816        max_spend: u64,
817        #[serde(skip_serializing_if = "Option::is_none")]
818        token: Option<String>,
819        remaining_s: u64,
820        #[serde(skip_serializing_if = "Option::is_none")]
821        origin: Option<String>,
822        trace: Trace,
823    },
824
825    #[serde(rename = "cashu_received")]
826    CashuReceived {
827        id: String,
828        wallet: String,
829        amount: Amount,
830        trace: Trace,
831    },
832    #[serde(rename = "restored")]
833    Restored {
834        id: String,
835        wallet: String,
836        unspent: u64,
837        spent: u64,
838        pending: u64,
839        unit: String,
840        trace: Trace,
841    },
842    #[serde(rename = "wallet_seed")]
843    WalletSeed {
844        id: String,
845        wallet: String,
846        mnemonic_secret: String,
847        trace: Trace,
848    },
849
850    #[serde(rename = "sent")]
851    Sent {
852        id: String,
853        wallet: String,
854        transaction_id: String,
855        amount: Amount,
856        #[serde(skip_serializing_if = "Option::is_none")]
857        fee: Option<Amount>,
858        #[serde(skip_serializing_if = "Option::is_none")]
859        preimage: Option<String>,
860        trace: Trace,
861    },
862
863    #[serde(rename = "wallet_config")]
864    WalletConfig {
865        id: String,
866        wallet: String,
867        config: WalletMetadata,
868        trace: Trace,
869    },
870    #[serde(rename = "wallet_config_updated")]
871    WalletConfigUpdated {
872        id: String,
873        wallet: String,
874        trace: Trace,
875    },
876    #[serde(rename = "wallet_config_token_added")]
877    WalletConfigTokenAdded {
878        id: String,
879        wallet: String,
880        symbol: String,
881        address: String,
882        decimals: u8,
883        trace: Trace,
884    },
885    #[serde(rename = "wallet_config_token_removed")]
886    WalletConfigTokenRemoved {
887        id: String,
888        wallet: String,
889        symbol: String,
890        trace: Trace,
891    },
892
893    #[serde(rename = "error")]
894    Error {
895        #[serde(skip_serializing_if = "Option::is_none")]
896        id: Option<String>,
897        error_code: String,
898        error: String,
899        #[serde(skip_serializing_if = "Option::is_none")]
900        hint: Option<String>,
901        retryable: bool,
902        trace: Trace,
903    },
904
905    #[serde(rename = "dry_run")]
906    DryRun {
907        #[serde(skip_serializing_if = "Option::is_none")]
908        id: Option<String>,
909        command: String,
910        params: serde_json::Value,
911        trace: Trace,
912    },
913
914    #[serde(rename = "config")]
915    Config(RuntimeConfig),
916    #[serde(rename = "version")]
917    Version { version: String, trace: PongTrace },
918    #[serde(rename = "close")]
919    Close { message: String, trace: CloseTrace },
920    #[serde(rename = "log")]
921    Log {
922        event: String,
923        #[serde(skip_serializing_if = "Option::is_none")]
924        request_id: Option<String>,
925        #[serde(skip_serializing_if = "Option::is_none")]
926        version: Option<String>,
927        #[serde(skip_serializing_if = "Option::is_none")]
928        argv: Option<Vec<String>>,
929        #[serde(skip_serializing_if = "Option::is_none")]
930        config: Option<serde_json::Value>,
931        #[serde(skip_serializing_if = "Option::is_none")]
932        args: Option<serde_json::Value>,
933        #[serde(skip_serializing_if = "Option::is_none")]
934        env: Option<serde_json::Value>,
935        trace: Trace,
936    },
937}
938
939// ═══════════════════════════════════════════
940// Config Types
941// ═══════════════════════════════════════════
942
943#[derive(Debug, Serialize, Deserialize, Clone)]
944pub struct RuntimeConfig {
945    #[serde(default)]
946    pub data_dir: String,
947    #[serde(default, skip_serializing_if = "Option::is_none")]
948    pub rpc_endpoint: Option<String>,
949    #[serde(default, skip_serializing_if = "Option::is_none")]
950    pub rpc_secret: Option<String>,
951    #[serde(default)]
952    pub limits: Vec<SpendLimit>,
953    #[serde(default)]
954    pub log: Vec<String>,
955    #[serde(default, skip_serializing_if = "Option::is_none")]
956    pub exchange_rate: Option<ExchangeRateConfig>,
957    /// Named afpay RPC nodes (e.g. `[afpay_rpc.wallet-server]`).
958    #[serde(default)]
959    pub afpay_rpc: std::collections::HashMap<String, AfpayRpcConfig>,
960    /// Network → afpay_rpc node name (omit = local provider).
961    #[serde(default)]
962    pub providers: std::collections::HashMap<String, String>,
963    /// Storage backend: "redb" (default) or "postgres".
964    #[serde(default, skip_serializing_if = "Option::is_none")]
965    pub storage_backend: Option<String>,
966    /// PostgreSQL connection URL (used when storage_backend = "postgres").
967    #[serde(default, skip_serializing_if = "Option::is_none")]
968    pub postgres_url_secret: Option<String>,
969}
970
971impl Default for RuntimeConfig {
972    fn default() -> Self {
973        Self {
974            data_dir: default_data_dir(),
975            rpc_endpoint: None,
976            rpc_secret: None,
977            limits: vec![],
978            log: vec![],
979            exchange_rate: None,
980            afpay_rpc: std::collections::HashMap::new(),
981            providers: std::collections::HashMap::new(),
982            storage_backend: None,
983            postgres_url_secret: None,
984        }
985    }
986}
987
988fn default_data_dir() -> String {
989    // AFPAY_HOME takes priority, then ~/.afpay
990    if let Some(val) = std::env::var_os("AFPAY_HOME") {
991        return std::path::PathBuf::from(val).to_string_lossy().into_owned();
992    }
993    if let Some(home) = std::env::var_os("HOME") {
994        let mut p = std::path::PathBuf::from(home);
995        p.push(".afpay");
996        p.to_string_lossy().into_owned()
997    } else {
998        ".afpay".to_string()
999    }
1000}
1001
1002#[derive(Debug, Serialize, Deserialize, Clone)]
1003pub struct AfpayRpcConfig {
1004    pub endpoint: String,
1005    #[serde(default, skip_serializing_if = "Option::is_none")]
1006    pub endpoint_secret: Option<String>,
1007}
1008
1009#[derive(Debug, Serialize, Deserialize, Clone)]
1010pub struct ExchangeRateConfig {
1011    #[serde(default = "default_exchange_rate_ttl_s")]
1012    pub ttl_s: u64,
1013    #[serde(default = "default_exchange_rate_sources")]
1014    pub sources: Vec<ExchangeRateSource>,
1015}
1016
1017impl Default for ExchangeRateConfig {
1018    fn default() -> Self {
1019        Self {
1020            ttl_s: default_exchange_rate_ttl_s(),
1021            sources: default_exchange_rate_sources(),
1022        }
1023    }
1024}
1025
1026#[derive(Debug, Serialize, Deserialize, Clone)]
1027pub struct ExchangeRateSource {
1028    #[serde(rename = "type")]
1029    pub source_type: ExchangeRateSourceType,
1030    pub endpoint: String,
1031    #[serde(default, skip_serializing_if = "Option::is_none")]
1032    pub api_key: Option<String>,
1033}
1034
1035#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1036#[serde(rename_all = "snake_case")]
1037pub enum ExchangeRateSourceType {
1038    Generic,
1039    CoinGecko,
1040    Kraken,
1041}
1042
1043fn default_exchange_rate_ttl_s() -> u64 {
1044    300
1045}
1046
1047fn default_exchange_rate_sources() -> Vec<ExchangeRateSource> {
1048    vec![
1049        ExchangeRateSource {
1050            source_type: ExchangeRateSourceType::Kraken,
1051            endpoint: "https://api.kraken.com".to_string(),
1052            api_key: None,
1053        },
1054        ExchangeRateSource {
1055            source_type: ExchangeRateSourceType::CoinGecko,
1056            endpoint: "https://api.coingecko.com/api/v3".to_string(),
1057            api_key: None,
1058        },
1059    ]
1060}
1061
1062#[derive(Debug, Serialize, Deserialize, Default)]
1063pub struct ConfigPatch {
1064    #[serde(default)]
1065    pub data_dir: Option<String>,
1066    #[serde(default)]
1067    pub limits: Option<Vec<SpendLimit>>,
1068    #[serde(default)]
1069    pub log: Option<Vec<String>>,
1070    #[serde(default)]
1071    pub exchange_rate: Option<ExchangeRateConfig>,
1072    #[serde(default)]
1073    pub afpay_rpc: Option<std::collections::HashMap<String, AfpayRpcConfig>>,
1074    #[serde(default)]
1075    pub providers: Option<std::collections::HashMap<String, String>>,
1076}
1077
1078/// Deserializes `local_memo` with backward compatibility.
1079/// Accepts: null → None, "string" → Some({"note": "string"}), {object} → Some(object).
1080fn deserialize_local_memo<'de, D>(d: D) -> Result<Option<BTreeMap<String, String>>, D::Error>
1081where
1082    D: Deserializer<'de>,
1083{
1084    use serde::de;
1085
1086    struct LocalMemoVisitor;
1087
1088    impl<'de> de::Visitor<'de> for LocalMemoVisitor {
1089        type Value = Option<BTreeMap<String, String>>;
1090
1091        fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
1092            f.write_str("null, a string, or a map of string→string")
1093        }
1094
1095        fn visit_none<E: de::Error>(self) -> Result<Self::Value, E> {
1096            Ok(None)
1097        }
1098
1099        fn visit_unit<E: de::Error>(self) -> Result<Self::Value, E> {
1100            Ok(None)
1101        }
1102
1103        fn visit_str<E: de::Error>(self, v: &str) -> Result<Self::Value, E> {
1104            let mut m = BTreeMap::new();
1105            m.insert("note".to_string(), v.to_string());
1106            Ok(Some(m))
1107        }
1108
1109        fn visit_string<E: de::Error>(self, v: String) -> Result<Self::Value, E> {
1110            let mut m = BTreeMap::new();
1111            m.insert("note".to_string(), v);
1112            Ok(Some(m))
1113        }
1114
1115        fn visit_map<A: de::MapAccess<'de>>(self, mut map: A) -> Result<Self::Value, A::Error> {
1116            let mut m = BTreeMap::new();
1117            while let Some((k, v)) = map.next_entry::<String, String>()? {
1118                m.insert(k, v);
1119            }
1120            Ok(Some(m))
1121        }
1122
1123        fn visit_some<D2: Deserializer<'de>>(self, d: D2) -> Result<Self::Value, D2::Error> {
1124            d.deserialize_any(Self)
1125        }
1126    }
1127
1128    d.deserialize_option(LocalMemoVisitor)
1129}
1130
1131/// Returns true if the string looks like a BOLT12 offer (`lno1…`),
1132/// optionally with a `?amount=<sats>` suffix. Case-insensitive.
1133pub fn is_bolt12_offer(s: &str) -> bool {
1134    s.len() >= 4 && s[..4].eq_ignore_ascii_case("lno1")
1135}
1136
1137/// Split a BOLT12 offer string into the raw offer and an optional amount-sats.
1138/// Accepts `lno1...` or `lno1...?amount=1000`. Case-insensitive prefix detection.
1139pub fn parse_bolt12_offer_parts(s: &str) -> (String, Option<u64>) {
1140    if let Some(idx) = s.find("?amount=") {
1141        let offer = s[..idx].to_string();
1142        let amt = s[idx + 8..].parse::<u64>().ok();
1143        (offer, amt)
1144    } else {
1145        (s.to_string(), None)
1146    }
1147}
1148
1149#[cfg(test)]
1150mod tests {
1151    use super::*;
1152
1153    #[test]
1154    fn bolt12_offer_detection() {
1155        assert!(is_bolt12_offer("lno1qgsqvgjwcf6qqz9"));
1156        assert!(is_bolt12_offer("lno1qgsqvgjwcf6qqz9?amount=1000"));
1157        assert!(is_bolt12_offer("LNO1QGSQVGJWCF6QQZ9"));
1158        assert!(is_bolt12_offer("Lno1MixedCase"));
1159        assert!(!is_bolt12_offer("lnbc1qgsqvgjwcf6qqz9"));
1160        assert!(!is_bolt12_offer("lno"));
1161        assert!(!is_bolt12_offer(""));
1162    }
1163
1164    #[test]
1165    fn bolt12_offer_parts_parsing() {
1166        let (offer, amt) = parse_bolt12_offer_parts("lno1abc123");
1167        assert_eq!(offer, "lno1abc123");
1168        assert_eq!(amt, None);
1169
1170        let (offer, amt) = parse_bolt12_offer_parts("lno1abc123?amount=500");
1171        assert_eq!(offer, "lno1abc123");
1172        assert_eq!(amt, Some(500));
1173
1174        let (offer, amt) = parse_bolt12_offer_parts("LNO1ABC?amount=42");
1175        assert_eq!(offer, "LNO1ABC");
1176        assert_eq!(amt, Some(42));
1177    }
1178
1179    #[test]
1180    fn local_only_checks() {
1181        // Already local-only
1182        assert!(Input::WalletShowSeed {
1183            id: "t".into(),
1184            wallet: "w".into(),
1185        }
1186        .is_local_only());
1187
1188        assert!(Input::WalletClose {
1189            id: "t".into(),
1190            wallet: "w".into(),
1191            dangerously_skip_balance_check_and_may_lose_money: true,
1192        }
1193        .is_local_only());
1194
1195        assert!(!Input::WalletClose {
1196            id: "t".into(),
1197            wallet: "w".into(),
1198            dangerously_skip_balance_check_and_may_lose_money: false,
1199        }
1200        .is_local_only());
1201
1202        // Limit write ops
1203        assert!(Input::LimitAdd {
1204            id: "t".into(),
1205            limit: SpendLimit {
1206                rule_id: None,
1207                scope: SpendScope::GlobalUsdCents,
1208                network: None,
1209                wallet: None,
1210                window_s: 3600,
1211                max_spend: 1000,
1212                token: None,
1213            },
1214        }
1215        .is_local_only());
1216
1217        assert!(Input::LimitRemove {
1218            id: "t".into(),
1219            rule_id: "r_1".into(),
1220        }
1221        .is_local_only());
1222
1223        assert!(Input::LimitSet {
1224            id: "t".into(),
1225            limits: vec![],
1226        }
1227        .is_local_only());
1228
1229        // Limit read is NOT local-only
1230        assert!(!Input::LimitList { id: "t".into() }.is_local_only());
1231
1232        // Wallet config write ops
1233        assert!(Input::WalletConfigSet {
1234            id: "t".into(),
1235            wallet: "w".into(),
1236            label: None,
1237            rpc_endpoints: vec![],
1238            chain_id: None,
1239        }
1240        .is_local_only());
1241
1242        assert!(Input::WalletConfigTokenAdd {
1243            id: "t".into(),
1244            wallet: "w".into(),
1245            symbol: "dai".into(),
1246            address: "0x".into(),
1247            decimals: 18,
1248        }
1249        .is_local_only());
1250
1251        assert!(Input::WalletConfigTokenRemove {
1252            id: "t".into(),
1253            wallet: "w".into(),
1254            symbol: "dai".into(),
1255        }
1256        .is_local_only());
1257
1258        // Wallet config read is NOT local-only
1259        assert!(!Input::WalletConfigShow {
1260            id: "t".into(),
1261            wallet: "w".into(),
1262        }
1263        .is_local_only());
1264
1265        // Restore (seed over RPC)
1266        assert!(Input::Restore {
1267            id: "t".into(),
1268            wallet: "w".into(),
1269        }
1270        .is_local_only());
1271    }
1272
1273    #[test]
1274    fn wallet_seed_output_uses_mnemonic_secret_field() {
1275        let out = Output::WalletSeed {
1276            id: "t_1".to_string(),
1277            wallet: "w_1".to_string(),
1278            mnemonic_secret: "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about".to_string(),
1279            trace: Trace::from_duration(0),
1280        };
1281        let value = serde_json::to_value(out).expect("serialize wallet_seed output");
1282        assert_eq!(
1283            value.get("mnemonic_secret").and_then(|v| v.as_str()),
1284            Some(
1285                "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
1286            )
1287        );
1288        assert!(value.get("mnemonic").is_none());
1289    }
1290
1291    #[test]
1292    fn history_list_parses_time_range_fields() {
1293        let json = r#"{
1294            "code": "history",
1295            "id": "t_1",
1296            "wallet": "w_1",
1297            "limit": 10,
1298            "offset": 0,
1299            "since_epoch_s": 1700000000,
1300            "until_epoch_s": 1700100000
1301        }"#;
1302        let input: Input = serde_json::from_str(json).expect("parse history_list with time range");
1303        match input {
1304            Input::HistoryList {
1305                since_epoch_s,
1306                until_epoch_s,
1307                ..
1308            } => {
1309                assert_eq!(since_epoch_s, Some(1_700_000_000));
1310                assert_eq!(until_epoch_s, Some(1_700_100_000));
1311            }
1312            other => panic!("expected HistoryList, got {other:?}"),
1313        }
1314    }
1315
1316    #[test]
1317    fn history_list_time_range_fields_default_to_none() {
1318        let json = r#"{
1319            "code": "history",
1320            "id": "t_1",
1321            "limit": 10,
1322            "offset": 0
1323        }"#;
1324        let input: Input =
1325            serde_json::from_str(json).expect("parse history_list without time range");
1326        match input {
1327            Input::HistoryList {
1328                since_epoch_s,
1329                until_epoch_s,
1330                ..
1331            } => {
1332                assert_eq!(since_epoch_s, None);
1333                assert_eq!(until_epoch_s, None);
1334            }
1335            other => panic!("expected HistoryList, got {other:?}"),
1336        }
1337    }
1338
1339    #[test]
1340    fn history_update_parses_sync_fields() {
1341        let json = r#"{
1342            "code": "history_update",
1343            "id": "t_2",
1344            "wallet": "w_1",
1345            "network": "sol",
1346            "limit": 150
1347        }"#;
1348        let input: Input = serde_json::from_str(json).expect("parse history_update");
1349        match input {
1350            Input::HistoryUpdate {
1351                wallet,
1352                network,
1353                limit,
1354                ..
1355            } => {
1356                assert_eq!(wallet.as_deref(), Some("w_1"));
1357                assert_eq!(network, Some(Network::Sol));
1358                assert_eq!(limit, Some(150));
1359            }
1360            other => panic!("expected HistoryUpdate, got {other:?}"),
1361        }
1362    }
1363
1364    #[test]
1365    fn history_update_fields_default_to_none() {
1366        let json = r#"{
1367            "code": "history_update",
1368            "id": "t_3"
1369        }"#;
1370        let input: Input = serde_json::from_str(json).expect("parse history_update defaults");
1371        match input {
1372            Input::HistoryUpdate {
1373                wallet,
1374                network,
1375                limit,
1376                ..
1377            } => {
1378                assert_eq!(wallet, None);
1379                assert_eq!(network, None);
1380                assert_eq!(limit, None);
1381            }
1382            other => panic!("expected HistoryUpdate, got {other:?}"),
1383        }
1384    }
1385}