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    /// Reference keys found in the transaction (sol only, per strain-payment-method-solana).
361    #[serde(default, skip_serializing_if = "Option::is_none")]
362    pub reference_keys: Option<Vec<String>>,
363}
364
365#[derive(Debug, Clone, Serialize)]
366pub struct CashuSendResult {
367    pub wallet: String,
368    pub transaction_id: String,
369    pub status: TxStatus,
370    pub fee: Option<Amount>,
371    pub token: String,
372}
373
374#[derive(Debug, Clone, Serialize)]
375pub struct CashuReceiveResult {
376    pub wallet: String,
377    pub amount: Amount,
378}
379
380#[derive(Debug, Clone, Serialize)]
381pub struct RestoreResult {
382    pub wallet: String,
383    pub unspent: u64,
384    pub spent: u64,
385    pub pending: u64,
386    pub unit: String,
387}
388
389#[cfg(feature = "interactive")]
390#[derive(Debug, Clone, Serialize)]
391pub struct CashuSendQuoteInfo {
392    pub wallet: String,
393    pub amount_native: u64,
394    pub fee_native: u64,
395    pub fee_unit: String,
396}
397
398#[derive(Debug, Clone, Serialize)]
399pub struct SendQuoteInfo {
400    pub wallet: String,
401    pub amount_native: u64,
402    pub fee_estimate_native: u64,
403    pub fee_unit: String,
404}
405
406#[derive(Debug, Clone, Serialize)]
407pub struct SendResult {
408    pub wallet: String,
409    pub transaction_id: String,
410    pub amount: Amount,
411    pub fee: Option<Amount>,
412    pub preimage: Option<String>,
413}
414
415#[derive(Debug, Clone, Serialize)]
416pub struct HistoryStatusInfo {
417    pub transaction_id: String,
418    pub status: TxStatus,
419    pub confirmations: Option<u32>,
420    pub preimage: Option<String>,
421    pub item: Option<HistoryRecord>,
422}
423
424// ═══════════════════════════════════════════
425// Trace Types
426// ═══════════════════════════════════════════
427
428#[derive(Debug, Serialize, Clone)]
429pub struct Trace {
430    pub duration_ms: u64,
431}
432
433impl Trace {
434    pub fn from_duration(duration_ms: u64) -> Self {
435        Self { duration_ms }
436    }
437}
438
439#[derive(Debug, Serialize)]
440pub struct PongTrace {
441    pub uptime_s: u64,
442    pub requests_total: u64,
443    pub in_flight: usize,
444}
445
446#[derive(Debug, Serialize)]
447pub struct CloseTrace {
448    pub uptime_s: u64,
449    pub requests_total: u64,
450}
451
452// ═══════════════════════════════════════════
453// Input (Requests)
454// ═══════════════════════════════════════════
455
456#[derive(Debug, Serialize, Deserialize)]
457#[serde(tag = "code")]
458pub enum Input {
459    #[serde(rename = "wallet_create")]
460    WalletCreate {
461        id: String,
462        network: Network,
463        #[serde(default)]
464        label: Option<String>,
465        /// Cashu mint URL (cashu only).
466        #[serde(default, skip_serializing_if = "Option::is_none")]
467        mint_url: Option<String>,
468        /// RPC endpoints for sol/evm providers.
469        #[serde(default, skip_serializing_if = "Vec::is_empty")]
470        rpc_endpoints: Vec<String>,
471        /// EVM chain ID (evm only, default 8453 = Base).
472        #[serde(default, skip_serializing_if = "Option::is_none")]
473        chain_id: Option<u64>,
474        #[serde(default, skip_serializing_if = "Option::is_none")]
475        mnemonic_secret: Option<String>,
476        /// Esplora API URL (btc only).
477        #[serde(default, skip_serializing_if = "Option::is_none")]
478        btc_esplora_url: Option<String>,
479        /// BTC sub-network: "mainnet" | "signet" (btc only).
480        #[serde(default, skip_serializing_if = "Option::is_none")]
481        btc_network: Option<String>,
482        /// BTC address type: "taproot" | "segwit" (btc only).
483        #[serde(default, skip_serializing_if = "Option::is_none")]
484        btc_address_type: Option<String>,
485        /// BTC chain-source backend (btc only).
486        #[serde(default, skip_serializing_if = "Option::is_none")]
487        btc_backend: Option<BtcBackend>,
488        /// Bitcoin Core RPC URL (btc core-rpc only).
489        #[serde(default, skip_serializing_if = "Option::is_none")]
490        btc_core_url: Option<String>,
491        /// Bitcoin Core RPC auth (btc core-rpc only).
492        #[serde(default, skip_serializing_if = "Option::is_none")]
493        btc_core_auth_secret: Option<String>,
494        /// Electrum server URL (btc electrum only).
495        #[serde(default, skip_serializing_if = "Option::is_none")]
496        btc_electrum_url: Option<String>,
497    },
498    #[serde(rename = "ln_wallet_create")]
499    LnWalletCreate {
500        id: String,
501        #[serde(flatten)]
502        request: LnWalletCreateRequest,
503    },
504    #[serde(rename = "wallet_close")]
505    WalletClose {
506        id: String,
507        wallet: String,
508        #[serde(default)]
509        dangerously_skip_balance_check_and_may_lose_money: bool,
510    },
511    #[serde(rename = "wallet_list")]
512    WalletList {
513        id: String,
514        #[serde(default)]
515        network: Option<Network>,
516    },
517    #[serde(rename = "balance")]
518    Balance {
519        id: String,
520        #[serde(default)]
521        wallet: Option<String>,
522        #[serde(default, skip_serializing_if = "Option::is_none")]
523        network: Option<Network>,
524        #[serde(default)]
525        check: bool,
526    },
527    #[serde(rename = "receive")]
528    Receive {
529        id: String,
530        wallet: String,
531        #[serde(default, skip_serializing_if = "Option::is_none")]
532        network: Option<Network>,
533        #[serde(default)]
534        amount: Option<Amount>,
535        #[serde(default, skip_serializing_if = "Option::is_none")]
536        onchain_memo: Option<String>,
537        #[serde(default)]
538        wait_until_paid: bool,
539        #[serde(default)]
540        wait_timeout_s: Option<u64>,
541        #[serde(default)]
542        wait_poll_interval_ms: Option<u64>,
543        #[serde(default)]
544        wait_sync_limit: Option<usize>,
545        #[serde(default)]
546        write_qr_svg_file: bool,
547        #[serde(default, skip_serializing_if = "Option::is_none")]
548        min_confirmations: Option<u32>,
549        /// Reference key to watch for (base58, sol only, per strain-payment-method-solana).
550        #[serde(default, skip_serializing_if = "Option::is_none")]
551        reference: Option<String>,
552    },
553    #[serde(rename = "receive_claim")]
554    ReceiveClaim {
555        id: String,
556        wallet: String,
557        quote_id: String,
558    },
559
560    #[serde(rename = "cashu_send")]
561    CashuSend {
562        id: String,
563        #[serde(default)]
564        wallet: Option<String>,
565        amount: Amount,
566        #[serde(default)]
567        onchain_memo: Option<String>,
568        #[serde(default, deserialize_with = "deserialize_local_memo")]
569        local_memo: Option<BTreeMap<String, String>>,
570        /// Restrict to wallets on these mints (tried in order).
571        #[serde(default, skip_serializing_if = "Option::is_none")]
572        mints: Option<Vec<String>>,
573    },
574    #[serde(rename = "cashu_receive")]
575    CashuReceive {
576        id: String,
577        #[serde(default)]
578        wallet: Option<String>,
579        token: String,
580    },
581    #[serde(rename = "send")]
582    Send {
583        id: String,
584        #[serde(default)]
585        wallet: Option<String>,
586        #[serde(default, skip_serializing_if = "Option::is_none")]
587        network: Option<Network>,
588        to: String,
589        #[serde(default)]
590        onchain_memo: Option<String>,
591        #[serde(default, deserialize_with = "deserialize_local_memo")]
592        local_memo: Option<BTreeMap<String, String>>,
593        /// Restrict to wallets on these mints (cashu only).
594        #[serde(default, skip_serializing_if = "Option::is_none")]
595        mints: Option<Vec<String>>,
596    },
597
598    #[serde(rename = "restore")]
599    Restore { id: String, wallet: String },
600    #[serde(rename = "local_wallet_show_seed")]
601    WalletShowSeed { id: String, wallet: String },
602
603    #[serde(rename = "history")]
604    HistoryList {
605        id: String,
606        #[serde(default)]
607        wallet: Option<String>,
608        #[serde(default, skip_serializing_if = "Option::is_none")]
609        network: Option<Network>,
610        #[serde(default, skip_serializing_if = "Option::is_none")]
611        onchain_memo: Option<String>,
612        #[serde(default)]
613        limit: Option<usize>,
614        #[serde(default)]
615        offset: Option<usize>,
616        /// Only include records created at or after this epoch second.
617        #[serde(default, skip_serializing_if = "Option::is_none")]
618        since_epoch_s: Option<u64>,
619        /// Only include records created before this epoch second.
620        #[serde(default, skip_serializing_if = "Option::is_none")]
621        until_epoch_s: Option<u64>,
622    },
623    #[serde(rename = "history_status")]
624    HistoryStatus { id: String, transaction_id: String },
625    #[serde(rename = "history_update")]
626    HistoryUpdate {
627        id: String,
628        #[serde(default)]
629        wallet: Option<String>,
630        #[serde(default, skip_serializing_if = "Option::is_none")]
631        network: Option<Network>,
632        #[serde(default)]
633        limit: Option<usize>,
634    },
635
636    #[serde(rename = "limit_add")]
637    LimitAdd { id: String, limit: SpendLimit },
638    #[serde(rename = "limit_remove")]
639    LimitRemove { id: String, rule_id: String },
640    #[serde(rename = "limit_list")]
641    LimitList { id: String },
642    #[serde(rename = "limit_set")]
643    LimitSet { id: String, limits: Vec<SpendLimit> },
644
645    #[serde(rename = "wallet_config_show")]
646    WalletConfigShow { id: String, wallet: String },
647    #[serde(rename = "wallet_config_set")]
648    WalletConfigSet {
649        id: String,
650        wallet: String,
651        #[serde(default, skip_serializing_if = "Option::is_none")]
652        label: Option<String>,
653        #[serde(default, skip_serializing_if = "Vec::is_empty")]
654        rpc_endpoints: Vec<String>,
655        #[serde(default, skip_serializing_if = "Option::is_none")]
656        chain_id: Option<u64>,
657    },
658    #[serde(rename = "wallet_config_token_add")]
659    WalletConfigTokenAdd {
660        id: String,
661        wallet: String,
662        symbol: String,
663        address: String,
664        decimals: u8,
665    },
666    #[serde(rename = "wallet_config_token_remove")]
667    WalletConfigTokenRemove {
668        id: String,
669        wallet: String,
670        symbol: String,
671    },
672
673    #[serde(rename = "config")]
674    Config(ConfigPatch),
675    #[serde(rename = "version")]
676    Version,
677    #[serde(rename = "close")]
678    Close,
679}
680
681impl Input {
682    /// Returns true if this input must only be handled locally (never via RPC).
683    pub fn is_local_only(&self) -> bool {
684        matches!(
685            self,
686            Input::WalletShowSeed { .. }
687                | Input::WalletClose {
688                    dangerously_skip_balance_check_and_may_lose_money: true,
689                    ..
690                }
691                | Input::LimitAdd { .. }
692                | Input::LimitRemove { .. }
693                | Input::LimitSet { .. }
694                | Input::WalletConfigSet { .. }
695                | Input::WalletConfigTokenAdd { .. }
696                | Input::WalletConfigTokenRemove { .. }
697                | Input::Restore { .. }
698                | Input::Config(_)
699        )
700    }
701}
702
703// ═══════════════════════════════════════════
704// Output (Responses)
705// ═══════════════════════════════════════════
706
707#[derive(Debug, Serialize)]
708#[serde(tag = "code")]
709pub enum Output {
710    #[serde(rename = "wallet_created")]
711    WalletCreated {
712        id: String,
713        wallet: String,
714        network: Network,
715        address: String,
716        #[serde(skip_serializing_if = "Option::is_none")]
717        mnemonic: Option<String>,
718        trace: Trace,
719    },
720    #[serde(rename = "wallet_closed")]
721    WalletClosed {
722        id: String,
723        wallet: String,
724        trace: Trace,
725    },
726    #[serde(rename = "wallet_list")]
727    WalletList {
728        id: String,
729        wallets: Vec<WalletSummary>,
730        trace: Trace,
731    },
732    #[serde(rename = "wallet_balances")]
733    WalletBalances {
734        id: String,
735        wallets: Vec<WalletBalanceItem>,
736        trace: Trace,
737    },
738    #[serde(rename = "receive_info")]
739    ReceiveInfo {
740        id: String,
741        wallet: String,
742        receive_info: ReceiveInfo,
743        trace: Trace,
744    },
745    #[serde(rename = "receive_claimed")]
746    ReceiveClaimed {
747        id: String,
748        wallet: String,
749        amount: Amount,
750        trace: Trace,
751    },
752
753    #[serde(rename = "cashu_sent")]
754    CashuSent {
755        id: String,
756        wallet: String,
757        transaction_id: String,
758        status: TxStatus,
759        #[serde(skip_serializing_if = "Option::is_none")]
760        fee: Option<Amount>,
761        token: String,
762        trace: Trace,
763    },
764
765    #[serde(rename = "history")]
766    History {
767        id: String,
768        items: Vec<HistoryRecord>,
769        trace: Trace,
770    },
771    #[serde(rename = "history_status")]
772    HistoryStatus {
773        id: String,
774        transaction_id: String,
775        status: TxStatus,
776        #[serde(skip_serializing_if = "Option::is_none")]
777        confirmations: Option<u32>,
778        #[serde(skip_serializing_if = "Option::is_none")]
779        preimage: Option<String>,
780        #[serde(skip_serializing_if = "Option::is_none")]
781        item: Option<HistoryRecord>,
782        trace: Trace,
783    },
784    #[serde(rename = "history_updated")]
785    HistoryUpdated {
786        id: String,
787        wallets_synced: usize,
788        records_scanned: usize,
789        records_added: usize,
790        records_updated: usize,
791        trace: Trace,
792    },
793
794    #[serde(rename = "limit_added")]
795    LimitAdded {
796        id: String,
797        rule_id: String,
798        trace: Trace,
799    },
800    #[serde(rename = "limit_removed")]
801    LimitRemoved {
802        id: String,
803        rule_id: String,
804        trace: Trace,
805    },
806    #[serde(rename = "limit_status")]
807    LimitStatus {
808        id: String,
809        limits: Vec<SpendLimitStatus>,
810        #[serde(default, skip_serializing_if = "Vec::is_empty")]
811        downstream: Vec<DownstreamLimitNode>,
812        trace: Trace,
813    },
814    #[serde(rename = "limit_exceeded")]
815    #[allow(dead_code)]
816    LimitExceeded {
817        id: String,
818        rule_id: String,
819        scope: SpendScope,
820        scope_key: String,
821        spent: u64,
822        max_spend: u64,
823        #[serde(skip_serializing_if = "Option::is_none")]
824        token: Option<String>,
825        remaining_s: u64,
826        #[serde(skip_serializing_if = "Option::is_none")]
827        origin: Option<String>,
828        trace: Trace,
829    },
830
831    #[serde(rename = "cashu_received")]
832    CashuReceived {
833        id: String,
834        wallet: String,
835        amount: Amount,
836        trace: Trace,
837    },
838    #[serde(rename = "restored")]
839    Restored {
840        id: String,
841        wallet: String,
842        unspent: u64,
843        spent: u64,
844        pending: u64,
845        unit: String,
846        trace: Trace,
847    },
848    #[serde(rename = "wallet_seed")]
849    WalletSeed {
850        id: String,
851        wallet: String,
852        mnemonic_secret: String,
853        trace: Trace,
854    },
855
856    #[serde(rename = "sent")]
857    Sent {
858        id: String,
859        wallet: String,
860        transaction_id: String,
861        amount: Amount,
862        #[serde(skip_serializing_if = "Option::is_none")]
863        fee: Option<Amount>,
864        #[serde(skip_serializing_if = "Option::is_none")]
865        preimage: Option<String>,
866        trace: Trace,
867    },
868
869    #[serde(rename = "wallet_config")]
870    WalletConfig {
871        id: String,
872        wallet: String,
873        config: WalletMetadata,
874        trace: Trace,
875    },
876    #[serde(rename = "wallet_config_updated")]
877    WalletConfigUpdated {
878        id: String,
879        wallet: String,
880        trace: Trace,
881    },
882    #[serde(rename = "wallet_config_token_added")]
883    WalletConfigTokenAdded {
884        id: String,
885        wallet: String,
886        symbol: String,
887        address: String,
888        decimals: u8,
889        trace: Trace,
890    },
891    #[serde(rename = "wallet_config_token_removed")]
892    WalletConfigTokenRemoved {
893        id: String,
894        wallet: String,
895        symbol: String,
896        trace: Trace,
897    },
898
899    #[serde(rename = "error")]
900    Error {
901        #[serde(skip_serializing_if = "Option::is_none")]
902        id: Option<String>,
903        error_code: String,
904        error: String,
905        #[serde(skip_serializing_if = "Option::is_none")]
906        hint: Option<String>,
907        retryable: bool,
908        trace: Trace,
909    },
910
911    #[serde(rename = "dry_run")]
912    DryRun {
913        #[serde(skip_serializing_if = "Option::is_none")]
914        id: Option<String>,
915        command: String,
916        params: serde_json::Value,
917        trace: Trace,
918    },
919
920    #[serde(rename = "config")]
921    Config(RuntimeConfig),
922    #[serde(rename = "version")]
923    Version { version: String, trace: PongTrace },
924    #[serde(rename = "close")]
925    Close { message: String, trace: CloseTrace },
926    #[serde(rename = "log")]
927    Log {
928        event: String,
929        #[serde(skip_serializing_if = "Option::is_none")]
930        request_id: Option<String>,
931        #[serde(skip_serializing_if = "Option::is_none")]
932        version: Option<String>,
933        #[serde(skip_serializing_if = "Option::is_none")]
934        argv: Option<Vec<String>>,
935        #[serde(skip_serializing_if = "Option::is_none")]
936        config: Option<serde_json::Value>,
937        #[serde(skip_serializing_if = "Option::is_none")]
938        args: Option<serde_json::Value>,
939        #[serde(skip_serializing_if = "Option::is_none")]
940        env: Option<serde_json::Value>,
941        trace: Trace,
942    },
943}
944
945// ═══════════════════════════════════════════
946// Config Types
947// ═══════════════════════════════════════════
948
949#[derive(Debug, Serialize, Deserialize, Clone)]
950pub struct RuntimeConfig {
951    #[serde(default)]
952    pub data_dir: String,
953    #[serde(default, skip_serializing_if = "Option::is_none")]
954    pub rpc_endpoint: Option<String>,
955    #[serde(default, skip_serializing_if = "Option::is_none")]
956    pub rpc_secret: Option<String>,
957    #[serde(default)]
958    pub limits: Vec<SpendLimit>,
959    #[serde(default)]
960    pub log: Vec<String>,
961    #[serde(default, skip_serializing_if = "Option::is_none")]
962    pub exchange_rate: Option<ExchangeRateConfig>,
963    /// Named afpay RPC nodes (e.g. `[afpay_rpc.wallet-server]`).
964    #[serde(default)]
965    pub afpay_rpc: std::collections::HashMap<String, AfpayRpcConfig>,
966    /// Network → afpay_rpc node name (omit = local provider).
967    #[serde(default)]
968    pub providers: std::collections::HashMap<String, String>,
969    /// Storage backend: "redb" (default) or "postgres".
970    #[serde(default, skip_serializing_if = "Option::is_none")]
971    pub storage_backend: Option<String>,
972    /// PostgreSQL connection URL (used when storage_backend = "postgres").
973    #[serde(default, skip_serializing_if = "Option::is_none")]
974    pub postgres_url_secret: Option<String>,
975    /// Rate limiting for REST/RPC endpoints.
976    #[serde(default, skip_serializing_if = "Option::is_none")]
977    pub rate_limit: Option<RateLimitConfig>,
978}
979
980impl Default for RuntimeConfig {
981    fn default() -> Self {
982        Self {
983            data_dir: default_data_dir(),
984            rpc_endpoint: None,
985            rpc_secret: None,
986            limits: vec![],
987            log: vec![],
988            exchange_rate: None,
989            afpay_rpc: std::collections::HashMap::new(),
990            providers: std::collections::HashMap::new(),
991            storage_backend: None,
992            postgres_url_secret: None,
993            rate_limit: None,
994        }
995    }
996}
997
998fn default_data_dir() -> String {
999    // AFPAY_HOME takes priority, then ~/.afpay
1000    if let Some(val) = std::env::var_os("AFPAY_HOME") {
1001        return std::path::PathBuf::from(val).to_string_lossy().into_owned();
1002    }
1003    if let Some(home) = std::env::var_os("HOME") {
1004        let mut p = std::path::PathBuf::from(home);
1005        p.push(".afpay");
1006        p.to_string_lossy().into_owned()
1007    } else {
1008        ".afpay".to_string()
1009    }
1010}
1011
1012#[derive(Debug, Serialize, Deserialize, Clone)]
1013pub struct AfpayRpcConfig {
1014    pub endpoint: String,
1015    #[serde(default, skip_serializing_if = "Option::is_none")]
1016    pub endpoint_secret: Option<String>,
1017}
1018
1019#[derive(Debug, Serialize, Deserialize, Clone)]
1020pub struct ExchangeRateConfig {
1021    #[serde(default = "default_exchange_rate_ttl_s")]
1022    pub ttl_s: u64,
1023    #[serde(default = "default_exchange_rate_sources")]
1024    pub sources: Vec<ExchangeRateSource>,
1025}
1026
1027impl Default for ExchangeRateConfig {
1028    fn default() -> Self {
1029        Self {
1030            ttl_s: default_exchange_rate_ttl_s(),
1031            sources: default_exchange_rate_sources(),
1032        }
1033    }
1034}
1035
1036#[derive(Debug, Serialize, Deserialize, Clone)]
1037pub struct ExchangeRateSource {
1038    #[serde(rename = "type")]
1039    pub source_type: ExchangeRateSourceType,
1040    pub endpoint: String,
1041    #[serde(default, skip_serializing_if = "Option::is_none")]
1042    pub api_key: Option<String>,
1043}
1044
1045#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1046#[serde(rename_all = "snake_case")]
1047pub enum ExchangeRateSourceType {
1048    Generic,
1049    CoinGecko,
1050    Kraken,
1051}
1052
1053/// Rate limiting configuration for REST/RPC endpoints.
1054///
1055/// ```toml
1056/// [rate_limit]
1057/// requests_per_second = 20
1058/// max_concurrent = 50
1059/// ```
1060#[derive(Debug, Serialize, Deserialize, Clone)]
1061pub struct RateLimitConfig {
1062    /// Maximum requests per second (token-bucket refill rate). 0 = unlimited.
1063    #[serde(default = "default_rate_limit_rps")]
1064    pub requests_per_second: u32,
1065    /// Maximum concurrent in-flight requests. 0 = unlimited.
1066    #[serde(default = "default_rate_limit_concurrent")]
1067    pub max_concurrent: u32,
1068}
1069
1070impl Default for RateLimitConfig {
1071    fn default() -> Self {
1072        Self {
1073            requests_per_second: default_rate_limit_rps(),
1074            max_concurrent: default_rate_limit_concurrent(),
1075        }
1076    }
1077}
1078
1079fn default_rate_limit_rps() -> u32 {
1080    20
1081}
1082
1083fn default_rate_limit_concurrent() -> u32 {
1084    50
1085}
1086
1087fn default_exchange_rate_ttl_s() -> u64 {
1088    300
1089}
1090
1091fn default_exchange_rate_sources() -> Vec<ExchangeRateSource> {
1092    vec![
1093        ExchangeRateSource {
1094            source_type: ExchangeRateSourceType::Kraken,
1095            endpoint: "https://api.kraken.com".to_string(),
1096            api_key: None,
1097        },
1098        ExchangeRateSource {
1099            source_type: ExchangeRateSourceType::CoinGecko,
1100            endpoint: "https://api.coingecko.com/api/v3".to_string(),
1101            api_key: None,
1102        },
1103    ]
1104}
1105
1106#[derive(Debug, Serialize, Deserialize, Default)]
1107pub struct ConfigPatch {
1108    #[serde(default)]
1109    pub data_dir: Option<String>,
1110    #[serde(default)]
1111    pub limits: Option<Vec<SpendLimit>>,
1112    #[serde(default)]
1113    pub log: Option<Vec<String>>,
1114    #[serde(default)]
1115    pub exchange_rate: Option<ExchangeRateConfig>,
1116    #[serde(default)]
1117    pub afpay_rpc: Option<std::collections::HashMap<String, AfpayRpcConfig>>,
1118    #[serde(default)]
1119    pub providers: Option<std::collections::HashMap<String, String>>,
1120}
1121
1122/// Deserializes `local_memo` with backward compatibility.
1123/// Accepts: null → None, "string" → Some({"note": "string"}), {object} → Some(object).
1124fn deserialize_local_memo<'de, D>(d: D) -> Result<Option<BTreeMap<String, String>>, D::Error>
1125where
1126    D: Deserializer<'de>,
1127{
1128    use serde::de;
1129
1130    struct LocalMemoVisitor;
1131
1132    impl<'de> de::Visitor<'de> for LocalMemoVisitor {
1133        type Value = Option<BTreeMap<String, String>>;
1134
1135        fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
1136            f.write_str("null, a string, or a map of string→string")
1137        }
1138
1139        fn visit_none<E: de::Error>(self) -> Result<Self::Value, E> {
1140            Ok(None)
1141        }
1142
1143        fn visit_unit<E: de::Error>(self) -> Result<Self::Value, E> {
1144            Ok(None)
1145        }
1146
1147        fn visit_str<E: de::Error>(self, v: &str) -> Result<Self::Value, E> {
1148            let mut m = BTreeMap::new();
1149            m.insert("note".to_string(), v.to_string());
1150            Ok(Some(m))
1151        }
1152
1153        fn visit_string<E: de::Error>(self, v: String) -> Result<Self::Value, E> {
1154            let mut m = BTreeMap::new();
1155            m.insert("note".to_string(), v);
1156            Ok(Some(m))
1157        }
1158
1159        fn visit_map<A: de::MapAccess<'de>>(self, mut map: A) -> Result<Self::Value, A::Error> {
1160            let mut m = BTreeMap::new();
1161            while let Some((k, v)) = map.next_entry::<String, String>()? {
1162                m.insert(k, v);
1163            }
1164            Ok(Some(m))
1165        }
1166
1167        fn visit_some<D2: Deserializer<'de>>(self, d: D2) -> Result<Self::Value, D2::Error> {
1168            d.deserialize_any(Self)
1169        }
1170    }
1171
1172    d.deserialize_option(LocalMemoVisitor)
1173}
1174
1175/// Returns true if the string looks like a BOLT12 offer (`lno1…`),
1176/// optionally with a `?amount=<sats>` suffix. Case-insensitive.
1177pub fn is_bolt12_offer(s: &str) -> bool {
1178    s.len() >= 4 && s[..4].eq_ignore_ascii_case("lno1")
1179}
1180
1181/// Split a BOLT12 offer string into the raw offer and an optional amount-sats.
1182/// Accepts `lno1...` or `lno1...?amount=1000`. Case-insensitive prefix detection.
1183pub fn parse_bolt12_offer_parts(s: &str) -> (String, Option<u64>) {
1184    if let Some(idx) = s.find("?amount=") {
1185        let offer = s[..idx].to_string();
1186        let amt = s[idx + 8..].parse::<u64>().ok();
1187        (offer, amt)
1188    } else {
1189        (s.to_string(), None)
1190    }
1191}
1192
1193#[cfg(test)]
1194mod tests {
1195    use super::*;
1196
1197    #[test]
1198    fn bolt12_offer_detection() {
1199        assert!(is_bolt12_offer("lno1qgsqvgjwcf6qqz9"));
1200        assert!(is_bolt12_offer("lno1qgsqvgjwcf6qqz9?amount=1000"));
1201        assert!(is_bolt12_offer("LNO1QGSQVGJWCF6QQZ9"));
1202        assert!(is_bolt12_offer("Lno1MixedCase"));
1203        assert!(!is_bolt12_offer("lnbc1qgsqvgjwcf6qqz9"));
1204        assert!(!is_bolt12_offer("lno"));
1205        assert!(!is_bolt12_offer(""));
1206    }
1207
1208    #[test]
1209    fn bolt12_offer_parts_parsing() {
1210        let (offer, amt) = parse_bolt12_offer_parts("lno1abc123");
1211        assert_eq!(offer, "lno1abc123");
1212        assert_eq!(amt, None);
1213
1214        let (offer, amt) = parse_bolt12_offer_parts("lno1abc123?amount=500");
1215        assert_eq!(offer, "lno1abc123");
1216        assert_eq!(amt, Some(500));
1217
1218        let (offer, amt) = parse_bolt12_offer_parts("LNO1ABC?amount=42");
1219        assert_eq!(offer, "LNO1ABC");
1220        assert_eq!(amt, Some(42));
1221    }
1222
1223    #[test]
1224    fn local_only_checks() {
1225        // Already local-only
1226        assert!(Input::WalletShowSeed {
1227            id: "t".into(),
1228            wallet: "w".into(),
1229        }
1230        .is_local_only());
1231
1232        assert!(Input::WalletClose {
1233            id: "t".into(),
1234            wallet: "w".into(),
1235            dangerously_skip_balance_check_and_may_lose_money: true,
1236        }
1237        .is_local_only());
1238
1239        assert!(!Input::WalletClose {
1240            id: "t".into(),
1241            wallet: "w".into(),
1242            dangerously_skip_balance_check_and_may_lose_money: false,
1243        }
1244        .is_local_only());
1245
1246        // Limit write ops
1247        assert!(Input::LimitAdd {
1248            id: "t".into(),
1249            limit: SpendLimit {
1250                rule_id: None,
1251                scope: SpendScope::GlobalUsdCents,
1252                network: None,
1253                wallet: None,
1254                window_s: 3600,
1255                max_spend: 1000,
1256                token: None,
1257            },
1258        }
1259        .is_local_only());
1260
1261        assert!(Input::LimitRemove {
1262            id: "t".into(),
1263            rule_id: "r_1".into(),
1264        }
1265        .is_local_only());
1266
1267        assert!(Input::LimitSet {
1268            id: "t".into(),
1269            limits: vec![],
1270        }
1271        .is_local_only());
1272
1273        // Limit read is NOT local-only
1274        assert!(!Input::LimitList { id: "t".into() }.is_local_only());
1275
1276        // Wallet config write ops
1277        assert!(Input::WalletConfigSet {
1278            id: "t".into(),
1279            wallet: "w".into(),
1280            label: None,
1281            rpc_endpoints: vec![],
1282            chain_id: None,
1283        }
1284        .is_local_only());
1285
1286        assert!(Input::WalletConfigTokenAdd {
1287            id: "t".into(),
1288            wallet: "w".into(),
1289            symbol: "dai".into(),
1290            address: "0x".into(),
1291            decimals: 18,
1292        }
1293        .is_local_only());
1294
1295        assert!(Input::WalletConfigTokenRemove {
1296            id: "t".into(),
1297            wallet: "w".into(),
1298            symbol: "dai".into(),
1299        }
1300        .is_local_only());
1301
1302        // Wallet config read is NOT local-only
1303        assert!(!Input::WalletConfigShow {
1304            id: "t".into(),
1305            wallet: "w".into(),
1306        }
1307        .is_local_only());
1308
1309        // Restore (seed over RPC)
1310        assert!(Input::Restore {
1311            id: "t".into(),
1312            wallet: "w".into(),
1313        }
1314        .is_local_only());
1315    }
1316
1317    #[test]
1318    fn wallet_seed_output_uses_mnemonic_secret_field() {
1319        let out = Output::WalletSeed {
1320            id: "t_1".to_string(),
1321            wallet: "w_1".to_string(),
1322            mnemonic_secret: "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about".to_string(),
1323            trace: Trace::from_duration(0),
1324        };
1325        let value = serde_json::to_value(out).expect("serialize wallet_seed output");
1326        assert_eq!(
1327            value.get("mnemonic_secret").and_then(|v| v.as_str()),
1328            Some(
1329                "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
1330            )
1331        );
1332        assert!(value.get("mnemonic").is_none());
1333    }
1334
1335    #[test]
1336    fn history_list_parses_time_range_fields() {
1337        let json = r#"{
1338            "code": "history",
1339            "id": "t_1",
1340            "wallet": "w_1",
1341            "limit": 10,
1342            "offset": 0,
1343            "since_epoch_s": 1700000000,
1344            "until_epoch_s": 1700100000
1345        }"#;
1346        let input: Input = serde_json::from_str(json).expect("parse history_list with time range");
1347        match input {
1348            Input::HistoryList {
1349                since_epoch_s,
1350                until_epoch_s,
1351                ..
1352            } => {
1353                assert_eq!(since_epoch_s, Some(1_700_000_000));
1354                assert_eq!(until_epoch_s, Some(1_700_100_000));
1355            }
1356            other => panic!("expected HistoryList, got {other:?}"),
1357        }
1358    }
1359
1360    #[test]
1361    fn history_list_time_range_fields_default_to_none() {
1362        let json = r#"{
1363            "code": "history",
1364            "id": "t_1",
1365            "limit": 10,
1366            "offset": 0
1367        }"#;
1368        let input: Input =
1369            serde_json::from_str(json).expect("parse history_list without time range");
1370        match input {
1371            Input::HistoryList {
1372                since_epoch_s,
1373                until_epoch_s,
1374                ..
1375            } => {
1376                assert_eq!(since_epoch_s, None);
1377                assert_eq!(until_epoch_s, None);
1378            }
1379            other => panic!("expected HistoryList, got {other:?}"),
1380        }
1381    }
1382
1383    #[test]
1384    fn history_update_parses_sync_fields() {
1385        let json = r#"{
1386            "code": "history_update",
1387            "id": "t_2",
1388            "wallet": "w_1",
1389            "network": "sol",
1390            "limit": 150
1391        }"#;
1392        let input: Input = serde_json::from_str(json).expect("parse history_update");
1393        match input {
1394            Input::HistoryUpdate {
1395                wallet,
1396                network,
1397                limit,
1398                ..
1399            } => {
1400                assert_eq!(wallet.as_deref(), Some("w_1"));
1401                assert_eq!(network, Some(Network::Sol));
1402                assert_eq!(limit, Some(150));
1403            }
1404            other => panic!("expected HistoryUpdate, got {other:?}"),
1405        }
1406    }
1407
1408    #[test]
1409    fn history_update_fields_default_to_none() {
1410        let json = r#"{
1411            "code": "history_update",
1412            "id": "t_3"
1413        }"#;
1414        let input: Input = serde_json::from_str(json).expect("parse history_update defaults");
1415        match input {
1416            Input::HistoryUpdate {
1417                wallet,
1418                network,
1419                limit,
1420                ..
1421            } => {
1422                assert_eq!(wallet, None);
1423                assert_eq!(network, None);
1424                assert_eq!(limit, None);
1425            }
1426            other => panic!("expected HistoryUpdate, got {other:?}"),
1427        }
1428    }
1429}