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