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 write_qr_svg_file: false,
542 min_confirmations: None,
543 })
544 .await,
545 &["receive_info"],
546 )?;
547 let info: ReceiveInfo = serde_json::from_value(
548 out.get("receive_info")
549 .cloned()
550 .unwrap_or(serde_json::Value::Null),
551 )
552 .map_err(|e| PayError::NetworkError(format!("parse receive_info: {e}")))?;
553 Ok(info)
554 }
555
556 async fn receive_claim(&self, wallet: &str, quote_id: &str) -> Result<u64, PayError> {
557 let out = self.first_output(
558 self.call(&Input::ReceiveClaim {
559 id: self.gen_id(),
560 wallet: wallet.to_string(),
561 quote_id: quote_id.to_string(),
562 })
563 .await,
564 &["receive_claimed"],
565 )?;
566 Ok(out["amount"]["value"].as_u64().unwrap_or(0))
567 }
568
569 async fn cashu_send(
570 &self,
571 wallet: &str,
572 amount: Amount,
573 onchain_memo: Option<&str>,
574 mints: Option<&[String]>,
575 ) -> Result<CashuSendResult, PayError> {
576 let out = self.first_output(
577 self.call(&Input::CashuSend {
578 id: self.gen_id(),
579 wallet: Some(wallet.to_string()),
580 amount: amount.clone(),
581 onchain_memo: onchain_memo.map(|s| s.to_string()),
582 local_memo: None,
583 mints: mints.map(|m| m.to_vec()),
584 })
585 .await,
586 &["cashu_sent"],
587 )?;
588 Ok(CashuSendResult {
589 wallet: out["wallet"].as_str().unwrap_or(wallet).to_string(),
590 transaction_id: out["transaction_id"].as_str().unwrap_or("").to_string(),
591 status: serde_json::from_value(out["status"].clone()).unwrap_or(TxStatus::Pending),
592 fee: out
593 .get("fee")
594 .and_then(|v| serde_json::from_value(v.clone()).ok()),
595 token: out["token"].as_str().unwrap_or("").to_string(),
596 })
597 }
598
599 async fn cashu_receive(
600 &self,
601 wallet: &str,
602 token: &str,
603 ) -> Result<CashuReceiveResult, PayError> {
604 let out = self.first_output(
605 self.call(&Input::CashuReceive {
606 id: self.gen_id(),
607 wallet: Some(wallet.to_string()),
608 token: token.to_string(),
609 })
610 .await,
611 &["cashu_received"],
612 )?;
613 let amount: Amount = serde_json::from_value(
614 out.get("amount")
615 .cloned()
616 .unwrap_or(serde_json::json!({"value": 0, "unit": "sats"})),
617 )
618 .map_err(|e| PayError::NetworkError(format!("parse amount: {e}")))?;
619 Ok(CashuReceiveResult {
620 wallet: out["wallet"].as_str().unwrap_or(wallet).to_string(),
621 amount,
622 })
623 }
624
625 async fn send(
626 &self,
627 wallet: &str,
628 to: &str,
629 onchain_memo: Option<&str>,
630 mints: Option<&[String]>,
631 ) -> Result<SendResult, PayError> {
632 let out = self.first_output(
633 self.call(&Input::Send {
634 id: self.gen_id(),
635 wallet: Some(wallet.to_string()),
636 network: Some(self.network),
637 to: to.to_string(),
638 onchain_memo: onchain_memo.map(|s| s.to_string()),
639 local_memo: None,
640 mints: mints.map(|m| m.to_vec()),
641 })
642 .await,
643 &["sent"],
644 )?;
645 let amount: Amount = serde_json::from_value(
646 out.get("amount")
647 .cloned()
648 .unwrap_or(serde_json::json!({"value": 0, "unit": "sats"})),
649 )
650 .map_err(|e| PayError::NetworkError(format!("parse amount: {e}")))?;
651 Ok(SendResult {
652 wallet: out["wallet"].as_str().unwrap_or(wallet).to_string(),
653 transaction_id: out["transaction_id"].as_str().unwrap_or("").to_string(),
654 amount,
655 fee: out
656 .get("fee")
657 .and_then(|v| serde_json::from_value(v.clone()).ok()),
658 preimage: out
659 .get("preimage")
660 .and_then(|v| v.as_str())
661 .map(|s| s.to_string()),
662 })
663 }
664
665 async fn restore(&self, wallet: &str) -> Result<RestoreResult, PayError> {
666 let out = self.first_output(
667 self.call(&Input::Restore {
668 id: self.gen_id(),
669 wallet: wallet.to_string(),
670 })
671 .await,
672 &["restored"],
673 )?;
674 Ok(RestoreResult {
675 wallet: out["wallet"].as_str().unwrap_or(wallet).to_string(),
676 unspent: out["unspent"].as_u64().unwrap_or(0),
677 spent: out["spent"].as_u64().unwrap_or(0),
678 pending: out["pending"].as_u64().unwrap_or(0),
679 unit: out["unit"].as_str().unwrap_or("sats").to_string(),
680 })
681 }
682
683 async fn history_list(
684 &self,
685 wallet: &str,
686 limit: usize,
687 offset: usize,
688 ) -> Result<Vec<HistoryRecord>, PayError> {
689 let out = self.first_output(
690 self.call(&Input::HistoryList {
691 id: self.gen_id(),
692 wallet: Some(wallet.to_string()),
693 network: None,
694 onchain_memo: None,
695 limit: Some(limit),
696 offset: Some(offset),
697 since_epoch_s: None,
698 until_epoch_s: None,
699 })
700 .await,
701 &["history"],
702 )?;
703 let items: Vec<HistoryRecord> = serde_json::from_value(
704 out.get("items")
705 .cloned()
706 .unwrap_or(serde_json::Value::Array(vec![])),
707 )
708 .map_err(|e| PayError::NetworkError(format!("parse history items: {e}")))?;
709 Ok(items)
710 }
711
712 async fn history_status(&self, transaction_id: &str) -> Result<HistoryStatusInfo, PayError> {
713 let out = self.first_output(
714 self.call(&Input::HistoryStatus {
715 id: self.gen_id(),
716 transaction_id: transaction_id.to_string(),
717 })
718 .await,
719 &["history_status"],
720 )?;
721 Ok(HistoryStatusInfo {
722 transaction_id: out["transaction_id"]
723 .as_str()
724 .unwrap_or(transaction_id)
725 .to_string(),
726 status: serde_json::from_value(out["status"].clone()).unwrap_or(TxStatus::Pending),
727 confirmations: out
728 .get("confirmations")
729 .and_then(|v| v.as_u64())
730 .map(|v| v as u32),
731 preimage: out
732 .get("preimage")
733 .and_then(|v| v.as_str())
734 .map(|s| s.to_string()),
735 item: out
736 .get("item")
737 .and_then(|v| serde_json::from_value(v.clone()).ok()),
738 })
739 }
740
741 async fn history_sync(&self, wallet: &str, limit: usize) -> Result<HistorySyncStats, PayError> {
742 let out = self.first_output(
743 self.call(&Input::HistoryUpdate {
744 id: self.gen_id(),
745 wallet: Some(wallet.to_string()),
746 network: Some(self.network),
747 limit: Some(limit),
748 })
749 .await,
750 &["history_updated"],
751 )?;
752 Ok(HistorySyncStats {
753 records_scanned: out
754 .get("records_scanned")
755 .and_then(|v| v.as_u64())
756 .unwrap_or(0) as usize,
757 records_added: out
758 .get("records_added")
759 .and_then(|v| v.as_u64())
760 .unwrap_or(0) as usize,
761 records_updated: out
762 .get("records_updated")
763 .and_then(|v| v.as_u64())
764 .unwrap_or(0) as usize,
765 })
766 }
767}
768
769#[cfg(test)]
770mod tests {
771 use super::*;
772
773 #[test]
774 fn first_output_skips_log_events() {
775 let provider = RemoteProvider::new("http://127.0.0.1:1", "secret", Network::Cashu);
776 let out = provider
777 .first_output(
778 vec![
779 serde_json::json!({"code": "log", "event": "startup"}),
780 serde_json::json!({"code": "wallet_list", "wallets": []}),
781 ],
782 &["wallet_list"],
783 )
784 .expect("wallet_list output");
785 assert_eq!(out["code"], "wallet_list");
786 }
787
788 #[test]
789 fn first_output_maps_error() {
790 let provider = RemoteProvider::new("http://127.0.0.1:1", "secret", Network::Cashu);
791 let err = provider
792 .first_output(
793 vec![
794 serde_json::json!({"code": "log", "event": "wallet"}),
795 serde_json::json!({"code": "error", "error_code": "wallet_not_found", "error": "missing"}),
796 ],
797 &["wallet_list"],
798 )
799 .expect_err("error output should be mapped");
800 assert!(matches!(err, PayError::WalletNotFound(_)));
801 }
802
803 #[test]
804 fn first_output_maps_limit_exceeded() {
805 let provider = RemoteProvider::new("http://127.0.0.1:1", "secret", Network::Cashu);
806 let err = provider
807 .first_output(
808 vec![serde_json::json!({
809 "code": "limit_exceeded",
810 "rule_id": "r_abc123",
811 "spent": 1500,
812 "max_spend": 1000,
813 "remaining_s": 42
814 })],
815 &["sent"],
816 )
817 .expect_err("limit_exceeded should be mapped");
818 match err {
819 PayError::LimitExceeded {
820 rule_id,
821 spent,
822 max_spend,
823 remaining_s,
824 ..
825 } => {
826 assert_eq!(rule_id, "r_abc123");
827 assert_eq!(spent, 1500);
828 assert_eq!(max_spend, 1000);
829 assert_eq!(remaining_s, 42);
830 }
831 other => panic!("expected LimitExceeded, got: {other:?}"),
832 }
833 }
834}