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#[cfg_attr(feature = "mcp", derive(schemars::JsonSchema))]
11#[serde(rename_all = "lowercase")]
12pub enum Network {
13 Ln,
14 Sol,
15 Evm,
16 Cashu,
17 Btc,
18}
19
20impl std::fmt::Display for Network {
21 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
22 match self {
23 Self::Ln => write!(f, "ln"),
24 Self::Sol => write!(f, "sol"),
25 Self::Evm => write!(f, "evm"),
26 Self::Cashu => write!(f, "cashu"),
27 Self::Btc => write!(f, "btc"),
28 }
29 }
30}
31
32impl std::str::FromStr for Network {
33 type Err = String;
34 fn from_str(s: &str) -> Result<Self, Self::Err> {
35 match s {
36 "ln" => Ok(Self::Ln),
37 "sol" => Ok(Self::Sol),
38 "evm" => Ok(Self::Evm),
39 "cashu" => Ok(Self::Cashu),
40 "btc" => Ok(Self::Btc),
41 _ => Err(format!(
42 "unknown network '{s}'; expected: cashu, ln, sol, evm, btc"
43 )),
44 }
45 }
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct WalletCreateRequest {
50 pub label: String,
51 #[serde(default, skip_serializing_if = "Option::is_none")]
52 pub mint_url: Option<String>,
53 #[serde(default, skip_serializing_if = "Vec::is_empty")]
54 pub rpc_endpoints: Vec<String>,
55 #[serde(default, skip_serializing_if = "Option::is_none")]
56 pub chain_id: Option<u64>,
57 #[serde(default, skip_serializing_if = "Option::is_none")]
58 pub mnemonic_secret: Option<String>,
59 #[serde(default, skip_serializing_if = "Option::is_none")]
61 pub btc_esplora_url: Option<String>,
62 #[serde(default, skip_serializing_if = "Option::is_none")]
64 pub btc_network: Option<String>,
65 #[serde(default, skip_serializing_if = "Option::is_none")]
67 pub btc_address_type: Option<String>,
68 #[serde(default, skip_serializing_if = "Option::is_none")]
70 pub btc_backend: Option<BtcBackend>,
71 #[serde(default, skip_serializing_if = "Option::is_none")]
73 pub btc_core_url: Option<String>,
74 #[serde(default, skip_serializing_if = "Option::is_none")]
76 pub btc_core_auth_secret: Option<String>,
77 #[serde(default, skip_serializing_if = "Option::is_none")]
79 pub btc_electrum_url: Option<String>,
80}
81
82#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
83#[serde(rename_all = "lowercase")]
84pub enum Direction {
85 Send,
86 Receive,
87}
88
89#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
90#[serde(rename_all = "lowercase")]
91pub enum TxStatus {
92 Pending,
93 Confirmed,
94 Failed,
95}
96
97#[derive(Debug, Clone, Serialize, Deserialize)]
102#[cfg_attr(feature = "mcp", derive(schemars::JsonSchema))]
103pub struct Amount {
104 pub value: u64,
105 pub token: String,
106}
107
108#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
109#[cfg_attr(feature = "mcp", derive(schemars::JsonSchema))]
110#[serde(rename_all = "snake_case")]
111pub enum LnWalletBackend {
112 Nwc,
113 Phoenixd,
114 Lnbits,
115}
116
117impl LnWalletBackend {
118 pub fn as_str(self) -> &'static str {
119 match self {
120 Self::Nwc => "nwc",
121 Self::Phoenixd => "phoenixd",
122 Self::Lnbits => "lnbits",
123 }
124 }
125}
126
127#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
128#[cfg_attr(feature = "mcp", derive(schemars::JsonSchema))]
129#[serde(rename_all = "kebab-case")]
130pub enum BtcBackend {
131 Esplora,
132 CoreRpc,
133 Electrum,
134}
135
136impl BtcBackend {
137 pub fn as_str(self) -> &'static str {
138 match self {
139 Self::Esplora => "esplora",
140 Self::CoreRpc => "core-rpc",
141 Self::Electrum => "electrum",
142 }
143 }
144}
145
146#[derive(Debug, Clone, Serialize, Deserialize)]
147pub struct LnWalletCreateRequest {
148 pub backend: LnWalletBackend,
149 #[serde(default, skip_serializing_if = "Option::is_none")]
150 pub label: Option<String>,
151 #[serde(default, skip_serializing_if = "Option::is_none")]
152 pub nwc_uri_secret: Option<String>,
153 #[serde(default, skip_serializing_if = "Option::is_none")]
154 pub endpoint: Option<String>,
155 #[serde(default, skip_serializing_if = "Option::is_none")]
156 pub password_secret: Option<String>,
157 #[serde(default, skip_serializing_if = "Option::is_none")]
158 pub admin_key_secret: Option<String>,
159}
160
161#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
162#[cfg_attr(feature = "mcp", derive(schemars::JsonSchema))]
163#[serde(rename_all = "snake_case")]
164pub enum SpendScope {
165 #[serde(alias = "all")]
166 GlobalUsdCents,
167 Network,
168 Wallet,
169}
170
171fn default_spend_scope_network() -> SpendScope {
172 SpendScope::Network
173}
174
175#[derive(Debug, Clone, Serialize, Deserialize)]
176#[cfg_attr(feature = "mcp", derive(schemars::JsonSchema))]
177pub struct SpendLimit {
178 #[serde(default, skip_serializing_if = "Option::is_none")]
179 pub rule_id: Option<String>,
180 #[serde(default = "default_spend_scope_network")]
181 pub scope: SpendScope,
182 #[serde(default, skip_serializing_if = "Option::is_none")]
183 pub network: Option<String>,
184 #[serde(default, skip_serializing_if = "Option::is_none")]
185 pub wallet: Option<String>,
186 pub window_s: u64,
187 pub max_spend: u64,
188 #[serde(default, skip_serializing_if = "Option::is_none")]
189 pub token: Option<String>,
190}
191
192#[derive(Debug, Clone, Serialize, Deserialize)]
193pub struct SpendLimitStatus {
194 pub rule_id: String,
195 pub scope: SpendScope,
196 #[serde(default, skip_serializing_if = "Option::is_none")]
197 pub network: Option<String>,
198 #[serde(default, skip_serializing_if = "Option::is_none")]
199 pub wallet: Option<String>,
200 pub window_s: u64,
201 pub max_spend: u64,
202 pub spent: u64,
203 pub remaining: u64,
204 #[serde(default, skip_serializing_if = "Option::is_none")]
205 pub token: Option<String>,
206 pub window_reset_s: u64,
207}
208
209#[derive(Debug, Clone, Serialize, Deserialize)]
210pub struct DownstreamLimitNode {
211 pub name: String,
212 pub endpoint: String,
213 #[serde(default, skip_serializing_if = "Vec::is_empty")]
214 pub limits: Vec<SpendLimitStatus>,
215 #[serde(default, skip_serializing_if = "Option::is_none")]
216 pub error: Option<String>,
217 #[serde(default, skip_serializing_if = "Vec::is_empty")]
218 pub downstream: Vec<DownstreamLimitNode>,
219}
220
221#[derive(Debug, Clone, Serialize, Deserialize)]
222pub struct WalletInfo {
223 pub id: String,
224 pub network: Network,
225 pub address: String,
226 #[serde(skip_serializing_if = "Option::is_none")]
227 pub label: Option<String>,
228 #[serde(skip_serializing_if = "Option::is_none")]
229 pub mnemonic: Option<String>,
230}
231
232#[derive(Debug, Clone, Serialize, Deserialize)]
233pub struct WalletSummary {
234 pub id: String,
235 pub network: Network,
236 #[serde(skip_serializing_if = "Option::is_none")]
237 pub label: Option<String>,
238 pub address: String,
239 #[serde(default, skip_serializing_if = "Option::is_none")]
240 pub backend: Option<String>,
241 #[serde(default, skip_serializing_if = "Option::is_none")]
242 pub mint_url: Option<String>,
243 #[serde(default, skip_serializing_if = "Option::is_none")]
244 pub rpc_endpoints: Option<Vec<String>>,
245 #[serde(default, skip_serializing_if = "Option::is_none")]
246 pub chain_id: Option<u64>,
247 pub created_at_epoch_s: u64,
248}
249
250#[derive(Debug, Clone, Serialize, Deserialize)]
251pub struct BalanceInfo {
252 pub confirmed: u64,
253 pub pending: u64,
254 pub unit: String,
256 #[serde(default, flatten, skip_serializing_if = "BTreeMap::is_empty")]
259 pub additional: BTreeMap<String, u64>,
260}
261
262impl BalanceInfo {
263 pub fn new(confirmed: u64, pending: u64, unit: impl Into<String>) -> Self {
264 Self {
265 confirmed,
266 pending,
267 unit: unit.into(),
268 additional: BTreeMap::new(),
269 }
270 }
271
272 pub fn with_additional(mut self, key: impl Into<String>, value: u64) -> Self {
273 self.additional.insert(key.into(), value);
274 self
275 }
276
277 pub fn non_zero_components(&self) -> Vec<(String, u64)> {
278 let mut components = Vec::new();
279 if self.confirmed > 0 {
280 components.push((format!("confirmed_{}", self.unit), self.confirmed));
281 }
282 if self.pending > 0 {
283 components.push((format!("pending_{}", self.unit), self.pending));
284 }
285 for (key, value) in &self.additional {
286 if *value > 0 {
287 components.push((key.clone(), *value));
288 }
289 }
290 components
291 }
292}
293
294#[derive(Debug, Clone, Serialize, Deserialize)]
295pub struct WalletBalanceItem {
296 #[serde(flatten)]
297 pub wallet: WalletSummary,
298 #[serde(default, skip_serializing_if = "Option::is_none")]
299 pub balance: Option<BalanceInfo>,
300 #[serde(default, skip_serializing_if = "Option::is_none")]
301 pub error: Option<String>,
302}
303
304#[derive(Debug, Clone, Serialize, Deserialize)]
305pub struct ReceiveInfo {
306 #[serde(skip_serializing_if = "Option::is_none")]
307 pub address: Option<String>,
308 #[serde(skip_serializing_if = "Option::is_none")]
309 pub invoice: Option<String>,
310 #[serde(skip_serializing_if = "Option::is_none")]
311 pub quote_id: Option<String>,
312}
313
314#[derive(Debug, Clone, Serialize, Deserialize)]
315pub struct HistoryRecord {
316 pub transaction_id: String,
317 pub wallet: String,
318 pub network: Network,
319 pub direction: Direction,
320 pub amount: Amount,
321 pub status: TxStatus,
322 #[serde(skip_serializing_if = "Option::is_none")]
323 pub onchain_memo: Option<String>,
324 #[serde(
325 default,
326 skip_serializing_if = "Option::is_none",
327 deserialize_with = "deserialize_local_memo"
328 )]
329 pub local_memo: Option<BTreeMap<String, String>>,
330 #[serde(skip_serializing_if = "Option::is_none")]
331 pub remote_addr: Option<String>,
332 #[serde(default, skip_serializing_if = "Option::is_none")]
333 pub preimage: Option<String>,
334 pub created_at_epoch_s: u64,
335 #[serde(skip_serializing_if = "Option::is_none")]
336 pub confirmed_at_epoch_s: Option<u64>,
337 #[serde(default, skip_serializing_if = "Option::is_none")]
338 pub fee: Option<Amount>,
339}
340
341#[derive(Debug, Clone, Serialize)]
342pub struct CashuSendResult {
343 pub wallet: String,
344 pub transaction_id: String,
345 pub status: TxStatus,
346 pub fee: Option<Amount>,
347 pub token: String,
348}
349
350#[derive(Debug, Clone, Serialize)]
351pub struct CashuReceiveResult {
352 pub wallet: String,
353 pub amount: Amount,
354}
355
356#[derive(Debug, Clone, Serialize)]
357pub struct RestoreResult {
358 pub wallet: String,
359 pub unspent: u64,
360 pub spent: u64,
361 pub pending: u64,
362 pub unit: String,
363}
364
365#[derive(Debug, Clone, Serialize)]
366pub struct CashuSendQuoteInfo {
367 pub wallet: String,
368 pub amount_native: u64,
369 pub fee_native: u64,
370 pub fee_unit: String,
371}
372
373#[derive(Debug, Clone, Serialize)]
374pub struct SendQuoteInfo {
375 pub wallet: String,
376 pub amount_native: u64,
377 pub fee_estimate_native: u64,
378 pub fee_unit: String,
379}
380
381#[derive(Debug, Clone, Serialize)]
382pub struct SendResult {
383 pub wallet: String,
384 pub transaction_id: String,
385 pub amount: Amount,
386 pub fee: Option<Amount>,
387 pub preimage: Option<String>,
388}
389
390#[derive(Debug, Clone, Serialize)]
391pub struct HistoryStatusInfo {
392 pub transaction_id: String,
393 pub status: TxStatus,
394 pub confirmations: Option<u32>,
395 pub preimage: Option<String>,
396 pub item: Option<HistoryRecord>,
397}
398
399#[derive(Debug, Serialize, Clone)]
404pub struct Trace {
405 pub duration_ms: u64,
406}
407
408impl Trace {
409 pub fn from_duration(duration_ms: u64) -> Self {
410 Self { duration_ms }
411 }
412}
413
414#[derive(Debug, Serialize)]
415pub struct PongTrace {
416 pub uptime_s: u64,
417 pub requests_total: u64,
418 pub in_flight: usize,
419}
420
421#[derive(Debug, Serialize)]
422pub struct CloseTrace {
423 pub uptime_s: u64,
424 pub requests_total: u64,
425}
426
427#[derive(Debug, Serialize, Deserialize)]
432#[serde(tag = "code")]
433pub enum Input {
434 #[serde(rename = "wallet_create")]
435 WalletCreate {
436 id: String,
437 network: Network,
438 #[serde(default)]
439 label: Option<String>,
440 #[serde(default, skip_serializing_if = "Option::is_none")]
442 mint_url: Option<String>,
443 #[serde(default, skip_serializing_if = "Vec::is_empty")]
445 rpc_endpoints: Vec<String>,
446 #[serde(default, skip_serializing_if = "Option::is_none")]
448 chain_id: Option<u64>,
449 #[serde(default, skip_serializing_if = "Option::is_none")]
450 mnemonic_secret: Option<String>,
451 #[serde(default, skip_serializing_if = "Option::is_none")]
453 btc_esplora_url: Option<String>,
454 #[serde(default, skip_serializing_if = "Option::is_none")]
456 btc_network: Option<String>,
457 #[serde(default, skip_serializing_if = "Option::is_none")]
459 btc_address_type: Option<String>,
460 #[serde(default, skip_serializing_if = "Option::is_none")]
462 btc_backend: Option<BtcBackend>,
463 #[serde(default, skip_serializing_if = "Option::is_none")]
465 btc_core_url: Option<String>,
466 #[serde(default, skip_serializing_if = "Option::is_none")]
468 btc_core_auth_secret: Option<String>,
469 #[serde(default, skip_serializing_if = "Option::is_none")]
471 btc_electrum_url: Option<String>,
472 },
473 #[serde(rename = "ln_wallet_create")]
474 LnWalletCreate {
475 id: String,
476 #[serde(flatten)]
477 request: LnWalletCreateRequest,
478 },
479 #[serde(rename = "wallet_close")]
480 WalletClose {
481 id: String,
482 wallet: String,
483 #[serde(default)]
484 dangerously_skip_balance_check_and_may_lose_money: bool,
485 },
486 #[serde(rename = "wallet_list")]
487 WalletList {
488 id: String,
489 #[serde(default)]
490 network: Option<Network>,
491 },
492 #[serde(rename = "balance")]
493 Balance {
494 id: String,
495 #[serde(default)]
496 wallet: Option<String>,
497 #[serde(default, skip_serializing_if = "Option::is_none")]
498 network: Option<Network>,
499 #[serde(default)]
500 check: bool,
501 },
502 #[serde(rename = "receive")]
503 Receive {
504 id: String,
505 wallet: String,
506 #[serde(default, skip_serializing_if = "Option::is_none")]
507 network: Option<Network>,
508 #[serde(default)]
509 amount: Option<Amount>,
510 #[serde(default, skip_serializing_if = "Option::is_none")]
511 onchain_memo: Option<String>,
512 #[serde(default)]
513 wait_until_paid: bool,
514 #[serde(default)]
515 wait_timeout_s: Option<u64>,
516 #[serde(default)]
517 wait_poll_interval_ms: Option<u64>,
518 #[serde(default)]
519 write_qr_svg_file: bool,
520 #[serde(default, skip_serializing_if = "Option::is_none")]
521 min_confirmations: Option<u32>,
522 },
523 #[serde(rename = "receive_claim")]
524 ReceiveClaim {
525 id: String,
526 wallet: String,
527 quote_id: String,
528 },
529
530 #[serde(rename = "cashu_send")]
531 CashuSend {
532 id: String,
533 #[serde(default)]
534 wallet: Option<String>,
535 amount: Amount,
536 #[serde(default)]
537 onchain_memo: Option<String>,
538 #[serde(default, deserialize_with = "deserialize_local_memo")]
539 local_memo: Option<BTreeMap<String, String>>,
540 #[serde(default, skip_serializing_if = "Option::is_none")]
542 mints: Option<Vec<String>>,
543 },
544 #[serde(rename = "cashu_receive")]
545 CashuReceive {
546 id: String,
547 #[serde(default)]
548 wallet: Option<String>,
549 token: String,
550 },
551 #[serde(rename = "send")]
552 Send {
553 id: String,
554 #[serde(default)]
555 wallet: Option<String>,
556 #[serde(default, skip_serializing_if = "Option::is_none")]
557 network: Option<Network>,
558 to: String,
559 #[serde(default)]
560 onchain_memo: Option<String>,
561 #[serde(default, deserialize_with = "deserialize_local_memo")]
562 local_memo: Option<BTreeMap<String, String>>,
563 #[serde(default, skip_serializing_if = "Option::is_none")]
565 mints: Option<Vec<String>>,
566 },
567
568 #[serde(rename = "restore")]
569 Restore { id: String, wallet: String },
570 #[serde(rename = "local_wallet_show_seed")]
571 WalletShowSeed { id: String, wallet: String },
572
573 #[serde(rename = "history")]
574 HistoryList {
575 id: String,
576 #[serde(default)]
577 wallet: Option<String>,
578 #[serde(default, skip_serializing_if = "Option::is_none")]
579 network: Option<Network>,
580 #[serde(default, skip_serializing_if = "Option::is_none")]
581 onchain_memo: Option<String>,
582 #[serde(default)]
583 limit: Option<usize>,
584 #[serde(default)]
585 offset: Option<usize>,
586 #[serde(default, skip_serializing_if = "Option::is_none")]
588 since_epoch_s: Option<u64>,
589 #[serde(default, skip_serializing_if = "Option::is_none")]
591 until_epoch_s: Option<u64>,
592 },
593 #[serde(rename = "history_status")]
594 HistoryStatus { id: String, transaction_id: String },
595 #[serde(rename = "history_update")]
596 HistoryUpdate {
597 id: String,
598 #[serde(default)]
599 wallet: Option<String>,
600 #[serde(default, skip_serializing_if = "Option::is_none")]
601 network: Option<Network>,
602 #[serde(default)]
603 limit: Option<usize>,
604 },
605
606 #[serde(rename = "limit_add")]
607 LimitAdd { id: String, limit: SpendLimit },
608 #[serde(rename = "limit_remove")]
609 LimitRemove { id: String, rule_id: String },
610 #[serde(rename = "limit_list")]
611 LimitList { id: String },
612 #[serde(rename = "limit_set")]
613 LimitSet { id: String, limits: Vec<SpendLimit> },
614
615 #[serde(rename = "wallet_config_show")]
616 WalletConfigShow { id: String, wallet: String },
617 #[serde(rename = "wallet_config_set")]
618 WalletConfigSet {
619 id: String,
620 wallet: String,
621 #[serde(default, skip_serializing_if = "Option::is_none")]
622 label: Option<String>,
623 #[serde(default, skip_serializing_if = "Vec::is_empty")]
624 rpc_endpoints: Vec<String>,
625 #[serde(default, skip_serializing_if = "Option::is_none")]
626 chain_id: Option<u64>,
627 },
628 #[serde(rename = "wallet_config_token_add")]
629 WalletConfigTokenAdd {
630 id: String,
631 wallet: String,
632 symbol: String,
633 address: String,
634 decimals: u8,
635 },
636 #[serde(rename = "wallet_config_token_remove")]
637 WalletConfigTokenRemove {
638 id: String,
639 wallet: String,
640 symbol: String,
641 },
642
643 #[serde(rename = "config")]
644 Config(ConfigPatch),
645 #[serde(rename = "version")]
646 Version,
647 #[serde(rename = "close")]
648 Close,
649}
650
651impl Input {
652 pub fn is_local_only(&self) -> bool {
654 matches!(
655 self,
656 Input::WalletShowSeed { .. }
657 | Input::WalletClose {
658 dangerously_skip_balance_check_and_may_lose_money: true,
659 ..
660 }
661 | Input::LimitAdd { .. }
662 | Input::LimitRemove { .. }
663 | Input::LimitSet { .. }
664 | Input::WalletConfigSet { .. }
665 | Input::WalletConfigTokenAdd { .. }
666 | Input::WalletConfigTokenRemove { .. }
667 | Input::Restore { .. }
668 | Input::Config(_)
669 )
670 }
671}
672
673#[derive(Debug, Serialize)]
678#[serde(tag = "code")]
679pub enum Output {
680 #[serde(rename = "wallet_created")]
681 WalletCreated {
682 id: String,
683 wallet: String,
684 network: Network,
685 address: String,
686 #[serde(skip_serializing_if = "Option::is_none")]
687 mnemonic: Option<String>,
688 trace: Trace,
689 },
690 #[serde(rename = "wallet_closed")]
691 WalletClosed {
692 id: String,
693 wallet: String,
694 trace: Trace,
695 },
696 #[serde(rename = "wallet_list")]
697 WalletList {
698 id: String,
699 wallets: Vec<WalletSummary>,
700 trace: Trace,
701 },
702 #[serde(rename = "wallet_balances")]
703 WalletBalances {
704 id: String,
705 wallets: Vec<WalletBalanceItem>,
706 trace: Trace,
707 },
708 #[serde(rename = "receive_info")]
709 ReceiveInfo {
710 id: String,
711 wallet: String,
712 receive_info: ReceiveInfo,
713 trace: Trace,
714 },
715 #[serde(rename = "receive_claimed")]
716 ReceiveClaimed {
717 id: String,
718 wallet: String,
719 amount: Amount,
720 trace: Trace,
721 },
722
723 #[serde(rename = "cashu_sent")]
724 CashuSent {
725 id: String,
726 wallet: String,
727 transaction_id: String,
728 status: TxStatus,
729 #[serde(skip_serializing_if = "Option::is_none")]
730 fee: Option<Amount>,
731 token: String,
732 trace: Trace,
733 },
734
735 #[serde(rename = "history")]
736 History {
737 id: String,
738 items: Vec<HistoryRecord>,
739 trace: Trace,
740 },
741 #[serde(rename = "history_status")]
742 HistoryStatus {
743 id: String,
744 transaction_id: String,
745 status: TxStatus,
746 #[serde(skip_serializing_if = "Option::is_none")]
747 confirmations: Option<u32>,
748 #[serde(skip_serializing_if = "Option::is_none")]
749 preimage: Option<String>,
750 #[serde(skip_serializing_if = "Option::is_none")]
751 item: Option<HistoryRecord>,
752 trace: Trace,
753 },
754 #[serde(rename = "history_updated")]
755 HistoryUpdated {
756 id: String,
757 wallets_synced: usize,
758 records_scanned: usize,
759 records_added: usize,
760 records_updated: usize,
761 trace: Trace,
762 },
763
764 #[serde(rename = "limit_added")]
765 LimitAdded {
766 id: String,
767 rule_id: String,
768 trace: Trace,
769 },
770 #[serde(rename = "limit_removed")]
771 LimitRemoved {
772 id: String,
773 rule_id: String,
774 trace: Trace,
775 },
776 #[serde(rename = "limit_status")]
777 LimitStatus {
778 id: String,
779 limits: Vec<SpendLimitStatus>,
780 #[serde(default, skip_serializing_if = "Vec::is_empty")]
781 downstream: Vec<DownstreamLimitNode>,
782 trace: Trace,
783 },
784 #[serde(rename = "limit_exceeded")]
785 #[allow(dead_code)]
786 LimitExceeded {
787 id: String,
788 rule_id: String,
789 scope: SpendScope,
790 scope_key: String,
791 spent: u64,
792 max_spend: u64,
793 #[serde(skip_serializing_if = "Option::is_none")]
794 token: Option<String>,
795 remaining_s: u64,
796 #[serde(skip_serializing_if = "Option::is_none")]
797 origin: Option<String>,
798 trace: Trace,
799 },
800
801 #[serde(rename = "cashu_received")]
802 CashuReceived {
803 id: String,
804 wallet: String,
805 amount: Amount,
806 trace: Trace,
807 },
808 #[serde(rename = "restored")]
809 Restored {
810 id: String,
811 wallet: String,
812 unspent: u64,
813 spent: u64,
814 pending: u64,
815 unit: String,
816 trace: Trace,
817 },
818 #[serde(rename = "wallet_seed")]
819 WalletSeed {
820 id: String,
821 wallet: String,
822 mnemonic_secret: String,
823 trace: Trace,
824 },
825
826 #[serde(rename = "sent")]
827 Sent {
828 id: String,
829 wallet: String,
830 transaction_id: String,
831 amount: Amount,
832 #[serde(skip_serializing_if = "Option::is_none")]
833 fee: Option<Amount>,
834 #[serde(skip_serializing_if = "Option::is_none")]
835 preimage: Option<String>,
836 trace: Trace,
837 },
838
839 #[serde(rename = "wallet_config")]
840 WalletConfig {
841 id: String,
842 wallet: String,
843 config: WalletMetadata,
844 trace: Trace,
845 },
846 #[serde(rename = "wallet_config_updated")]
847 WalletConfigUpdated {
848 id: String,
849 wallet: String,
850 trace: Trace,
851 },
852 #[serde(rename = "wallet_config_token_added")]
853 WalletConfigTokenAdded {
854 id: String,
855 wallet: String,
856 symbol: String,
857 address: String,
858 decimals: u8,
859 trace: Trace,
860 },
861 #[serde(rename = "wallet_config_token_removed")]
862 WalletConfigTokenRemoved {
863 id: String,
864 wallet: String,
865 symbol: String,
866 trace: Trace,
867 },
868
869 #[serde(rename = "error")]
870 Error {
871 #[serde(skip_serializing_if = "Option::is_none")]
872 id: Option<String>,
873 error_code: String,
874 error: String,
875 #[serde(skip_serializing_if = "Option::is_none")]
876 hint: Option<String>,
877 retryable: bool,
878 trace: Trace,
879 },
880
881 #[serde(rename = "dry_run")]
882 DryRun {
883 #[serde(skip_serializing_if = "Option::is_none")]
884 id: Option<String>,
885 command: String,
886 params: serde_json::Value,
887 trace: Trace,
888 },
889
890 #[serde(rename = "config")]
891 Config(RuntimeConfig),
892 #[serde(rename = "version")]
893 Version { version: String, trace: PongTrace },
894 #[serde(rename = "close")]
895 Close { message: String, trace: CloseTrace },
896 #[serde(rename = "log")]
897 Log {
898 event: String,
899 #[serde(skip_serializing_if = "Option::is_none")]
900 request_id: Option<String>,
901 #[serde(skip_serializing_if = "Option::is_none")]
902 version: Option<String>,
903 #[serde(skip_serializing_if = "Option::is_none")]
904 argv: Option<Vec<String>>,
905 #[serde(skip_serializing_if = "Option::is_none")]
906 config: Option<serde_json::Value>,
907 #[serde(skip_serializing_if = "Option::is_none")]
908 args: Option<serde_json::Value>,
909 #[serde(skip_serializing_if = "Option::is_none")]
910 env: Option<serde_json::Value>,
911 trace: Trace,
912 },
913}
914
915#[derive(Debug, Serialize, Deserialize, Clone)]
920pub struct RuntimeConfig {
921 #[serde(default)]
922 pub data_dir: String,
923 #[serde(default, skip_serializing_if = "Option::is_none")]
924 pub rpc_endpoint: Option<String>,
925 #[serde(default, skip_serializing_if = "Option::is_none")]
926 pub rpc_secret: Option<String>,
927 #[serde(default)]
928 pub limits: Vec<SpendLimit>,
929 #[serde(default)]
930 pub log: Vec<String>,
931 #[serde(default, skip_serializing_if = "Option::is_none")]
932 pub exchange_rate: Option<ExchangeRateConfig>,
933 #[serde(default)]
935 pub afpay_rpc: std::collections::HashMap<String, AfpayRpcConfig>,
936 #[serde(default)]
938 pub providers: std::collections::HashMap<String, String>,
939 #[serde(default, skip_serializing_if = "Option::is_none")]
941 pub storage_backend: Option<String>,
942 #[serde(default, skip_serializing_if = "Option::is_none")]
944 pub postgres_url_secret: Option<String>,
945}
946
947impl Default for RuntimeConfig {
948 fn default() -> Self {
949 Self {
950 data_dir: default_data_dir(),
951 rpc_endpoint: None,
952 rpc_secret: None,
953 limits: vec![],
954 log: vec![],
955 exchange_rate: None,
956 afpay_rpc: std::collections::HashMap::new(),
957 providers: std::collections::HashMap::new(),
958 storage_backend: None,
959 postgres_url_secret: None,
960 }
961 }
962}
963
964fn default_data_dir() -> String {
965 if let Some(val) = std::env::var_os("AFPAY_HOME") {
967 return std::path::PathBuf::from(val).to_string_lossy().into_owned();
968 }
969 if let Some(home) = std::env::var_os("HOME") {
970 let mut p = std::path::PathBuf::from(home);
971 p.push(".afpay");
972 p.to_string_lossy().into_owned()
973 } else {
974 ".afpay".to_string()
975 }
976}
977
978#[derive(Debug, Serialize, Deserialize, Clone)]
979pub struct AfpayRpcConfig {
980 pub endpoint: String,
981 #[serde(default, skip_serializing_if = "Option::is_none")]
982 pub endpoint_secret: Option<String>,
983}
984
985#[derive(Debug, Serialize, Deserialize, Clone)]
986pub struct ExchangeRateConfig {
987 #[serde(default = "default_exchange_rate_ttl_s")]
988 pub ttl_s: u64,
989 #[serde(default = "default_exchange_rate_sources")]
990 pub sources: Vec<ExchangeRateSource>,
991}
992
993impl Default for ExchangeRateConfig {
994 fn default() -> Self {
995 Self {
996 ttl_s: default_exchange_rate_ttl_s(),
997 sources: default_exchange_rate_sources(),
998 }
999 }
1000}
1001
1002#[derive(Debug, Serialize, Deserialize, Clone)]
1003pub struct ExchangeRateSource {
1004 #[serde(rename = "type")]
1005 pub source_type: ExchangeRateSourceType,
1006 pub endpoint: String,
1007 #[serde(default, skip_serializing_if = "Option::is_none")]
1008 pub api_key: Option<String>,
1009}
1010
1011#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1012#[serde(rename_all = "snake_case")]
1013pub enum ExchangeRateSourceType {
1014 Generic,
1015 CoinGecko,
1016 Kraken,
1017}
1018
1019fn default_exchange_rate_ttl_s() -> u64 {
1020 300
1021}
1022
1023fn default_exchange_rate_sources() -> Vec<ExchangeRateSource> {
1024 vec![
1025 ExchangeRateSource {
1026 source_type: ExchangeRateSourceType::Kraken,
1027 endpoint: "https://api.kraken.com".to_string(),
1028 api_key: None,
1029 },
1030 ExchangeRateSource {
1031 source_type: ExchangeRateSourceType::CoinGecko,
1032 endpoint: "https://api.coingecko.com/api/v3".to_string(),
1033 api_key: None,
1034 },
1035 ]
1036}
1037
1038#[derive(Debug, Serialize, Deserialize, Default)]
1039pub struct ConfigPatch {
1040 #[serde(default)]
1041 pub data_dir: Option<String>,
1042 #[serde(default)]
1043 pub limits: Option<Vec<SpendLimit>>,
1044 #[serde(default)]
1045 pub log: Option<Vec<String>>,
1046 #[serde(default)]
1047 pub exchange_rate: Option<ExchangeRateConfig>,
1048 #[serde(default)]
1049 pub afpay_rpc: Option<std::collections::HashMap<String, AfpayRpcConfig>>,
1050 #[serde(default)]
1051 pub providers: Option<std::collections::HashMap<String, String>>,
1052}
1053
1054fn deserialize_local_memo<'de, D>(d: D) -> Result<Option<BTreeMap<String, String>>, D::Error>
1057where
1058 D: Deserializer<'de>,
1059{
1060 use serde::de;
1061
1062 struct LocalMemoVisitor;
1063
1064 impl<'de> de::Visitor<'de> for LocalMemoVisitor {
1065 type Value = Option<BTreeMap<String, String>>;
1066
1067 fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
1068 f.write_str("null, a string, or a map of string→string")
1069 }
1070
1071 fn visit_none<E: de::Error>(self) -> Result<Self::Value, E> {
1072 Ok(None)
1073 }
1074
1075 fn visit_unit<E: de::Error>(self) -> Result<Self::Value, E> {
1076 Ok(None)
1077 }
1078
1079 fn visit_str<E: de::Error>(self, v: &str) -> Result<Self::Value, E> {
1080 let mut m = BTreeMap::new();
1081 m.insert("note".to_string(), v.to_string());
1082 Ok(Some(m))
1083 }
1084
1085 fn visit_string<E: de::Error>(self, v: String) -> Result<Self::Value, E> {
1086 let mut m = BTreeMap::new();
1087 m.insert("note".to_string(), v);
1088 Ok(Some(m))
1089 }
1090
1091 fn visit_map<A: de::MapAccess<'de>>(self, mut map: A) -> Result<Self::Value, A::Error> {
1092 let mut m = BTreeMap::new();
1093 while let Some((k, v)) = map.next_entry::<String, String>()? {
1094 m.insert(k, v);
1095 }
1096 Ok(Some(m))
1097 }
1098
1099 fn visit_some<D2: Deserializer<'de>>(self, d: D2) -> Result<Self::Value, D2::Error> {
1100 d.deserialize_any(Self)
1101 }
1102 }
1103
1104 d.deserialize_option(LocalMemoVisitor)
1105}
1106
1107#[cfg(test)]
1108mod tests {
1109 use super::*;
1110
1111 #[test]
1112 fn local_only_checks() {
1113 assert!(Input::WalletShowSeed {
1115 id: "t".into(),
1116 wallet: "w".into(),
1117 }
1118 .is_local_only());
1119
1120 assert!(Input::WalletClose {
1121 id: "t".into(),
1122 wallet: "w".into(),
1123 dangerously_skip_balance_check_and_may_lose_money: true,
1124 }
1125 .is_local_only());
1126
1127 assert!(!Input::WalletClose {
1128 id: "t".into(),
1129 wallet: "w".into(),
1130 dangerously_skip_balance_check_and_may_lose_money: false,
1131 }
1132 .is_local_only());
1133
1134 assert!(Input::LimitAdd {
1136 id: "t".into(),
1137 limit: SpendLimit {
1138 rule_id: None,
1139 scope: SpendScope::GlobalUsdCents,
1140 network: None,
1141 wallet: None,
1142 window_s: 3600,
1143 max_spend: 1000,
1144 token: None,
1145 },
1146 }
1147 .is_local_only());
1148
1149 assert!(Input::LimitRemove {
1150 id: "t".into(),
1151 rule_id: "r_1".into(),
1152 }
1153 .is_local_only());
1154
1155 assert!(Input::LimitSet {
1156 id: "t".into(),
1157 limits: vec![],
1158 }
1159 .is_local_only());
1160
1161 assert!(!Input::LimitList { id: "t".into() }.is_local_only());
1163
1164 assert!(Input::WalletConfigSet {
1166 id: "t".into(),
1167 wallet: "w".into(),
1168 label: None,
1169 rpc_endpoints: vec![],
1170 chain_id: None,
1171 }
1172 .is_local_only());
1173
1174 assert!(Input::WalletConfigTokenAdd {
1175 id: "t".into(),
1176 wallet: "w".into(),
1177 symbol: "dai".into(),
1178 address: "0x".into(),
1179 decimals: 18,
1180 }
1181 .is_local_only());
1182
1183 assert!(Input::WalletConfigTokenRemove {
1184 id: "t".into(),
1185 wallet: "w".into(),
1186 symbol: "dai".into(),
1187 }
1188 .is_local_only());
1189
1190 assert!(!Input::WalletConfigShow {
1192 id: "t".into(),
1193 wallet: "w".into(),
1194 }
1195 .is_local_only());
1196
1197 assert!(Input::Restore {
1199 id: "t".into(),
1200 wallet: "w".into(),
1201 }
1202 .is_local_only());
1203 }
1204
1205 #[test]
1206 fn wallet_seed_output_uses_mnemonic_secret_field() {
1207 let out = Output::WalletSeed {
1208 id: "t_1".to_string(),
1209 wallet: "w_1".to_string(),
1210 mnemonic_secret: "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about".to_string(),
1211 trace: Trace::from_duration(0),
1212 };
1213 let value = serde_json::to_value(out).expect("serialize wallet_seed output");
1214 assert_eq!(
1215 value.get("mnemonic_secret").and_then(|v| v.as_str()),
1216 Some(
1217 "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
1218 )
1219 );
1220 assert!(value.get("mnemonic").is_none());
1221 }
1222
1223 #[test]
1224 fn history_list_parses_time_range_fields() {
1225 let json = r#"{
1226 "code": "history",
1227 "id": "t_1",
1228 "wallet": "w_1",
1229 "limit": 10,
1230 "offset": 0,
1231 "since_epoch_s": 1700000000,
1232 "until_epoch_s": 1700100000
1233 }"#;
1234 let input: Input = serde_json::from_str(json).expect("parse history_list with time range");
1235 match input {
1236 Input::HistoryList {
1237 since_epoch_s,
1238 until_epoch_s,
1239 ..
1240 } => {
1241 assert_eq!(since_epoch_s, Some(1_700_000_000));
1242 assert_eq!(until_epoch_s, Some(1_700_100_000));
1243 }
1244 other => panic!("expected HistoryList, got {other:?}"),
1245 }
1246 }
1247
1248 #[test]
1249 fn history_list_time_range_fields_default_to_none() {
1250 let json = r#"{
1251 "code": "history",
1252 "id": "t_1",
1253 "limit": 10,
1254 "offset": 0
1255 }"#;
1256 let input: Input =
1257 serde_json::from_str(json).expect("parse history_list without time range");
1258 match input {
1259 Input::HistoryList {
1260 since_epoch_s,
1261 until_epoch_s,
1262 ..
1263 } => {
1264 assert_eq!(since_epoch_s, None);
1265 assert_eq!(until_epoch_s, None);
1266 }
1267 other => panic!("expected HistoryList, got {other:?}"),
1268 }
1269 }
1270
1271 #[test]
1272 fn history_update_parses_sync_fields() {
1273 let json = r#"{
1274 "code": "history_update",
1275 "id": "t_2",
1276 "wallet": "w_1",
1277 "network": "sol",
1278 "limit": 150
1279 }"#;
1280 let input: Input = serde_json::from_str(json).expect("parse history_update");
1281 match input {
1282 Input::HistoryUpdate {
1283 wallet,
1284 network,
1285 limit,
1286 ..
1287 } => {
1288 assert_eq!(wallet.as_deref(), Some("w_1"));
1289 assert_eq!(network, Some(Network::Sol));
1290 assert_eq!(limit, Some(150));
1291 }
1292 other => panic!("expected HistoryUpdate, got {other:?}"),
1293 }
1294 }
1295
1296 #[test]
1297 fn history_update_fields_default_to_none() {
1298 let json = r#"{
1299 "code": "history_update",
1300 "id": "t_3"
1301 }"#;
1302 let input: Input = serde_json::from_str(json).expect("parse history_update defaults");
1303 match input {
1304 Input::HistoryUpdate {
1305 wallet,
1306 network,
1307 limit,
1308 ..
1309 } => {
1310 assert_eq!(wallet, None);
1311 assert_eq!(network, None);
1312 assert_eq!(limit, None);
1313 }
1314 other => panic!("expected HistoryUpdate, got {other:?}"),
1315 }
1316 }
1317}