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 wait_sync_limit: Option<usize>,
520 #[serde(default)]
521 write_qr_svg_file: bool,
522 #[serde(default, skip_serializing_if = "Option::is_none")]
523 min_confirmations: Option<u32>,
524 },
525 #[serde(rename = "receive_claim")]
526 ReceiveClaim {
527 id: String,
528 wallet: String,
529 quote_id: String,
530 },
531
532 #[serde(rename = "cashu_send")]
533 CashuSend {
534 id: String,
535 #[serde(default)]
536 wallet: Option<String>,
537 amount: Amount,
538 #[serde(default)]
539 onchain_memo: Option<String>,
540 #[serde(default, deserialize_with = "deserialize_local_memo")]
541 local_memo: Option<BTreeMap<String, String>>,
542 #[serde(default, skip_serializing_if = "Option::is_none")]
544 mints: Option<Vec<String>>,
545 },
546 #[serde(rename = "cashu_receive")]
547 CashuReceive {
548 id: String,
549 #[serde(default)]
550 wallet: Option<String>,
551 token: String,
552 },
553 #[serde(rename = "send")]
554 Send {
555 id: String,
556 #[serde(default)]
557 wallet: Option<String>,
558 #[serde(default, skip_serializing_if = "Option::is_none")]
559 network: Option<Network>,
560 to: String,
561 #[serde(default)]
562 onchain_memo: Option<String>,
563 #[serde(default, deserialize_with = "deserialize_local_memo")]
564 local_memo: Option<BTreeMap<String, String>>,
565 #[serde(default, skip_serializing_if = "Option::is_none")]
567 mints: Option<Vec<String>>,
568 },
569
570 #[serde(rename = "restore")]
571 Restore { id: String, wallet: String },
572 #[serde(rename = "local_wallet_show_seed")]
573 WalletShowSeed { id: String, wallet: String },
574
575 #[serde(rename = "history")]
576 HistoryList {
577 id: String,
578 #[serde(default)]
579 wallet: Option<String>,
580 #[serde(default, skip_serializing_if = "Option::is_none")]
581 network: Option<Network>,
582 #[serde(default, skip_serializing_if = "Option::is_none")]
583 onchain_memo: Option<String>,
584 #[serde(default)]
585 limit: Option<usize>,
586 #[serde(default)]
587 offset: Option<usize>,
588 #[serde(default, skip_serializing_if = "Option::is_none")]
590 since_epoch_s: Option<u64>,
591 #[serde(default, skip_serializing_if = "Option::is_none")]
593 until_epoch_s: Option<u64>,
594 },
595 #[serde(rename = "history_status")]
596 HistoryStatus { id: String, transaction_id: String },
597 #[serde(rename = "history_update")]
598 HistoryUpdate {
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)]
605 limit: Option<usize>,
606 },
607
608 #[serde(rename = "limit_add")]
609 LimitAdd { id: String, limit: SpendLimit },
610 #[serde(rename = "limit_remove")]
611 LimitRemove { id: String, rule_id: String },
612 #[serde(rename = "limit_list")]
613 LimitList { id: String },
614 #[serde(rename = "limit_set")]
615 LimitSet { id: String, limits: Vec<SpendLimit> },
616
617 #[serde(rename = "wallet_config_show")]
618 WalletConfigShow { id: String, wallet: String },
619 #[serde(rename = "wallet_config_set")]
620 WalletConfigSet {
621 id: String,
622 wallet: String,
623 #[serde(default, skip_serializing_if = "Option::is_none")]
624 label: Option<String>,
625 #[serde(default, skip_serializing_if = "Vec::is_empty")]
626 rpc_endpoints: Vec<String>,
627 #[serde(default, skip_serializing_if = "Option::is_none")]
628 chain_id: Option<u64>,
629 },
630 #[serde(rename = "wallet_config_token_add")]
631 WalletConfigTokenAdd {
632 id: String,
633 wallet: String,
634 symbol: String,
635 address: String,
636 decimals: u8,
637 },
638 #[serde(rename = "wallet_config_token_remove")]
639 WalletConfigTokenRemove {
640 id: String,
641 wallet: String,
642 symbol: String,
643 },
644
645 #[serde(rename = "config")]
646 Config(ConfigPatch),
647 #[serde(rename = "version")]
648 Version,
649 #[serde(rename = "close")]
650 Close,
651}
652
653impl Input {
654 pub fn is_local_only(&self) -> bool {
656 matches!(
657 self,
658 Input::WalletShowSeed { .. }
659 | Input::WalletClose {
660 dangerously_skip_balance_check_and_may_lose_money: true,
661 ..
662 }
663 | Input::LimitAdd { .. }
664 | Input::LimitRemove { .. }
665 | Input::LimitSet { .. }
666 | Input::WalletConfigSet { .. }
667 | Input::WalletConfigTokenAdd { .. }
668 | Input::WalletConfigTokenRemove { .. }
669 | Input::Restore { .. }
670 | Input::Config(_)
671 )
672 }
673}
674
675#[derive(Debug, Serialize)]
680#[serde(tag = "code")]
681pub enum Output {
682 #[serde(rename = "wallet_created")]
683 WalletCreated {
684 id: String,
685 wallet: String,
686 network: Network,
687 address: String,
688 #[serde(skip_serializing_if = "Option::is_none")]
689 mnemonic: Option<String>,
690 trace: Trace,
691 },
692 #[serde(rename = "wallet_closed")]
693 WalletClosed {
694 id: String,
695 wallet: String,
696 trace: Trace,
697 },
698 #[serde(rename = "wallet_list")]
699 WalletList {
700 id: String,
701 wallets: Vec<WalletSummary>,
702 trace: Trace,
703 },
704 #[serde(rename = "wallet_balances")]
705 WalletBalances {
706 id: String,
707 wallets: Vec<WalletBalanceItem>,
708 trace: Trace,
709 },
710 #[serde(rename = "receive_info")]
711 ReceiveInfo {
712 id: String,
713 wallet: String,
714 receive_info: ReceiveInfo,
715 trace: Trace,
716 },
717 #[serde(rename = "receive_claimed")]
718 ReceiveClaimed {
719 id: String,
720 wallet: String,
721 amount: Amount,
722 trace: Trace,
723 },
724
725 #[serde(rename = "cashu_sent")]
726 CashuSent {
727 id: String,
728 wallet: String,
729 transaction_id: String,
730 status: TxStatus,
731 #[serde(skip_serializing_if = "Option::is_none")]
732 fee: Option<Amount>,
733 token: String,
734 trace: Trace,
735 },
736
737 #[serde(rename = "history")]
738 History {
739 id: String,
740 items: Vec<HistoryRecord>,
741 trace: Trace,
742 },
743 #[serde(rename = "history_status")]
744 HistoryStatus {
745 id: String,
746 transaction_id: String,
747 status: TxStatus,
748 #[serde(skip_serializing_if = "Option::is_none")]
749 confirmations: Option<u32>,
750 #[serde(skip_serializing_if = "Option::is_none")]
751 preimage: Option<String>,
752 #[serde(skip_serializing_if = "Option::is_none")]
753 item: Option<HistoryRecord>,
754 trace: Trace,
755 },
756 #[serde(rename = "history_updated")]
757 HistoryUpdated {
758 id: String,
759 wallets_synced: usize,
760 records_scanned: usize,
761 records_added: usize,
762 records_updated: usize,
763 trace: Trace,
764 },
765
766 #[serde(rename = "limit_added")]
767 LimitAdded {
768 id: String,
769 rule_id: String,
770 trace: Trace,
771 },
772 #[serde(rename = "limit_removed")]
773 LimitRemoved {
774 id: String,
775 rule_id: String,
776 trace: Trace,
777 },
778 #[serde(rename = "limit_status")]
779 LimitStatus {
780 id: String,
781 limits: Vec<SpendLimitStatus>,
782 #[serde(default, skip_serializing_if = "Vec::is_empty")]
783 downstream: Vec<DownstreamLimitNode>,
784 trace: Trace,
785 },
786 #[serde(rename = "limit_exceeded")]
787 #[allow(dead_code)]
788 LimitExceeded {
789 id: String,
790 rule_id: String,
791 scope: SpendScope,
792 scope_key: String,
793 spent: u64,
794 max_spend: u64,
795 #[serde(skip_serializing_if = "Option::is_none")]
796 token: Option<String>,
797 remaining_s: u64,
798 #[serde(skip_serializing_if = "Option::is_none")]
799 origin: Option<String>,
800 trace: Trace,
801 },
802
803 #[serde(rename = "cashu_received")]
804 CashuReceived {
805 id: String,
806 wallet: String,
807 amount: Amount,
808 trace: Trace,
809 },
810 #[serde(rename = "restored")]
811 Restored {
812 id: String,
813 wallet: String,
814 unspent: u64,
815 spent: u64,
816 pending: u64,
817 unit: String,
818 trace: Trace,
819 },
820 #[serde(rename = "wallet_seed")]
821 WalletSeed {
822 id: String,
823 wallet: String,
824 mnemonic_secret: String,
825 trace: Trace,
826 },
827
828 #[serde(rename = "sent")]
829 Sent {
830 id: String,
831 wallet: String,
832 transaction_id: String,
833 amount: Amount,
834 #[serde(skip_serializing_if = "Option::is_none")]
835 fee: Option<Amount>,
836 #[serde(skip_serializing_if = "Option::is_none")]
837 preimage: Option<String>,
838 trace: Trace,
839 },
840
841 #[serde(rename = "wallet_config")]
842 WalletConfig {
843 id: String,
844 wallet: String,
845 config: WalletMetadata,
846 trace: Trace,
847 },
848 #[serde(rename = "wallet_config_updated")]
849 WalletConfigUpdated {
850 id: String,
851 wallet: String,
852 trace: Trace,
853 },
854 #[serde(rename = "wallet_config_token_added")]
855 WalletConfigTokenAdded {
856 id: String,
857 wallet: String,
858 symbol: String,
859 address: String,
860 decimals: u8,
861 trace: Trace,
862 },
863 #[serde(rename = "wallet_config_token_removed")]
864 WalletConfigTokenRemoved {
865 id: String,
866 wallet: String,
867 symbol: String,
868 trace: Trace,
869 },
870
871 #[serde(rename = "error")]
872 Error {
873 #[serde(skip_serializing_if = "Option::is_none")]
874 id: Option<String>,
875 error_code: String,
876 error: String,
877 #[serde(skip_serializing_if = "Option::is_none")]
878 hint: Option<String>,
879 retryable: bool,
880 trace: Trace,
881 },
882
883 #[serde(rename = "dry_run")]
884 DryRun {
885 #[serde(skip_serializing_if = "Option::is_none")]
886 id: Option<String>,
887 command: String,
888 params: serde_json::Value,
889 trace: Trace,
890 },
891
892 #[serde(rename = "config")]
893 Config(RuntimeConfig),
894 #[serde(rename = "version")]
895 Version { version: String, trace: PongTrace },
896 #[serde(rename = "close")]
897 Close { message: String, trace: CloseTrace },
898 #[serde(rename = "log")]
899 Log {
900 event: String,
901 #[serde(skip_serializing_if = "Option::is_none")]
902 request_id: Option<String>,
903 #[serde(skip_serializing_if = "Option::is_none")]
904 version: Option<String>,
905 #[serde(skip_serializing_if = "Option::is_none")]
906 argv: Option<Vec<String>>,
907 #[serde(skip_serializing_if = "Option::is_none")]
908 config: Option<serde_json::Value>,
909 #[serde(skip_serializing_if = "Option::is_none")]
910 args: Option<serde_json::Value>,
911 #[serde(skip_serializing_if = "Option::is_none")]
912 env: Option<serde_json::Value>,
913 trace: Trace,
914 },
915}
916
917#[derive(Debug, Serialize, Deserialize, Clone)]
922pub struct RuntimeConfig {
923 #[serde(default)]
924 pub data_dir: String,
925 #[serde(default, skip_serializing_if = "Option::is_none")]
926 pub rpc_endpoint: Option<String>,
927 #[serde(default, skip_serializing_if = "Option::is_none")]
928 pub rpc_secret: Option<String>,
929 #[serde(default)]
930 pub limits: Vec<SpendLimit>,
931 #[serde(default)]
932 pub log: Vec<String>,
933 #[serde(default, skip_serializing_if = "Option::is_none")]
934 pub exchange_rate: Option<ExchangeRateConfig>,
935 #[serde(default)]
937 pub afpay_rpc: std::collections::HashMap<String, AfpayRpcConfig>,
938 #[serde(default)]
940 pub providers: std::collections::HashMap<String, String>,
941 #[serde(default, skip_serializing_if = "Option::is_none")]
943 pub storage_backend: Option<String>,
944 #[serde(default, skip_serializing_if = "Option::is_none")]
946 pub postgres_url_secret: Option<String>,
947}
948
949impl Default for RuntimeConfig {
950 fn default() -> Self {
951 Self {
952 data_dir: default_data_dir(),
953 rpc_endpoint: None,
954 rpc_secret: None,
955 limits: vec![],
956 log: vec![],
957 exchange_rate: None,
958 afpay_rpc: std::collections::HashMap::new(),
959 providers: std::collections::HashMap::new(),
960 storage_backend: None,
961 postgres_url_secret: None,
962 }
963 }
964}
965
966fn default_data_dir() -> String {
967 if let Some(val) = std::env::var_os("AFPAY_HOME") {
969 return std::path::PathBuf::from(val).to_string_lossy().into_owned();
970 }
971 if let Some(home) = std::env::var_os("HOME") {
972 let mut p = std::path::PathBuf::from(home);
973 p.push(".afpay");
974 p.to_string_lossy().into_owned()
975 } else {
976 ".afpay".to_string()
977 }
978}
979
980#[derive(Debug, Serialize, Deserialize, Clone)]
981pub struct AfpayRpcConfig {
982 pub endpoint: String,
983 #[serde(default, skip_serializing_if = "Option::is_none")]
984 pub endpoint_secret: Option<String>,
985}
986
987#[derive(Debug, Serialize, Deserialize, Clone)]
988pub struct ExchangeRateConfig {
989 #[serde(default = "default_exchange_rate_ttl_s")]
990 pub ttl_s: u64,
991 #[serde(default = "default_exchange_rate_sources")]
992 pub sources: Vec<ExchangeRateSource>,
993}
994
995impl Default for ExchangeRateConfig {
996 fn default() -> Self {
997 Self {
998 ttl_s: default_exchange_rate_ttl_s(),
999 sources: default_exchange_rate_sources(),
1000 }
1001 }
1002}
1003
1004#[derive(Debug, Serialize, Deserialize, Clone)]
1005pub struct ExchangeRateSource {
1006 #[serde(rename = "type")]
1007 pub source_type: ExchangeRateSourceType,
1008 pub endpoint: String,
1009 #[serde(default, skip_serializing_if = "Option::is_none")]
1010 pub api_key: Option<String>,
1011}
1012
1013#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1014#[serde(rename_all = "snake_case")]
1015pub enum ExchangeRateSourceType {
1016 Generic,
1017 CoinGecko,
1018 Kraken,
1019}
1020
1021fn default_exchange_rate_ttl_s() -> u64 {
1022 300
1023}
1024
1025fn default_exchange_rate_sources() -> Vec<ExchangeRateSource> {
1026 vec![
1027 ExchangeRateSource {
1028 source_type: ExchangeRateSourceType::Kraken,
1029 endpoint: "https://api.kraken.com".to_string(),
1030 api_key: None,
1031 },
1032 ExchangeRateSource {
1033 source_type: ExchangeRateSourceType::CoinGecko,
1034 endpoint: "https://api.coingecko.com/api/v3".to_string(),
1035 api_key: None,
1036 },
1037 ]
1038}
1039
1040#[derive(Debug, Serialize, Deserialize, Default)]
1041pub struct ConfigPatch {
1042 #[serde(default)]
1043 pub data_dir: Option<String>,
1044 #[serde(default)]
1045 pub limits: Option<Vec<SpendLimit>>,
1046 #[serde(default)]
1047 pub log: Option<Vec<String>>,
1048 #[serde(default)]
1049 pub exchange_rate: Option<ExchangeRateConfig>,
1050 #[serde(default)]
1051 pub afpay_rpc: Option<std::collections::HashMap<String, AfpayRpcConfig>>,
1052 #[serde(default)]
1053 pub providers: Option<std::collections::HashMap<String, String>>,
1054}
1055
1056fn deserialize_local_memo<'de, D>(d: D) -> Result<Option<BTreeMap<String, String>>, D::Error>
1059where
1060 D: Deserializer<'de>,
1061{
1062 use serde::de;
1063
1064 struct LocalMemoVisitor;
1065
1066 impl<'de> de::Visitor<'de> for LocalMemoVisitor {
1067 type Value = Option<BTreeMap<String, String>>;
1068
1069 fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
1070 f.write_str("null, a string, or a map of string→string")
1071 }
1072
1073 fn visit_none<E: de::Error>(self) -> Result<Self::Value, E> {
1074 Ok(None)
1075 }
1076
1077 fn visit_unit<E: de::Error>(self) -> Result<Self::Value, E> {
1078 Ok(None)
1079 }
1080
1081 fn visit_str<E: de::Error>(self, v: &str) -> Result<Self::Value, E> {
1082 let mut m = BTreeMap::new();
1083 m.insert("note".to_string(), v.to_string());
1084 Ok(Some(m))
1085 }
1086
1087 fn visit_string<E: de::Error>(self, v: String) -> Result<Self::Value, E> {
1088 let mut m = BTreeMap::new();
1089 m.insert("note".to_string(), v);
1090 Ok(Some(m))
1091 }
1092
1093 fn visit_map<A: de::MapAccess<'de>>(self, mut map: A) -> Result<Self::Value, A::Error> {
1094 let mut m = BTreeMap::new();
1095 while let Some((k, v)) = map.next_entry::<String, String>()? {
1096 m.insert(k, v);
1097 }
1098 Ok(Some(m))
1099 }
1100
1101 fn visit_some<D2: Deserializer<'de>>(self, d: D2) -> Result<Self::Value, D2::Error> {
1102 d.deserialize_any(Self)
1103 }
1104 }
1105
1106 d.deserialize_option(LocalMemoVisitor)
1107}
1108
1109#[cfg(test)]
1110mod tests {
1111 use super::*;
1112
1113 #[test]
1114 fn local_only_checks() {
1115 assert!(Input::WalletShowSeed {
1117 id: "t".into(),
1118 wallet: "w".into(),
1119 }
1120 .is_local_only());
1121
1122 assert!(Input::WalletClose {
1123 id: "t".into(),
1124 wallet: "w".into(),
1125 dangerously_skip_balance_check_and_may_lose_money: true,
1126 }
1127 .is_local_only());
1128
1129 assert!(!Input::WalletClose {
1130 id: "t".into(),
1131 wallet: "w".into(),
1132 dangerously_skip_balance_check_and_may_lose_money: false,
1133 }
1134 .is_local_only());
1135
1136 assert!(Input::LimitAdd {
1138 id: "t".into(),
1139 limit: SpendLimit {
1140 rule_id: None,
1141 scope: SpendScope::GlobalUsdCents,
1142 network: None,
1143 wallet: None,
1144 window_s: 3600,
1145 max_spend: 1000,
1146 token: None,
1147 },
1148 }
1149 .is_local_only());
1150
1151 assert!(Input::LimitRemove {
1152 id: "t".into(),
1153 rule_id: "r_1".into(),
1154 }
1155 .is_local_only());
1156
1157 assert!(Input::LimitSet {
1158 id: "t".into(),
1159 limits: vec![],
1160 }
1161 .is_local_only());
1162
1163 assert!(!Input::LimitList { id: "t".into() }.is_local_only());
1165
1166 assert!(Input::WalletConfigSet {
1168 id: "t".into(),
1169 wallet: "w".into(),
1170 label: None,
1171 rpc_endpoints: vec![],
1172 chain_id: None,
1173 }
1174 .is_local_only());
1175
1176 assert!(Input::WalletConfigTokenAdd {
1177 id: "t".into(),
1178 wallet: "w".into(),
1179 symbol: "dai".into(),
1180 address: "0x".into(),
1181 decimals: 18,
1182 }
1183 .is_local_only());
1184
1185 assert!(Input::WalletConfigTokenRemove {
1186 id: "t".into(),
1187 wallet: "w".into(),
1188 symbol: "dai".into(),
1189 }
1190 .is_local_only());
1191
1192 assert!(!Input::WalletConfigShow {
1194 id: "t".into(),
1195 wallet: "w".into(),
1196 }
1197 .is_local_only());
1198
1199 assert!(Input::Restore {
1201 id: "t".into(),
1202 wallet: "w".into(),
1203 }
1204 .is_local_only());
1205 }
1206
1207 #[test]
1208 fn wallet_seed_output_uses_mnemonic_secret_field() {
1209 let out = Output::WalletSeed {
1210 id: "t_1".to_string(),
1211 wallet: "w_1".to_string(),
1212 mnemonic_secret: "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about".to_string(),
1213 trace: Trace::from_duration(0),
1214 };
1215 let value = serde_json::to_value(out).expect("serialize wallet_seed output");
1216 assert_eq!(
1217 value.get("mnemonic_secret").and_then(|v| v.as_str()),
1218 Some(
1219 "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
1220 )
1221 );
1222 assert!(value.get("mnemonic").is_none());
1223 }
1224
1225 #[test]
1226 fn history_list_parses_time_range_fields() {
1227 let json = r#"{
1228 "code": "history",
1229 "id": "t_1",
1230 "wallet": "w_1",
1231 "limit": 10,
1232 "offset": 0,
1233 "since_epoch_s": 1700000000,
1234 "until_epoch_s": 1700100000
1235 }"#;
1236 let input: Input = serde_json::from_str(json).expect("parse history_list with time range");
1237 match input {
1238 Input::HistoryList {
1239 since_epoch_s,
1240 until_epoch_s,
1241 ..
1242 } => {
1243 assert_eq!(since_epoch_s, Some(1_700_000_000));
1244 assert_eq!(until_epoch_s, Some(1_700_100_000));
1245 }
1246 other => panic!("expected HistoryList, got {other:?}"),
1247 }
1248 }
1249
1250 #[test]
1251 fn history_list_time_range_fields_default_to_none() {
1252 let json = r#"{
1253 "code": "history",
1254 "id": "t_1",
1255 "limit": 10,
1256 "offset": 0
1257 }"#;
1258 let input: Input =
1259 serde_json::from_str(json).expect("parse history_list without time range");
1260 match input {
1261 Input::HistoryList {
1262 since_epoch_s,
1263 until_epoch_s,
1264 ..
1265 } => {
1266 assert_eq!(since_epoch_s, None);
1267 assert_eq!(until_epoch_s, None);
1268 }
1269 other => panic!("expected HistoryList, got {other:?}"),
1270 }
1271 }
1272
1273 #[test]
1274 fn history_update_parses_sync_fields() {
1275 let json = r#"{
1276 "code": "history_update",
1277 "id": "t_2",
1278 "wallet": "w_1",
1279 "network": "sol",
1280 "limit": 150
1281 }"#;
1282 let input: Input = serde_json::from_str(json).expect("parse history_update");
1283 match input {
1284 Input::HistoryUpdate {
1285 wallet,
1286 network,
1287 limit,
1288 ..
1289 } => {
1290 assert_eq!(wallet.as_deref(), Some("w_1"));
1291 assert_eq!(network, Some(Network::Sol));
1292 assert_eq!(limit, Some(150));
1293 }
1294 other => panic!("expected HistoryUpdate, got {other:?}"),
1295 }
1296 }
1297
1298 #[test]
1299 fn history_update_fields_default_to_none() {
1300 let json = r#"{
1301 "code": "history_update",
1302 "id": "t_3"
1303 }"#;
1304 let input: Input = serde_json::from_str(json).expect("parse history_update defaults");
1305 match input {
1306 Input::HistoryUpdate {
1307 wallet,
1308 network,
1309 limit,
1310 ..
1311 } => {
1312 assert_eq!(wallet, None);
1313 assert_eq!(network, None);
1314 assert_eq!(limit, None);
1315 }
1316 other => panic!("expected HistoryUpdate, got {other:?}"),
1317 }
1318 }
1319}