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 #[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#[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#[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 #[serde(default, skip_serializing_if = "Option::is_none")]
467 mint_url: Option<String>,
468 #[serde(default, skip_serializing_if = "Vec::is_empty")]
470 rpc_endpoints: Vec<String>,
471 #[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 #[serde(default, skip_serializing_if = "Option::is_none")]
478 btc_esplora_url: Option<String>,
479 #[serde(default, skip_serializing_if = "Option::is_none")]
481 btc_network: Option<String>,
482 #[serde(default, skip_serializing_if = "Option::is_none")]
484 btc_address_type: Option<String>,
485 #[serde(default, skip_serializing_if = "Option::is_none")]
487 btc_backend: Option<BtcBackend>,
488 #[serde(default, skip_serializing_if = "Option::is_none")]
490 btc_core_url: Option<String>,
491 #[serde(default, skip_serializing_if = "Option::is_none")]
493 btc_core_auth_secret: Option<String>,
494 #[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 #[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 #[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 #[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 #[serde(default, skip_serializing_if = "Option::is_none")]
618 since_epoch_s: Option<u64>,
619 #[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 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#[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#[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 #[serde(default)]
965 pub afpay_rpc: std::collections::HashMap<String, AfpayRpcConfig>,
966 #[serde(default)]
968 pub providers: std::collections::HashMap<String, String>,
969 #[serde(default, skip_serializing_if = "Option::is_none")]
971 pub storage_backend: Option<String>,
972 #[serde(default, skip_serializing_if = "Option::is_none")]
974 pub postgres_url_secret: Option<String>,
975 #[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 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#[derive(Debug, Serialize, Deserialize, Clone)]
1061pub struct RateLimitConfig {
1062 #[serde(default = "default_rate_limit_rps")]
1064 pub requests_per_second: u32,
1065 #[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
1122fn 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
1175pub fn is_bolt12_offer(s: &str) -> bool {
1178 s.len() >= 4 && s[..4].eq_ignore_ascii_case("lno1")
1179}
1180
1181pub 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 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 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 assert!(!Input::LimitList { id: "t".into() }.is_local_only());
1275
1276 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 assert!(!Input::WalletConfigShow {
1304 id: "t".into(),
1305 wallet: "w".into(),
1306 }
1307 .is_local_only());
1308
1309 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}