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