1use crate::mode::rpc::crypto::Cipher;
2use crate::mode::rpc::proto::af_pay_client::AfPayClient;
3use crate::mode::rpc::proto::EncryptedRequest;
4use agent_first_data::OutputFormat;
5use std::io::Write;
6
7pub async fn rpc_call(
9 endpoint: &str,
10 secret: &str,
11 input: &impl serde::Serialize,
12) -> Vec<serde_json::Value> {
13 let cipher = Cipher::from_secret(secret);
14
15 let input_json = match serde_json::to_vec(input) {
17 Ok(v) => v,
18 Err(e) => return vec![rpc_error_output("serialize_error", &format!("{e}"))],
19 };
20
21 let (nonce, ciphertext) = match cipher.encrypt(&input_json) {
23 Ok(v) => v,
24 Err(e) => return vec![rpc_error_output("encrypt_error", &e)],
25 };
26
27 let url = if endpoint.starts_with("http://") || endpoint.starts_with("https://") {
29 endpoint.to_string()
30 } else {
31 format!("http://{endpoint}")
32 };
33
34 let mut client = match AfPayClient::connect(url).await {
36 Ok(c) => c,
37 Err(e) => return vec![rpc_error_output("connect_error", &format!("{e}"))],
38 };
39
40 let response = match client.call(EncryptedRequest { nonce, ciphertext }).await {
41 Ok(r) => r,
42 Err(status) => {
43 let error_code = match status.code() {
44 tonic::Code::PermissionDenied => "permission_denied",
45 tonic::Code::Unauthenticated => "unauthenticated",
46 tonic::Code::Unavailable => "unavailable",
47 tonic::Code::InvalidArgument => "invalid_argument",
48 _ => "rpc_error",
49 };
50 return vec![rpc_error_output(error_code, status.message())];
51 }
52 };
53
54 let resp = response.into_inner();
55
56 let plaintext = match cipher.decrypt(&resp.nonce, &resp.ciphertext) {
58 Ok(v) => v,
59 Err(e) => return vec![rpc_error_output("decrypt_error", &e)],
60 };
61
62 match serde_json::from_slice(&plaintext) {
64 Ok(v) => v,
65 Err(e) => vec![rpc_error_output("parse_error", &format!("{e}"))],
66 }
67}
68
69fn rpc_error_output(error_code: &str, error: &str) -> serde_json::Value {
70 let hint = match error_code {
71 "connect_error" => Some("check --rpc-endpoint address and that the daemon is running"),
72 "unauthenticated" | "decrypt_error" => Some("check --rpc-secret matches the daemon"),
73 "permission_denied" => Some("this operation can only be run on the daemon directly"),
74 _ => None,
75 };
76 let mut v = serde_json::json!({
77 "code": "error",
78 "error_code": error_code,
79 "error": error,
80 "retryable": matches!(error_code, "connect_error" | "unavailable"),
81 });
82 if let Some(h) = hint {
83 v["hint"] = serde_json::Value::String(h.to_string());
84 }
85 v
86}
87
88pub fn require_remote_args<'a>(
90 endpoint: Option<&'a str>,
91 secret: Option<&'a str>,
92 format: OutputFormat,
93) -> (&'a str, &'a str) {
94 let ep = match endpoint {
95 Some(ep) if !ep.is_empty() => ep,
96 _ => {
97 let value = agent_first_data::build_cli_error(
98 "--rpc-endpoint is required",
99 Some("pass the address of the afpay daemon"),
100 );
101 let rendered = agent_first_data::cli_output(&value, format);
102 let _ = writeln!(std::io::stdout(), "{rendered}");
103 std::process::exit(1);
104 }
105 };
106 let sec = match secret {
107 Some(s) if !s.is_empty() => s,
108 _ => {
109 let value = agent_first_data::build_cli_error(
110 "--rpc-secret is required with --rpc-endpoint",
111 Some("must match the --rpc-secret used by the daemon"),
112 );
113 let rendered = agent_first_data::cli_output(&value, format);
114 let _ = writeln!(std::io::stdout(), "{rendered}");
115 std::process::exit(1);
116 }
117 };
118 (ep, sec)
119}
120
121pub fn emit_remote_outputs(
123 outputs: &[serde_json::Value],
124 format: OutputFormat,
125 log_filters: &[String],
126) -> bool {
127 let mut had_error = false;
128 for value in outputs {
129 if value.get("code").and_then(|v| v.as_str()) == Some("error") {
130 had_error = true;
131 }
132 if let Some("log") = value.get("code").and_then(|v| v.as_str()) {
133 if let Some(event) = value.get("event").and_then(|v| v.as_str()) {
134 if !log_event_enabled(log_filters, event) {
135 continue;
136 }
137 }
138 }
139 let rendered = crate::output_fmt::render_value_with_policy(value, format);
140 let _ = writeln!(std::io::stdout(), "{rendered}");
141 }
142 had_error
143}
144
145pub fn wrap_remote_limit_topology(outputs: &mut [serde_json::Value], endpoint: &str) {
149 for value in outputs.iter_mut() {
150 let code = value.get("code").and_then(|v| v.as_str()).unwrap_or("");
151 match code {
152 "limit_status" => {
153 let limits = value
155 .get("limits")
156 .cloned()
157 .unwrap_or(serde_json::Value::Array(vec![]));
158 let downstream = value
159 .get("downstream")
160 .cloned()
161 .unwrap_or(serde_json::Value::Array(vec![]));
162 let node = serde_json::json!({
163 "name": endpoint,
164 "endpoint": endpoint,
165 "limits": limits,
166 "downstream": downstream,
167 });
168 value["limits"] = serde_json::Value::Array(vec![]);
169 value["downstream"] = serde_json::Value::Array(vec![node]);
170 }
171 "limit_exceeded"
172 if value.get("origin").is_none()
173 || value.get("origin") == Some(&serde_json::Value::Null) =>
174 {
175 value["origin"] = serde_json::Value::String(endpoint.to_string());
177 }
178 _ => {}
179 }
180 }
181}
182
183fn log_event_enabled(log: &[String], event: &str) -> bool {
184 if log.is_empty() {
185 return false;
186 }
187 let ev = event.to_ascii_lowercase();
188 log.iter()
189 .any(|f| f == "*" || f == "all" || ev.starts_with(f.as_str()))
190}
191
192use crate::provider::{HistorySyncStats, PayError, PayProvider};
197use crate::types::*;
198use async_trait::async_trait;
199use serde::de::DeserializeOwned;
200use serde::Deserialize;
201use std::sync::atomic::{AtomicU64, Ordering};
202
203static REMOTE_REQUEST_FALLBACK_COUNTER: AtomicU64 = AtomicU64::new(0);
204
205#[derive(Deserialize)]
206struct WalletCreatedOut {
207 wallet: String,
208 address: String,
209 #[serde(default)]
210 label: Option<String>,
211 #[serde(default)]
212 mnemonic: Option<String>,
213}
214
215#[derive(Deserialize)]
216struct WalletListOut {
217 #[serde(default)]
218 wallets: Vec<WalletSummary>,
219}
220
221#[derive(Deserialize)]
222struct WalletBalancesOut {
223 #[serde(default)]
224 wallets: Vec<WalletBalanceItem>,
225}
226
227#[derive(Deserialize)]
228struct LegacyWalletBalanceOut {
229 #[serde(default)]
230 balance: Option<BalanceInfo>,
231}
232
233#[derive(Deserialize)]
234struct ReceiveInfoOut {
235 receive_info: ReceiveInfo,
236}
237
238#[derive(Deserialize)]
239struct ReceiveClaimedOut {
240 amount: Amount,
241}
242
243#[derive(Deserialize)]
244struct CashuSentOut {
245 wallet: String,
246 transaction_id: String,
247 status: TxStatus,
248 #[serde(default)]
249 fee: Option<Amount>,
250 token: String,
251}
252
253#[derive(Deserialize)]
254struct CashuReceivedOut {
255 wallet: String,
256 amount: Amount,
257 #[serde(default)]
258 memo: Option<String>,
259}
260
261#[derive(Deserialize)]
262struct SentOut {
263 wallet: String,
264 transaction_id: String,
265 amount: Amount,
266 #[serde(default)]
267 fee: Option<Amount>,
268 #[serde(default)]
269 preimage: Option<String>,
270}
271
272#[derive(Deserialize)]
273struct RestoredOut {
274 wallet: String,
275 unspent: u64,
276 spent: u64,
277 pending: u64,
278 unit: String,
279}
280
281#[derive(Deserialize)]
282struct HistoryOut {
283 #[serde(default)]
284 items: Vec<HistoryRecord>,
285}
286
287#[derive(Deserialize)]
288struct HistoryStatusOut {
289 transaction_id: String,
290 status: TxStatus,
291 #[serde(default)]
292 confirmations: Option<u32>,
293 #[serde(default)]
294 preimage: Option<String>,
295 #[serde(default)]
296 item: Option<HistoryRecord>,
297}
298
299#[derive(Deserialize)]
300struct HistoryUpdatedOut {
301 #[serde(default)]
302 records_scanned: usize,
303 #[serde(default)]
304 records_added: usize,
305 #[serde(default)]
306 records_updated: usize,
307}
308
309pub struct RemoteProvider {
310 endpoint: String,
311 secret: String,
312 network: Network,
313}
314
315impl RemoteProvider {
316 pub fn new(endpoint: &str, secret: &str, network: Network) -> Self {
317 Self {
318 endpoint: endpoint.to_string(),
319 secret: secret.to_string(),
320 network,
321 }
322 }
323
324 async fn call(&self, input: &Input) -> Vec<serde_json::Value> {
325 rpc_call(&self.endpoint, &self.secret, input).await
326 }
327
328 fn map_remote_error(&self, value: &serde_json::Value) -> Option<PayError> {
329 let code = value
330 .get("code")
331 .and_then(|v| v.as_str())
332 .unwrap_or_default();
333 match code {
334 "error" => {
335 let msg = value
336 .get("error")
337 .and_then(|v| v.as_str())
338 .unwrap_or("unknown error");
339 let error_code = value
340 .get("error_code")
341 .and_then(|v| v.as_str())
342 .unwrap_or("remote_error");
343 Some(match error_code {
344 "wallet_not_found" => PayError::WalletNotFound(msg.to_string()),
345 "invalid_amount" => PayError::InvalidAmount(msg.to_string()),
346 "not_implemented" => PayError::NotImplemented(msg.to_string()),
347 "limit_exceeded" => PayError::LimitExceeded {
348 rule_id: value
349 .get("rule_id")
350 .and_then(|v| v.as_str())
351 .unwrap_or("")
352 .to_string(),
353 scope: serde_json::from_value(
354 value
355 .get("scope")
356 .cloned()
357 .unwrap_or_else(|| serde_json::json!("network")),
358 )
359 .unwrap_or(SpendScope::Network),
360 scope_key: value
361 .get("scope_key")
362 .and_then(|v| v.as_str())
363 .unwrap_or("")
364 .to_string(),
365 spent: value.get("spent").and_then(|v| v.as_u64()).unwrap_or(0),
366 max_spend: value.get("max_spend").and_then(|v| v.as_u64()).unwrap_or(0),
367 token: value
368 .get("token")
369 .and_then(|v| v.as_str())
370 .map(|s| s.to_string()),
371 remaining_s: value
372 .get("remaining_s")
373 .and_then(|v| v.as_u64())
374 .unwrap_or(0),
375 origin: Some(
376 value
377 .get("origin")
378 .and_then(|v| v.as_str())
379 .map(|s| s.to_string())
380 .unwrap_or_else(|| self.endpoint.clone()),
381 ),
382 },
383 _ => PayError::NetworkError(msg.to_string()),
384 })
385 }
386 "limit_exceeded" => Some(PayError::LimitExceeded {
387 rule_id: value
388 .get("rule_id")
389 .and_then(|v| v.as_str())
390 .unwrap_or("")
391 .to_string(),
392 scope: serde_json::from_value(
393 value
394 .get("scope")
395 .cloned()
396 .unwrap_or_else(|| serde_json::json!("network")),
397 )
398 .unwrap_or(SpendScope::Network),
399 scope_key: value
400 .get("scope_key")
401 .and_then(|v| v.as_str())
402 .unwrap_or("")
403 .to_string(),
404 spent: value.get("spent").and_then(|v| v.as_u64()).unwrap_or(0),
405 max_spend: value.get("max_spend").and_then(|v| v.as_u64()).unwrap_or(0),
406 token: value
407 .get("token")
408 .and_then(|v| v.as_str())
409 .map(|s| s.to_string()),
410 remaining_s: value
411 .get("remaining_s")
412 .and_then(|v| v.as_u64())
413 .unwrap_or(0),
414 origin: Some(
415 value
416 .get("origin")
417 .and_then(|v| v.as_str())
418 .map(|s| s.to_string())
419 .unwrap_or_else(|| self.endpoint.clone()),
420 ),
421 }),
422 _ => None,
423 }
424 }
425
426 fn first_output(
428 &self,
429 outputs: Vec<serde_json::Value>,
430 expected_codes: &[&str],
431 ) -> Result<serde_json::Value, PayError> {
432 for value in outputs {
433 let code = value
434 .get("code")
435 .and_then(|v| v.as_str())
436 .unwrap_or_default();
437 if code == "log" {
438 continue;
439 }
440 if let Some(err) = self.map_remote_error(&value) {
441 return Err(err);
442 }
443 if expected_codes.contains(&code) {
444 return Ok(value);
445 }
446 return Err(PayError::NetworkError(format!(
447 "unexpected remote output code '{code}'"
448 )));
449 }
450 Err(PayError::NetworkError(
451 "empty or log-only response from remote".to_string(),
452 ))
453 }
454
455 fn parse_output<T: DeserializeOwned>(
456 &self,
457 value: serde_json::Value,
458 label: &str,
459 ) -> Result<T, PayError> {
460 serde_json::from_value(value)
461 .map_err(|e| PayError::NetworkError(format!("parse {label}: {e}")))
462 }
463
464 fn balance_from_output(
465 &self,
466 value: serde_json::Value,
467 wallet: &str,
468 ) -> Result<BalanceInfo, PayError> {
469 if value.get("code").and_then(|v| v.as_str()) == Some("wallet_balance") {
470 let parsed: LegacyWalletBalanceOut = self.parse_output(value, "wallet_balance")?;
471 return Ok(parsed
472 .balance
473 .unwrap_or_else(|| BalanceInfo::new(0, 0, "unknown")));
474 }
475
476 let parsed: WalletBalancesOut = self.parse_output(value, "wallet_balances")?;
477 let mut wallets = parsed.wallets;
478 let item = wallets
479 .iter()
480 .position(|item| item.wallet.id == wallet)
481 .map(|idx| wallets.remove(idx))
482 .or_else(|| {
483 (wallets.len() == 1).then(|| wallets.remove(0))
486 });
487 let Some(item) = item else {
488 return Err(PayError::WalletNotFound(format!(
489 "wallet {wallet} not found in remote balance response"
490 )));
491 };
492 item.balance.ok_or_else(|| {
493 PayError::NetworkError(
494 item.error
495 .unwrap_or_else(|| "remote balance response has no balance".to_string()),
496 )
497 })
498 }
499
500 fn gen_id(&self) -> String {
501 crate::store::wallet::generate_request_identifier().unwrap_or_else(|_| {
502 let seq = REMOTE_REQUEST_FALLBACK_COUNTER.fetch_add(1, Ordering::Relaxed);
503 format!(
504 "req_fallback_{}_{}",
505 crate::store::wallet::now_epoch_seconds(),
506 seq
507 )
508 })
509 }
510}
511
512#[async_trait]
513impl PayProvider for RemoteProvider {
514 fn network(&self) -> Network {
515 self.network
516 }
517
518 async fn ping(&self) -> Result<(), PayError> {
519 let outputs = self.call(&Input::Version).await;
520 for value in &outputs {
521 if let Some(err) = self.map_remote_error(value) {
522 return Err(err);
523 }
524 if value.get("code").and_then(|v| v.as_str()) == Some("version") {
525 let remote_version = value
526 .get("version")
527 .and_then(|v| v.as_str())
528 .unwrap_or("unknown");
529 let local = crate::config::VERSION;
530 if remote_version != local {
531 return Err(PayError::NetworkError(format!(
532 "version mismatch: local v{local}, remote v{remote_version}"
533 )));
534 }
535 }
536 }
537 Ok(())
538 }
539
540 async fn create_wallet(&self, request: &WalletCreateRequest) -> Result<WalletInfo, PayError> {
541 let out = self.first_output(
542 self.call(&Input::WalletCreate {
543 id: self.gen_id(),
544 network: self.network,
545 label: Some(request.label.clone()),
546 mint_url: request.mint_url.clone(),
547 rpc_endpoints: request.rpc_endpoints.clone(),
548 chain_id: request.chain_id,
549 mnemonic_secret: request.mnemonic_secret.clone(),
550 btc_esplora_url: request.btc_esplora_url.clone(),
551 btc_network: request.btc_network.clone(),
552 btc_address_type: request.btc_address_type.clone(),
553 btc_backend: request.btc_backend,
554 btc_core_url: request.btc_core_url.clone(),
555 btc_core_auth_secret: request.btc_core_auth_secret.clone(),
556 btc_electrum_url: request.btc_electrum_url.clone(),
557 })
558 .await,
559 &["wallet_created"],
560 )?;
561 let parsed: WalletCreatedOut = self.parse_output(out, "wallet_created")?;
562 Ok(WalletInfo {
563 id: parsed.wallet,
564 network: self.network,
565 address: parsed.address,
566 label: parsed.label,
567 mnemonic: parsed.mnemonic,
568 })
569 }
570
571 async fn create_ln_wallet(
572 &self,
573 request: LnWalletCreateRequest,
574 ) -> Result<WalletInfo, PayError> {
575 if self.network != Network::Ln {
576 return Err(PayError::InvalidAmount(
577 "ln_wallet_create can only be used with ln provider".to_string(),
578 ));
579 }
580 let out = self.first_output(
581 self.call(&Input::LnWalletCreate {
582 id: self.gen_id(),
583 request,
584 })
585 .await,
586 &["wallet_created"],
587 )?;
588 let parsed: WalletCreatedOut = self.parse_output(out, "wallet_created")?;
589 Ok(WalletInfo {
590 id: parsed.wallet,
591 network: self.network,
592 address: parsed.address,
593 label: parsed.label,
594 mnemonic: parsed.mnemonic,
595 })
596 }
597
598 async fn close_wallet(&self, wallet: &str) -> Result<(), PayError> {
599 self.first_output(
600 self.call(&Input::WalletClose {
601 id: self.gen_id(),
602 wallet: wallet.to_string(),
603 dangerously_skip_balance_check_and_may_lose_money: false,
604 })
605 .await,
606 &["wallet_closed"],
607 )?;
608 Ok(())
609 }
610
611 async fn list_wallets(&self) -> Result<Vec<WalletSummary>, PayError> {
612 let out = self.first_output(
613 self.call(&Input::WalletList {
614 id: self.gen_id(),
615 network: Some(self.network),
616 })
617 .await,
618 &["wallet_list"],
619 )?;
620 let parsed: WalletListOut = self.parse_output(out, "wallet_list")?;
621 Ok(parsed.wallets)
622 }
623
624 async fn balance(&self, wallet: &str) -> Result<BalanceInfo, PayError> {
625 let out = self.first_output(
626 self.call(&Input::Balance {
627 id: self.gen_id(),
628 wallet: Some(wallet.to_string()),
629 network: None,
630 check: false,
631 })
632 .await,
633 &["wallet_balances", "wallet_balance"],
634 )?;
635 self.balance_from_output(out, wallet)
636 }
637
638 async fn check_balance(&self, wallet: &str) -> Result<BalanceInfo, PayError> {
639 let out = self.first_output(
640 self.call(&Input::Balance {
641 id: self.gen_id(),
642 wallet: Some(wallet.to_string()),
643 network: None,
644 check: true,
645 })
646 .await,
647 &["wallet_balances", "wallet_balance"],
648 )?;
649 self.balance_from_output(out, wallet)
650 }
651
652 async fn balance_all(&self) -> Result<Vec<WalletBalanceItem>, PayError> {
653 let out = self.first_output(
654 self.call(&Input::Balance {
655 id: self.gen_id(),
656 wallet: None,
657 network: None,
658 check: false,
659 })
660 .await,
661 &["wallet_balances", "wallet_balance"],
662 )?;
663 if out.get("code").and_then(|v| v.as_str()) == Some("wallet_balance") {
665 let legacy: LegacyWalletBalanceOut = self.parse_output(out, "wallet_balance")?;
666 let Some(balance) = legacy.balance else {
667 return Ok(vec![]);
668 };
669 return Ok(vec![WalletBalanceItem {
670 wallet: WalletSummary {
671 id: String::new(),
672 network: self.network,
673 label: None,
674 address: String::new(),
675 backend: None,
676 mint_url: None,
677 rpc_endpoints: None,
678 chain_id: None,
679 created_at_epoch_s: 0,
680 },
681 balance: Some(balance),
682 error: None,
683 }]);
684 }
685 let parsed: WalletBalancesOut = self.parse_output(out, "wallet_balances")?;
686 Ok(parsed.wallets)
687 }
688
689 async fn receive_info(
690 &self,
691 wallet: &str,
692 amount: Option<Amount>,
693 ) -> Result<ReceiveInfo, PayError> {
694 let out = self.first_output(
695 self.call(&Input::Receive {
696 id: self.gen_id(),
697 wallet: wallet.to_string(),
698 network: Some(self.network),
699 amount,
700 onchain_memo: None,
701 wait_until_paid: false,
702 wait_timeout_s: None,
703 wait_poll_interval_ms: None,
704 wait_sync_limit: None,
705 write_qr_svg_file: false,
706 min_confirmations: None,
707 reference: None,
708 })
709 .await,
710 &["receive_info"],
711 )?;
712 let parsed: ReceiveInfoOut = self.parse_output(out, "receive_info")?;
713 Ok(parsed.receive_info)
714 }
715
716 async fn receive_claim(&self, wallet: &str, quote_id: &str) -> Result<u64, PayError> {
717 let out = self.first_output(
718 self.call(&Input::ReceiveClaim {
719 id: self.gen_id(),
720 wallet: wallet.to_string(),
721 quote_id: quote_id.to_string(),
722 })
723 .await,
724 &["receive_claimed"],
725 )?;
726 let parsed: ReceiveClaimedOut = self.parse_output(out, "receive_claimed")?;
727 Ok(parsed.amount.value)
728 }
729
730 async fn cashu_send(
731 &self,
732 wallet: &str,
733 amount: Amount,
734 onchain_memo: Option<&str>,
735 mints: Option<&[String]>,
736 ) -> Result<CashuSendResult, PayError> {
737 let out = self.first_output(
738 self.call(&Input::CashuSend {
739 id: self.gen_id(),
740 wallet: Some(wallet.to_string()),
741 amount: amount.clone(),
742 onchain_memo: onchain_memo.map(|s| s.to_string()),
743 local_memo: None,
744 mints: mints.map(|m| m.to_vec()),
745 })
746 .await,
747 &["cashu_sent"],
748 )?;
749 let parsed: CashuSentOut = self.parse_output(out, "cashu_sent")?;
750 Ok(CashuSendResult {
751 wallet: parsed.wallet,
752 transaction_id: parsed.transaction_id,
753 status: parsed.status,
754 fee: parsed.fee,
755 token: parsed.token,
756 })
757 }
758
759 async fn cashu_receive(
760 &self,
761 wallet: &str,
762 token: &str,
763 ) -> Result<CashuReceiveResult, PayError> {
764 let out = self.first_output(
765 self.call(&Input::CashuReceive {
766 id: self.gen_id(),
767 wallet: Some(wallet.to_string()),
768 token: token.to_string(),
769 })
770 .await,
771 &["cashu_received"],
772 )?;
773 let parsed: CashuReceivedOut = self.parse_output(out, "cashu_received")?;
774 Ok(CashuReceiveResult {
775 wallet: parsed.wallet,
776 amount: parsed.amount,
777 memo: parsed.memo,
778 })
779 }
780
781 async fn send(
782 &self,
783 wallet: &str,
784 to: &str,
785 onchain_memo: Option<&str>,
786 mints: Option<&[String]>,
787 ) -> Result<SendResult, PayError> {
788 let out = self.first_output(
789 self.call(&Input::Send {
790 id: self.gen_id(),
791 wallet: Some(wallet.to_string()),
792 network: Some(self.network),
793 to: to.to_string(),
794 onchain_memo: onchain_memo.map(|s| s.to_string()),
795 local_memo: None,
796 mints: mints.map(|m| m.to_vec()),
797 })
798 .await,
799 &["sent"],
800 )?;
801 let parsed: SentOut = self.parse_output(out, "sent")?;
802 Ok(SendResult {
803 wallet: parsed.wallet,
804 transaction_id: parsed.transaction_id,
805 amount: parsed.amount,
806 fee: parsed.fee,
807 preimage: parsed.preimage,
808 })
809 }
810
811 async fn restore(&self, wallet: &str) -> Result<RestoreResult, PayError> {
812 let out = self.first_output(
813 self.call(&Input::Restore {
814 id: self.gen_id(),
815 wallet: wallet.to_string(),
816 })
817 .await,
818 &["restored"],
819 )?;
820 let parsed: RestoredOut = self.parse_output(out, "restored")?;
821 Ok(RestoreResult {
822 wallet: parsed.wallet,
823 unspent: parsed.unspent,
824 spent: parsed.spent,
825 pending: parsed.pending,
826 unit: parsed.unit,
827 })
828 }
829
830 async fn history_list(
831 &self,
832 wallet: &str,
833 limit: usize,
834 offset: usize,
835 ) -> Result<Vec<HistoryRecord>, PayError> {
836 let out = self.first_output(
837 self.call(&Input::HistoryList {
838 id: self.gen_id(),
839 wallet: Some(wallet.to_string()),
840 network: None,
841 onchain_memo: None,
842 limit: Some(limit),
843 offset: Some(offset),
844 since_epoch_s: None,
845 until_epoch_s: None,
846 })
847 .await,
848 &["history"],
849 )?;
850 let parsed: HistoryOut = self.parse_output(out, "history")?;
851 Ok(parsed.items)
852 }
853
854 async fn history_status(&self, transaction_id: &str) -> Result<HistoryStatusInfo, PayError> {
855 let out = self.first_output(
856 self.call(&Input::HistoryStatus {
857 id: self.gen_id(),
858 transaction_id: transaction_id.to_string(),
859 })
860 .await,
861 &["history_status"],
862 )?;
863 let parsed: HistoryStatusOut = self.parse_output(out, "history_status")?;
864 Ok(HistoryStatusInfo {
865 transaction_id: parsed.transaction_id,
866 status: parsed.status,
867 confirmations: parsed.confirmations,
868 preimage: parsed.preimage,
869 item: parsed.item,
870 })
871 }
872
873 async fn history_sync(&self, wallet: &str, limit: usize) -> Result<HistorySyncStats, PayError> {
874 let out = self.first_output(
875 self.call(&Input::HistoryUpdate {
876 id: self.gen_id(),
877 wallet: Some(wallet.to_string()),
878 network: Some(self.network),
879 limit: Some(limit),
880 })
881 .await,
882 &["history_updated"],
883 )?;
884 let parsed: HistoryUpdatedOut = self.parse_output(out, "history_updated")?;
885 Ok(HistorySyncStats {
886 records_scanned: parsed.records_scanned,
887 records_added: parsed.records_added,
888 records_updated: parsed.records_updated,
889 })
890 }
891}
892
893#[cfg(test)]
894#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
895mod tests {
896 use super::*;
897
898 #[test]
899 fn first_output_skips_log_events() {
900 let provider = RemoteProvider::new("http://127.0.0.1:1", "secret", Network::Cashu);
901 let out = provider
902 .first_output(
903 vec![
904 serde_json::json!({"code": "log", "event": "startup"}),
905 serde_json::json!({"code": "wallet_list", "wallets": []}),
906 ],
907 &["wallet_list"],
908 )
909 .expect("wallet_list output");
910 assert_eq!(out["code"], "wallet_list");
911 }
912
913 #[test]
914 fn first_output_maps_error() {
915 let provider = RemoteProvider::new("http://127.0.0.1:1", "secret", Network::Cashu);
916 let err = provider
917 .first_output(
918 vec![
919 serde_json::json!({"code": "log", "event": "wallet"}),
920 serde_json::json!({"code": "error", "error_code": "wallet_not_found", "error": "missing"}),
921 ],
922 &["wallet_list"],
923 )
924 .expect_err("error output should be mapped");
925 assert!(matches!(err, PayError::WalletNotFound(_)));
926 }
927
928 #[test]
929 fn first_output_maps_limit_exceeded() {
930 let provider = RemoteProvider::new("http://127.0.0.1:1", "secret", Network::Cashu);
931 let err = provider
932 .first_output(
933 vec![serde_json::json!({
934 "code": "limit_exceeded",
935 "rule_id": "r_abc123",
936 "spent": 1500,
937 "max_spend": 1000,
938 "remaining_s": 42
939 })],
940 &["sent"],
941 )
942 .expect_err("limit_exceeded should be mapped");
943 match err {
944 PayError::LimitExceeded {
945 rule_id,
946 spent,
947 max_spend,
948 remaining_s,
949 ..
950 } => {
951 assert_eq!(rule_id, "r_abc123");
952 assert_eq!(spent, 1500);
953 assert_eq!(max_spend, 1000);
954 assert_eq!(remaining_s, 42);
955 }
956 other => panic!("expected LimitExceeded, got: {other:?}"),
957 }
958 }
959
960 #[test]
961 fn balance_parses_current_wallet_balances_schema() {
962 let provider = RemoteProvider::new("http://127.0.0.1:1", "secret", Network::Cashu);
963 let balance = provider
964 .balance_from_output(
965 serde_json::json!({
966 "code": "wallet_balances",
967 "wallets": [{
968 "id": "w_1",
969 "network": "cashu",
970 "address": "https://mint.example",
971 "created_at_epoch_s": 1,
972 "balance": {
973 "confirmed": 42,
974 "pending": 0,
975 "unit": "sats"
976 }
977 }]
978 }),
979 "w_1",
980 )
981 .expect("balance should parse");
982 assert_eq!(balance.confirmed, 42);
983 assert_eq!(balance.unit, "sats");
984 }
985}