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