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        write_qr_svg_file: bool,
520        #[serde(default, skip_serializing_if = "Option::is_none")]
521        min_confirmations: Option<u32>,
522    },
523    #[serde(rename = "receive_claim")]
524    ReceiveClaim {
525        id: String,
526        wallet: String,
527        quote_id: String,
528    },
529
530    #[serde(rename = "cashu_send")]
531    CashuSend {
532        id: String,
533        #[serde(default)]
534        wallet: Option<String>,
535        amount: Amount,
536        #[serde(default)]
537        onchain_memo: Option<String>,
538        #[serde(default, deserialize_with = "deserialize_local_memo")]
539        local_memo: Option<BTreeMap<String, String>>,
540        /// Restrict to wallets on these mints (tried in order).
541        #[serde(default, skip_serializing_if = "Option::is_none")]
542        mints: Option<Vec<String>>,
543    },
544    #[serde(rename = "cashu_receive")]
545    CashuReceive {
546        id: String,
547        #[serde(default)]
548        wallet: Option<String>,
549        token: String,
550    },
551    #[serde(rename = "send")]
552    Send {
553        id: String,
554        #[serde(default)]
555        wallet: Option<String>,
556        #[serde(default, skip_serializing_if = "Option::is_none")]
557        network: Option<Network>,
558        to: String,
559        #[serde(default)]
560        onchain_memo: Option<String>,
561        #[serde(default, deserialize_with = "deserialize_local_memo")]
562        local_memo: Option<BTreeMap<String, String>>,
563        /// Restrict to wallets on these mints (cashu only).
564        #[serde(default, skip_serializing_if = "Option::is_none")]
565        mints: Option<Vec<String>>,
566    },
567
568    #[serde(rename = "restore")]
569    Restore { id: String, wallet: String },
570    #[serde(rename = "local_wallet_show_seed")]
571    WalletShowSeed { id: String, wallet: String },
572
573    #[serde(rename = "history")]
574    HistoryList {
575        id: String,
576        #[serde(default)]
577        wallet: Option<String>,
578        #[serde(default, skip_serializing_if = "Option::is_none")]
579        network: Option<Network>,
580        #[serde(default, skip_serializing_if = "Option::is_none")]
581        onchain_memo: Option<String>,
582        #[serde(default)]
583        limit: Option<usize>,
584        #[serde(default)]
585        offset: Option<usize>,
586        /// Only include records created at or after this epoch second.
587        #[serde(default, skip_serializing_if = "Option::is_none")]
588        since_epoch_s: Option<u64>,
589        /// Only include records created before this epoch second.
590        #[serde(default, skip_serializing_if = "Option::is_none")]
591        until_epoch_s: Option<u64>,
592    },
593    #[serde(rename = "history_status")]
594    HistoryStatus { id: String, transaction_id: String },
595    #[serde(rename = "history_update")]
596    HistoryUpdate {
597        id: String,
598        #[serde(default)]
599        wallet: Option<String>,
600        #[serde(default, skip_serializing_if = "Option::is_none")]
601        network: Option<Network>,
602        #[serde(default)]
603        limit: Option<usize>,
604    },
605
606    #[serde(rename = "limit_add")]
607    LimitAdd { id: String, limit: SpendLimit },
608    #[serde(rename = "limit_remove")]
609    LimitRemove { id: String, rule_id: String },
610    #[serde(rename = "limit_list")]
611    LimitList { id: String },
612    #[serde(rename = "limit_set")]
613    LimitSet { id: String, limits: Vec<SpendLimit> },
614
615    #[serde(rename = "wallet_config_show")]
616    WalletConfigShow { id: String, wallet: String },
617    #[serde(rename = "wallet_config_set")]
618    WalletConfigSet {
619        id: String,
620        wallet: String,
621        #[serde(default, skip_serializing_if = "Option::is_none")]
622        label: Option<String>,
623        #[serde(default, skip_serializing_if = "Vec::is_empty")]
624        rpc_endpoints: Vec<String>,
625        #[serde(default, skip_serializing_if = "Option::is_none")]
626        chain_id: Option<u64>,
627    },
628    #[serde(rename = "wallet_config_token_add")]
629    WalletConfigTokenAdd {
630        id: String,
631        wallet: String,
632        symbol: String,
633        address: String,
634        decimals: u8,
635    },
636    #[serde(rename = "wallet_config_token_remove")]
637    WalletConfigTokenRemove {
638        id: String,
639        wallet: String,
640        symbol: String,
641    },
642
643    #[serde(rename = "config")]
644    Config(ConfigPatch),
645    #[serde(rename = "version")]
646    Version,
647    #[serde(rename = "close")]
648    Close,
649}
650
651impl Input {
652    /// Returns true if this input must only be handled locally (never via RPC).
653    pub fn is_local_only(&self) -> bool {
654        matches!(
655            self,
656            Input::WalletShowSeed { .. }
657                | Input::WalletClose {
658                    dangerously_skip_balance_check_and_may_lose_money: true,
659                    ..
660                }
661                | Input::LimitAdd { .. }
662                | Input::LimitRemove { .. }
663                | Input::LimitSet { .. }
664                | Input::WalletConfigSet { .. }
665                | Input::WalletConfigTokenAdd { .. }
666                | Input::WalletConfigTokenRemove { .. }
667                | Input::Restore { .. }
668                | Input::Config(_)
669        )
670    }
671}
672
673// ═══════════════════════════════════════════
674// Output (Responses)
675// ═══════════════════════════════════════════
676
677#[derive(Debug, Serialize)]
678#[serde(tag = "code")]
679pub enum Output {
680    #[serde(rename = "wallet_created")]
681    WalletCreated {
682        id: String,
683        wallet: String,
684        network: Network,
685        address: String,
686        #[serde(skip_serializing_if = "Option::is_none")]
687        mnemonic: Option<String>,
688        trace: Trace,
689    },
690    #[serde(rename = "wallet_closed")]
691    WalletClosed {
692        id: String,
693        wallet: String,
694        trace: Trace,
695    },
696    #[serde(rename = "wallet_list")]
697    WalletList {
698        id: String,
699        wallets: Vec<WalletSummary>,
700        trace: Trace,
701    },
702    #[serde(rename = "wallet_balances")]
703    WalletBalances {
704        id: String,
705        wallets: Vec<WalletBalanceItem>,
706        trace: Trace,
707    },
708    #[serde(rename = "receive_info")]
709    ReceiveInfo {
710        id: String,
711        wallet: String,
712        receive_info: ReceiveInfo,
713        trace: Trace,
714    },
715    #[serde(rename = "receive_claimed")]
716    ReceiveClaimed {
717        id: String,
718        wallet: String,
719        amount: Amount,
720        trace: Trace,
721    },
722
723    #[serde(rename = "cashu_sent")]
724    CashuSent {
725        id: String,
726        wallet: String,
727        transaction_id: String,
728        status: TxStatus,
729        #[serde(skip_serializing_if = "Option::is_none")]
730        fee: Option<Amount>,
731        token: String,
732        trace: Trace,
733    },
734
735    #[serde(rename = "history")]
736    History {
737        id: String,
738        items: Vec<HistoryRecord>,
739        trace: Trace,
740    },
741    #[serde(rename = "history_status")]
742    HistoryStatus {
743        id: String,
744        transaction_id: String,
745        status: TxStatus,
746        #[serde(skip_serializing_if = "Option::is_none")]
747        confirmations: Option<u32>,
748        #[serde(skip_serializing_if = "Option::is_none")]
749        preimage: Option<String>,
750        #[serde(skip_serializing_if = "Option::is_none")]
751        item: Option<HistoryRecord>,
752        trace: Trace,
753    },
754    #[serde(rename = "history_updated")]
755    HistoryUpdated {
756        id: String,
757        wallets_synced: usize,
758        records_scanned: usize,
759        records_added: usize,
760        records_updated: usize,
761        trace: Trace,
762    },
763
764    #[serde(rename = "limit_added")]
765    LimitAdded {
766        id: String,
767        rule_id: String,
768        trace: Trace,
769    },
770    #[serde(rename = "limit_removed")]
771    LimitRemoved {
772        id: String,
773        rule_id: String,
774        trace: Trace,
775    },
776    #[serde(rename = "limit_status")]
777    LimitStatus {
778        id: String,
779        limits: Vec<SpendLimitStatus>,
780        #[serde(default, skip_serializing_if = "Vec::is_empty")]
781        downstream: Vec<DownstreamLimitNode>,
782        trace: Trace,
783    },
784    #[serde(rename = "limit_exceeded")]
785    #[allow(dead_code)]
786    LimitExceeded {
787        id: String,
788        rule_id: String,
789        scope: SpendScope,
790        scope_key: String,
791        spent: u64,
792        max_spend: u64,
793        #[serde(skip_serializing_if = "Option::is_none")]
794        token: Option<String>,
795        remaining_s: u64,
796        #[serde(skip_serializing_if = "Option::is_none")]
797        origin: Option<String>,
798        trace: Trace,
799    },
800
801    #[serde(rename = "cashu_received")]
802    CashuReceived {
803        id: String,
804        wallet: String,
805        amount: Amount,
806        trace: Trace,
807    },
808    #[serde(rename = "restored")]
809    Restored {
810        id: String,
811        wallet: String,
812        unspent: u64,
813        spent: u64,
814        pending: u64,
815        unit: String,
816        trace: Trace,
817    },
818    #[serde(rename = "wallet_seed")]
819    WalletSeed {
820        id: String,
821        wallet: String,
822        mnemonic_secret: String,
823        trace: Trace,
824    },
825
826    #[serde(rename = "sent")]
827    Sent {
828        id: String,
829        wallet: String,
830        transaction_id: String,
831        amount: Amount,
832        #[serde(skip_serializing_if = "Option::is_none")]
833        fee: Option<Amount>,
834        #[serde(skip_serializing_if = "Option::is_none")]
835        preimage: Option<String>,
836        trace: Trace,
837    },
838
839    #[serde(rename = "wallet_config")]
840    WalletConfig {
841        id: String,
842        wallet: String,
843        config: WalletMetadata,
844        trace: Trace,
845    },
846    #[serde(rename = "wallet_config_updated")]
847    WalletConfigUpdated {
848        id: String,
849        wallet: String,
850        trace: Trace,
851    },
852    #[serde(rename = "wallet_config_token_added")]
853    WalletConfigTokenAdded {
854        id: String,
855        wallet: String,
856        symbol: String,
857        address: String,
858        decimals: u8,
859        trace: Trace,
860    },
861    #[serde(rename = "wallet_config_token_removed")]
862    WalletConfigTokenRemoved {
863        id: String,
864        wallet: String,
865        symbol: String,
866        trace: Trace,
867    },
868
869    #[serde(rename = "error")]
870    Error {
871        #[serde(skip_serializing_if = "Option::is_none")]
872        id: Option<String>,
873        error_code: String,
874        error: String,
875        #[serde(skip_serializing_if = "Option::is_none")]
876        hint: Option<String>,
877        retryable: bool,
878        trace: Trace,
879    },
880
881    #[serde(rename = "dry_run")]
882    DryRun {
883        #[serde(skip_serializing_if = "Option::is_none")]
884        id: Option<String>,
885        command: String,
886        params: serde_json::Value,
887        trace: Trace,
888    },
889
890    #[serde(rename = "config")]
891    Config(RuntimeConfig),
892    #[serde(rename = "version")]
893    Version { version: String, trace: PongTrace },
894    #[serde(rename = "close")]
895    Close { message: String, trace: CloseTrace },
896    #[serde(rename = "log")]
897    Log {
898        event: String,
899        #[serde(skip_serializing_if = "Option::is_none")]
900        request_id: Option<String>,
901        #[serde(skip_serializing_if = "Option::is_none")]
902        version: Option<String>,
903        #[serde(skip_serializing_if = "Option::is_none")]
904        argv: Option<Vec<String>>,
905        #[serde(skip_serializing_if = "Option::is_none")]
906        config: Option<serde_json::Value>,
907        #[serde(skip_serializing_if = "Option::is_none")]
908        args: Option<serde_json::Value>,
909        #[serde(skip_serializing_if = "Option::is_none")]
910        env: Option<serde_json::Value>,
911        trace: Trace,
912    },
913}
914
915// ═══════════════════════════════════════════
916// Config Types
917// ═══════════════════════════════════════════
918
919#[derive(Debug, Serialize, Deserialize, Clone)]
920pub struct RuntimeConfig {
921    #[serde(default)]
922    pub data_dir: String,
923    #[serde(default, skip_serializing_if = "Option::is_none")]
924    pub rpc_endpoint: Option<String>,
925    #[serde(default, skip_serializing_if = "Option::is_none")]
926    pub rpc_secret: Option<String>,
927    #[serde(default)]
928    pub limits: Vec<SpendLimit>,
929    #[serde(default)]
930    pub log: Vec<String>,
931    #[serde(default, skip_serializing_if = "Option::is_none")]
932    pub exchange_rate: Option<ExchangeRateConfig>,
933    /// Named afpay RPC nodes (e.g. `[afpay_rpc.wallet-server]`).
934    #[serde(default)]
935    pub afpay_rpc: std::collections::HashMap<String, AfpayRpcConfig>,
936    /// Network → afpay_rpc node name (omit = local provider).
937    #[serde(default)]
938    pub providers: std::collections::HashMap<String, String>,
939    /// Storage backend: "redb" (default) or "postgres".
940    #[serde(default, skip_serializing_if = "Option::is_none")]
941    pub storage_backend: Option<String>,
942    /// PostgreSQL connection URL (used when storage_backend = "postgres").
943    #[serde(default, skip_serializing_if = "Option::is_none")]
944    pub postgres_url_secret: Option<String>,
945}
946
947impl Default for RuntimeConfig {
948    fn default() -> Self {
949        Self {
950            data_dir: default_data_dir(),
951            rpc_endpoint: None,
952            rpc_secret: None,
953            limits: vec![],
954            log: vec![],
955            exchange_rate: None,
956            afpay_rpc: std::collections::HashMap::new(),
957            providers: std::collections::HashMap::new(),
958            storage_backend: None,
959            postgres_url_secret: None,
960        }
961    }
962}
963
964fn default_data_dir() -> String {
965    // AFPAY_HOME takes priority, then ~/.afpay
966    if let Some(val) = std::env::var_os("AFPAY_HOME") {
967        return std::path::PathBuf::from(val).to_string_lossy().into_owned();
968    }
969    if let Some(home) = std::env::var_os("HOME") {
970        let mut p = std::path::PathBuf::from(home);
971        p.push(".afpay");
972        p.to_string_lossy().into_owned()
973    } else {
974        ".afpay".to_string()
975    }
976}
977
978#[derive(Debug, Serialize, Deserialize, Clone)]
979pub struct AfpayRpcConfig {
980    pub endpoint: String,
981    #[serde(default, skip_serializing_if = "Option::is_none")]
982    pub endpoint_secret: Option<String>,
983}
984
985#[derive(Debug, Serialize, Deserialize, Clone)]
986pub struct ExchangeRateConfig {
987    #[serde(default = "default_exchange_rate_ttl_s")]
988    pub ttl_s: u64,
989    #[serde(default = "default_exchange_rate_sources")]
990    pub sources: Vec<ExchangeRateSource>,
991}
992
993impl Default for ExchangeRateConfig {
994    fn default() -> Self {
995        Self {
996            ttl_s: default_exchange_rate_ttl_s(),
997            sources: default_exchange_rate_sources(),
998        }
999    }
1000}
1001
1002#[derive(Debug, Serialize, Deserialize, Clone)]
1003pub struct ExchangeRateSource {
1004    #[serde(rename = "type")]
1005    pub source_type: ExchangeRateSourceType,
1006    pub endpoint: String,
1007    #[serde(default, skip_serializing_if = "Option::is_none")]
1008    pub api_key: Option<String>,
1009}
1010
1011#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1012#[serde(rename_all = "snake_case")]
1013pub enum ExchangeRateSourceType {
1014    Generic,
1015    CoinGecko,
1016    Kraken,
1017}
1018
1019fn default_exchange_rate_ttl_s() -> u64 {
1020    300
1021}
1022
1023fn default_exchange_rate_sources() -> Vec<ExchangeRateSource> {
1024    vec![
1025        ExchangeRateSource {
1026            source_type: ExchangeRateSourceType::Kraken,
1027            endpoint: "https://api.kraken.com".to_string(),
1028            api_key: None,
1029        },
1030        ExchangeRateSource {
1031            source_type: ExchangeRateSourceType::CoinGecko,
1032            endpoint: "https://api.coingecko.com/api/v3".to_string(),
1033            api_key: None,
1034        },
1035    ]
1036}
1037
1038#[derive(Debug, Serialize, Deserialize, Default)]
1039pub struct ConfigPatch {
1040    #[serde(default)]
1041    pub data_dir: Option<String>,
1042    #[serde(default)]
1043    pub limits: Option<Vec<SpendLimit>>,
1044    #[serde(default)]
1045    pub log: Option<Vec<String>>,
1046    #[serde(default)]
1047    pub exchange_rate: Option<ExchangeRateConfig>,
1048    #[serde(default)]
1049    pub afpay_rpc: Option<std::collections::HashMap<String, AfpayRpcConfig>>,
1050    #[serde(default)]
1051    pub providers: Option<std::collections::HashMap<String, String>>,
1052}
1053
1054/// Deserializes `local_memo` with backward compatibility.
1055/// Accepts: null → None, "string" → Some({"note": "string"}), {object} → Some(object).
1056fn deserialize_local_memo<'de, D>(d: D) -> Result<Option<BTreeMap<String, String>>, D::Error>
1057where
1058    D: Deserializer<'de>,
1059{
1060    use serde::de;
1061
1062    struct LocalMemoVisitor;
1063
1064    impl<'de> de::Visitor<'de> for LocalMemoVisitor {
1065        type Value = Option<BTreeMap<String, String>>;
1066
1067        fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
1068            f.write_str("null, a string, or a map of string→string")
1069        }
1070
1071        fn visit_none<E: de::Error>(self) -> Result<Self::Value, E> {
1072            Ok(None)
1073        }
1074
1075        fn visit_unit<E: de::Error>(self) -> Result<Self::Value, E> {
1076            Ok(None)
1077        }
1078
1079        fn visit_str<E: de::Error>(self, v: &str) -> Result<Self::Value, E> {
1080            let mut m = BTreeMap::new();
1081            m.insert("note".to_string(), v.to_string());
1082            Ok(Some(m))
1083        }
1084
1085        fn visit_string<E: de::Error>(self, v: String) -> Result<Self::Value, E> {
1086            let mut m = BTreeMap::new();
1087            m.insert("note".to_string(), v);
1088            Ok(Some(m))
1089        }
1090
1091        fn visit_map<A: de::MapAccess<'de>>(self, mut map: A) -> Result<Self::Value, A::Error> {
1092            let mut m = BTreeMap::new();
1093            while let Some((k, v)) = map.next_entry::<String, String>()? {
1094                m.insert(k, v);
1095            }
1096            Ok(Some(m))
1097        }
1098
1099        fn visit_some<D2: Deserializer<'de>>(self, d: D2) -> Result<Self::Value, D2::Error> {
1100            d.deserialize_any(Self)
1101        }
1102    }
1103
1104    d.deserialize_option(LocalMemoVisitor)
1105}
1106
1107#[cfg(test)]
1108mod tests {
1109    use super::*;
1110
1111    #[test]
1112    fn local_only_checks() {
1113        // Already local-only
1114        assert!(Input::WalletShowSeed {
1115            id: "t".into(),
1116            wallet: "w".into(),
1117        }
1118        .is_local_only());
1119
1120        assert!(Input::WalletClose {
1121            id: "t".into(),
1122            wallet: "w".into(),
1123            dangerously_skip_balance_check_and_may_lose_money: true,
1124        }
1125        .is_local_only());
1126
1127        assert!(!Input::WalletClose {
1128            id: "t".into(),
1129            wallet: "w".into(),
1130            dangerously_skip_balance_check_and_may_lose_money: false,
1131        }
1132        .is_local_only());
1133
1134        // Limit write ops
1135        assert!(Input::LimitAdd {
1136            id: "t".into(),
1137            limit: SpendLimit {
1138                rule_id: None,
1139                scope: SpendScope::GlobalUsdCents,
1140                network: None,
1141                wallet: None,
1142                window_s: 3600,
1143                max_spend: 1000,
1144                token: None,
1145            },
1146        }
1147        .is_local_only());
1148
1149        assert!(Input::LimitRemove {
1150            id: "t".into(),
1151            rule_id: "r_1".into(),
1152        }
1153        .is_local_only());
1154
1155        assert!(Input::LimitSet {
1156            id: "t".into(),
1157            limits: vec![],
1158        }
1159        .is_local_only());
1160
1161        // Limit read is NOT local-only
1162        assert!(!Input::LimitList { id: "t".into() }.is_local_only());
1163
1164        // Wallet config write ops
1165        assert!(Input::WalletConfigSet {
1166            id: "t".into(),
1167            wallet: "w".into(),
1168            label: None,
1169            rpc_endpoints: vec![],
1170            chain_id: None,
1171        }
1172        .is_local_only());
1173
1174        assert!(Input::WalletConfigTokenAdd {
1175            id: "t".into(),
1176            wallet: "w".into(),
1177            symbol: "dai".into(),
1178            address: "0x".into(),
1179            decimals: 18,
1180        }
1181        .is_local_only());
1182
1183        assert!(Input::WalletConfigTokenRemove {
1184            id: "t".into(),
1185            wallet: "w".into(),
1186            symbol: "dai".into(),
1187        }
1188        .is_local_only());
1189
1190        // Wallet config read is NOT local-only
1191        assert!(!Input::WalletConfigShow {
1192            id: "t".into(),
1193            wallet: "w".into(),
1194        }
1195        .is_local_only());
1196
1197        // Restore (seed over RPC)
1198        assert!(Input::Restore {
1199            id: "t".into(),
1200            wallet: "w".into(),
1201        }
1202        .is_local_only());
1203    }
1204
1205    #[test]
1206    fn wallet_seed_output_uses_mnemonic_secret_field() {
1207        let out = Output::WalletSeed {
1208            id: "t_1".to_string(),
1209            wallet: "w_1".to_string(),
1210            mnemonic_secret: "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about".to_string(),
1211            trace: Trace::from_duration(0),
1212        };
1213        let value = serde_json::to_value(out).expect("serialize wallet_seed output");
1214        assert_eq!(
1215            value.get("mnemonic_secret").and_then(|v| v.as_str()),
1216            Some(
1217                "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
1218            )
1219        );
1220        assert!(value.get("mnemonic").is_none());
1221    }
1222
1223    #[test]
1224    fn history_list_parses_time_range_fields() {
1225        let json = r#"{
1226            "code": "history",
1227            "id": "t_1",
1228            "wallet": "w_1",
1229            "limit": 10,
1230            "offset": 0,
1231            "since_epoch_s": 1700000000,
1232            "until_epoch_s": 1700100000
1233        }"#;
1234        let input: Input = serde_json::from_str(json).expect("parse history_list with time range");
1235        match input {
1236            Input::HistoryList {
1237                since_epoch_s,
1238                until_epoch_s,
1239                ..
1240            } => {
1241                assert_eq!(since_epoch_s, Some(1_700_000_000));
1242                assert_eq!(until_epoch_s, Some(1_700_100_000));
1243            }
1244            other => panic!("expected HistoryList, got {other:?}"),
1245        }
1246    }
1247
1248    #[test]
1249    fn history_list_time_range_fields_default_to_none() {
1250        let json = r#"{
1251            "code": "history",
1252            "id": "t_1",
1253            "limit": 10,
1254            "offset": 0
1255        }"#;
1256        let input: Input =
1257            serde_json::from_str(json).expect("parse history_list without time range");
1258        match input {
1259            Input::HistoryList {
1260                since_epoch_s,
1261                until_epoch_s,
1262                ..
1263            } => {
1264                assert_eq!(since_epoch_s, None);
1265                assert_eq!(until_epoch_s, None);
1266            }
1267            other => panic!("expected HistoryList, got {other:?}"),
1268        }
1269    }
1270
1271    #[test]
1272    fn history_update_parses_sync_fields() {
1273        let json = r#"{
1274            "code": "history_update",
1275            "id": "t_2",
1276            "wallet": "w_1",
1277            "network": "sol",
1278            "limit": 150
1279        }"#;
1280        let input: Input = serde_json::from_str(json).expect("parse history_update");
1281        match input {
1282            Input::HistoryUpdate {
1283                wallet,
1284                network,
1285                limit,
1286                ..
1287            } => {
1288                assert_eq!(wallet.as_deref(), Some("w_1"));
1289                assert_eq!(network, Some(Network::Sol));
1290                assert_eq!(limit, Some(150));
1291            }
1292            other => panic!("expected HistoryUpdate, got {other:?}"),
1293        }
1294    }
1295
1296    #[test]
1297    fn history_update_fields_default_to_none() {
1298        let json = r#"{
1299            "code": "history_update",
1300            "id": "t_3"
1301        }"#;
1302        let input: Input = serde_json::from_str(json).expect("parse history_update defaults");
1303        match input {
1304            Input::HistoryUpdate {
1305                wallet,
1306                network,
1307                limit,
1308                ..
1309            } => {
1310                assert_eq!(wallet, None);
1311                assert_eq!(network, None);
1312                assert_eq!(limit, None);
1313            }
1314            other => panic!("expected HistoryUpdate, got {other:?}"),
1315        }
1316    }
1317}