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