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    /// Rate limiting for REST/RPC endpoints.
970    #[serde(default, skip_serializing_if = "Option::is_none")]
971    pub rate_limit: Option<RateLimitConfig>,
972}
973
974impl Default for RuntimeConfig {
975    fn default() -> Self {
976        Self {
977            data_dir: default_data_dir(),
978            rpc_endpoint: None,
979            rpc_secret: None,
980            limits: vec![],
981            log: vec![],
982            exchange_rate: None,
983            afpay_rpc: std::collections::HashMap::new(),
984            providers: std::collections::HashMap::new(),
985            storage_backend: None,
986            postgres_url_secret: None,
987            rate_limit: None,
988        }
989    }
990}
991
992fn default_data_dir() -> String {
993    // AFPAY_HOME takes priority, then ~/.afpay
994    if let Some(val) = std::env::var_os("AFPAY_HOME") {
995        return std::path::PathBuf::from(val).to_string_lossy().into_owned();
996    }
997    if let Some(home) = std::env::var_os("HOME") {
998        let mut p = std::path::PathBuf::from(home);
999        p.push(".afpay");
1000        p.to_string_lossy().into_owned()
1001    } else {
1002        ".afpay".to_string()
1003    }
1004}
1005
1006#[derive(Debug, Serialize, Deserialize, Clone)]
1007pub struct AfpayRpcConfig {
1008    pub endpoint: String,
1009    #[serde(default, skip_serializing_if = "Option::is_none")]
1010    pub endpoint_secret: Option<String>,
1011}
1012
1013#[derive(Debug, Serialize, Deserialize, Clone)]
1014pub struct ExchangeRateConfig {
1015    #[serde(default = "default_exchange_rate_ttl_s")]
1016    pub ttl_s: u64,
1017    #[serde(default = "default_exchange_rate_sources")]
1018    pub sources: Vec<ExchangeRateSource>,
1019}
1020
1021impl Default for ExchangeRateConfig {
1022    fn default() -> Self {
1023        Self {
1024            ttl_s: default_exchange_rate_ttl_s(),
1025            sources: default_exchange_rate_sources(),
1026        }
1027    }
1028}
1029
1030#[derive(Debug, Serialize, Deserialize, Clone)]
1031pub struct ExchangeRateSource {
1032    #[serde(rename = "type")]
1033    pub source_type: ExchangeRateSourceType,
1034    pub endpoint: String,
1035    #[serde(default, skip_serializing_if = "Option::is_none")]
1036    pub api_key: Option<String>,
1037}
1038
1039#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1040#[serde(rename_all = "snake_case")]
1041pub enum ExchangeRateSourceType {
1042    Generic,
1043    CoinGecko,
1044    Kraken,
1045}
1046
1047/// Rate limiting configuration for REST/RPC endpoints.
1048///
1049/// ```toml
1050/// [rate_limit]
1051/// requests_per_second = 20
1052/// max_concurrent = 50
1053/// ```
1054#[derive(Debug, Serialize, Deserialize, Clone)]
1055pub struct RateLimitConfig {
1056    /// Maximum requests per second (token-bucket refill rate). 0 = unlimited.
1057    #[serde(default = "default_rate_limit_rps")]
1058    pub requests_per_second: u32,
1059    /// Maximum concurrent in-flight requests. 0 = unlimited.
1060    #[serde(default = "default_rate_limit_concurrent")]
1061    pub max_concurrent: u32,
1062}
1063
1064impl Default for RateLimitConfig {
1065    fn default() -> Self {
1066        Self {
1067            requests_per_second: default_rate_limit_rps(),
1068            max_concurrent: default_rate_limit_concurrent(),
1069        }
1070    }
1071}
1072
1073fn default_rate_limit_rps() -> u32 {
1074    20
1075}
1076
1077fn default_rate_limit_concurrent() -> u32 {
1078    50
1079}
1080
1081fn default_exchange_rate_ttl_s() -> u64 {
1082    300
1083}
1084
1085fn default_exchange_rate_sources() -> Vec<ExchangeRateSource> {
1086    vec![
1087        ExchangeRateSource {
1088            source_type: ExchangeRateSourceType::Kraken,
1089            endpoint: "https://api.kraken.com".to_string(),
1090            api_key: None,
1091        },
1092        ExchangeRateSource {
1093            source_type: ExchangeRateSourceType::CoinGecko,
1094            endpoint: "https://api.coingecko.com/api/v3".to_string(),
1095            api_key: None,
1096        },
1097    ]
1098}
1099
1100#[derive(Debug, Serialize, Deserialize, Default)]
1101pub struct ConfigPatch {
1102    #[serde(default)]
1103    pub data_dir: Option<String>,
1104    #[serde(default)]
1105    pub limits: Option<Vec<SpendLimit>>,
1106    #[serde(default)]
1107    pub log: Option<Vec<String>>,
1108    #[serde(default)]
1109    pub exchange_rate: Option<ExchangeRateConfig>,
1110    #[serde(default)]
1111    pub afpay_rpc: Option<std::collections::HashMap<String, AfpayRpcConfig>>,
1112    #[serde(default)]
1113    pub providers: Option<std::collections::HashMap<String, String>>,
1114}
1115
1116/// Deserializes `local_memo` with backward compatibility.
1117/// Accepts: null → None, "string" → Some({"note": "string"}), {object} → Some(object).
1118fn deserialize_local_memo<'de, D>(d: D) -> Result<Option<BTreeMap<String, String>>, D::Error>
1119where
1120    D: Deserializer<'de>,
1121{
1122    use serde::de;
1123
1124    struct LocalMemoVisitor;
1125
1126    impl<'de> de::Visitor<'de> for LocalMemoVisitor {
1127        type Value = Option<BTreeMap<String, String>>;
1128
1129        fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
1130            f.write_str("null, a string, or a map of string→string")
1131        }
1132
1133        fn visit_none<E: de::Error>(self) -> Result<Self::Value, E> {
1134            Ok(None)
1135        }
1136
1137        fn visit_unit<E: de::Error>(self) -> Result<Self::Value, E> {
1138            Ok(None)
1139        }
1140
1141        fn visit_str<E: de::Error>(self, v: &str) -> Result<Self::Value, E> {
1142            let mut m = BTreeMap::new();
1143            m.insert("note".to_string(), v.to_string());
1144            Ok(Some(m))
1145        }
1146
1147        fn visit_string<E: de::Error>(self, v: String) -> Result<Self::Value, E> {
1148            let mut m = BTreeMap::new();
1149            m.insert("note".to_string(), v);
1150            Ok(Some(m))
1151        }
1152
1153        fn visit_map<A: de::MapAccess<'de>>(self, mut map: A) -> Result<Self::Value, A::Error> {
1154            let mut m = BTreeMap::new();
1155            while let Some((k, v)) = map.next_entry::<String, String>()? {
1156                m.insert(k, v);
1157            }
1158            Ok(Some(m))
1159        }
1160
1161        fn visit_some<D2: Deserializer<'de>>(self, d: D2) -> Result<Self::Value, D2::Error> {
1162            d.deserialize_any(Self)
1163        }
1164    }
1165
1166    d.deserialize_option(LocalMemoVisitor)
1167}
1168
1169/// Returns true if the string looks like a BOLT12 offer (`lno1…`),
1170/// optionally with a `?amount=<sats>` suffix. Case-insensitive.
1171pub fn is_bolt12_offer(s: &str) -> bool {
1172    s.len() >= 4 && s[..4].eq_ignore_ascii_case("lno1")
1173}
1174
1175/// Split a BOLT12 offer string into the raw offer and an optional amount-sats.
1176/// Accepts `lno1...` or `lno1...?amount=1000`. Case-insensitive prefix detection.
1177pub fn parse_bolt12_offer_parts(s: &str) -> (String, Option<u64>) {
1178    if let Some(idx) = s.find("?amount=") {
1179        let offer = s[..idx].to_string();
1180        let amt = s[idx + 8..].parse::<u64>().ok();
1181        (offer, amt)
1182    } else {
1183        (s.to_string(), None)
1184    }
1185}
1186
1187#[cfg(test)]
1188mod tests {
1189    use super::*;
1190
1191    #[test]
1192    fn bolt12_offer_detection() {
1193        assert!(is_bolt12_offer("lno1qgsqvgjwcf6qqz9"));
1194        assert!(is_bolt12_offer("lno1qgsqvgjwcf6qqz9?amount=1000"));
1195        assert!(is_bolt12_offer("LNO1QGSQVGJWCF6QQZ9"));
1196        assert!(is_bolt12_offer("Lno1MixedCase"));
1197        assert!(!is_bolt12_offer("lnbc1qgsqvgjwcf6qqz9"));
1198        assert!(!is_bolt12_offer("lno"));
1199        assert!(!is_bolt12_offer(""));
1200    }
1201
1202    #[test]
1203    fn bolt12_offer_parts_parsing() {
1204        let (offer, amt) = parse_bolt12_offer_parts("lno1abc123");
1205        assert_eq!(offer, "lno1abc123");
1206        assert_eq!(amt, None);
1207
1208        let (offer, amt) = parse_bolt12_offer_parts("lno1abc123?amount=500");
1209        assert_eq!(offer, "lno1abc123");
1210        assert_eq!(amt, Some(500));
1211
1212        let (offer, amt) = parse_bolt12_offer_parts("LNO1ABC?amount=42");
1213        assert_eq!(offer, "LNO1ABC");
1214        assert_eq!(amt, Some(42));
1215    }
1216
1217    #[test]
1218    fn local_only_checks() {
1219        // Already local-only
1220        assert!(Input::WalletShowSeed {
1221            id: "t".into(),
1222            wallet: "w".into(),
1223        }
1224        .is_local_only());
1225
1226        assert!(Input::WalletClose {
1227            id: "t".into(),
1228            wallet: "w".into(),
1229            dangerously_skip_balance_check_and_may_lose_money: true,
1230        }
1231        .is_local_only());
1232
1233        assert!(!Input::WalletClose {
1234            id: "t".into(),
1235            wallet: "w".into(),
1236            dangerously_skip_balance_check_and_may_lose_money: false,
1237        }
1238        .is_local_only());
1239
1240        // Limit write ops
1241        assert!(Input::LimitAdd {
1242            id: "t".into(),
1243            limit: SpendLimit {
1244                rule_id: None,
1245                scope: SpendScope::GlobalUsdCents,
1246                network: None,
1247                wallet: None,
1248                window_s: 3600,
1249                max_spend: 1000,
1250                token: None,
1251            },
1252        }
1253        .is_local_only());
1254
1255        assert!(Input::LimitRemove {
1256            id: "t".into(),
1257            rule_id: "r_1".into(),
1258        }
1259        .is_local_only());
1260
1261        assert!(Input::LimitSet {
1262            id: "t".into(),
1263            limits: vec![],
1264        }
1265        .is_local_only());
1266
1267        // Limit read is NOT local-only
1268        assert!(!Input::LimitList { id: "t".into() }.is_local_only());
1269
1270        // Wallet config write ops
1271        assert!(Input::WalletConfigSet {
1272            id: "t".into(),
1273            wallet: "w".into(),
1274            label: None,
1275            rpc_endpoints: vec![],
1276            chain_id: None,
1277        }
1278        .is_local_only());
1279
1280        assert!(Input::WalletConfigTokenAdd {
1281            id: "t".into(),
1282            wallet: "w".into(),
1283            symbol: "dai".into(),
1284            address: "0x".into(),
1285            decimals: 18,
1286        }
1287        .is_local_only());
1288
1289        assert!(Input::WalletConfigTokenRemove {
1290            id: "t".into(),
1291            wallet: "w".into(),
1292            symbol: "dai".into(),
1293        }
1294        .is_local_only());
1295
1296        // Wallet config read is NOT local-only
1297        assert!(!Input::WalletConfigShow {
1298            id: "t".into(),
1299            wallet: "w".into(),
1300        }
1301        .is_local_only());
1302
1303        // Restore (seed over RPC)
1304        assert!(Input::Restore {
1305            id: "t".into(),
1306            wallet: "w".into(),
1307        }
1308        .is_local_only());
1309    }
1310
1311    #[test]
1312    fn wallet_seed_output_uses_mnemonic_secret_field() {
1313        let out = Output::WalletSeed {
1314            id: "t_1".to_string(),
1315            wallet: "w_1".to_string(),
1316            mnemonic_secret: "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about".to_string(),
1317            trace: Trace::from_duration(0),
1318        };
1319        let value = serde_json::to_value(out).expect("serialize wallet_seed output");
1320        assert_eq!(
1321            value.get("mnemonic_secret").and_then(|v| v.as_str()),
1322            Some(
1323                "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
1324            )
1325        );
1326        assert!(value.get("mnemonic").is_none());
1327    }
1328
1329    #[test]
1330    fn history_list_parses_time_range_fields() {
1331        let json = r#"{
1332            "code": "history",
1333            "id": "t_1",
1334            "wallet": "w_1",
1335            "limit": 10,
1336            "offset": 0,
1337            "since_epoch_s": 1700000000,
1338            "until_epoch_s": 1700100000
1339        }"#;
1340        let input: Input = serde_json::from_str(json).expect("parse history_list with time range");
1341        match input {
1342            Input::HistoryList {
1343                since_epoch_s,
1344                until_epoch_s,
1345                ..
1346            } => {
1347                assert_eq!(since_epoch_s, Some(1_700_000_000));
1348                assert_eq!(until_epoch_s, Some(1_700_100_000));
1349            }
1350            other => panic!("expected HistoryList, got {other:?}"),
1351        }
1352    }
1353
1354    #[test]
1355    fn history_list_time_range_fields_default_to_none() {
1356        let json = r#"{
1357            "code": "history",
1358            "id": "t_1",
1359            "limit": 10,
1360            "offset": 0
1361        }"#;
1362        let input: Input =
1363            serde_json::from_str(json).expect("parse history_list without time range");
1364        match input {
1365            Input::HistoryList {
1366                since_epoch_s,
1367                until_epoch_s,
1368                ..
1369            } => {
1370                assert_eq!(since_epoch_s, None);
1371                assert_eq!(until_epoch_s, None);
1372            }
1373            other => panic!("expected HistoryList, got {other:?}"),
1374        }
1375    }
1376
1377    #[test]
1378    fn history_update_parses_sync_fields() {
1379        let json = r#"{
1380            "code": "history_update",
1381            "id": "t_2",
1382            "wallet": "w_1",
1383            "network": "sol",
1384            "limit": 150
1385        }"#;
1386        let input: Input = serde_json::from_str(json).expect("parse history_update");
1387        match input {
1388            Input::HistoryUpdate {
1389                wallet,
1390                network,
1391                limit,
1392                ..
1393            } => {
1394                assert_eq!(wallet.as_deref(), Some("w_1"));
1395                assert_eq!(network, Some(Network::Sol));
1396                assert_eq!(limit, Some(150));
1397            }
1398            other => panic!("expected HistoryUpdate, got {other:?}"),
1399        }
1400    }
1401
1402    #[test]
1403    fn history_update_fields_default_to_none() {
1404        let json = r#"{
1405            "code": "history_update",
1406            "id": "t_3"
1407        }"#;
1408        let input: Input = serde_json::from_str(json).expect("parse history_update defaults");
1409        match input {
1410            Input::HistoryUpdate {
1411                wallet,
1412                network,
1413                limit,
1414                ..
1415            } => {
1416                assert_eq!(wallet, None);
1417                assert_eq!(network, None);
1418                assert_eq!(limit, None);
1419            }
1420            other => panic!("expected HistoryUpdate, got {other:?}"),
1421        }
1422    }
1423}