1use crate::rpc::crypto::Cipher;
2use crate::rpc::proto::af_pay_client::AfPayClient;
3use crate::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()
174 || value.get("origin") == Some(&serde_json::Value::Null)
175 {
176 value["origin"] = serde_json::Value::String(endpoint.to_string());
177 }
178 }
179 _ => {}
180 }
181 }
182}
183
184fn log_event_enabled(log: &[String], event: &str) -> bool {
185 if log.is_empty() {
186 return false;
187 }
188 let ev = event.to_ascii_lowercase();
189 log.iter()
190 .any(|f| f == "*" || f == "all" || ev.starts_with(f.as_str()))
191}
192
193use crate::provider::{HistorySyncStats, PayError, PayProvider};
198use crate::types::*;
199use async_trait::async_trait;
200
201pub struct RemoteProvider {
202 endpoint: String,
203 secret: String,
204 network: Network,
205}
206
207impl RemoteProvider {
208 pub fn new(endpoint: &str, secret: &str, network: Network) -> Self {
209 Self {
210 endpoint: endpoint.to_string(),
211 secret: secret.to_string(),
212 network,
213 }
214 }
215
216 async fn call(&self, input: &Input) -> Vec<serde_json::Value> {
217 rpc_call(&self.endpoint, &self.secret, input).await
218 }
219
220 fn map_remote_error(&self, value: &serde_json::Value) -> Option<PayError> {
221 let code = value
222 .get("code")
223 .and_then(|v| v.as_str())
224 .unwrap_or_default();
225 match code {
226 "error" => {
227 let msg = value
228 .get("error")
229 .and_then(|v| v.as_str())
230 .unwrap_or("unknown error");
231 let error_code = value
232 .get("error_code")
233 .and_then(|v| v.as_str())
234 .unwrap_or("remote_error");
235 Some(match error_code {
236 "wallet_not_found" => PayError::WalletNotFound(msg.to_string()),
237 "invalid_amount" => PayError::InvalidAmount(msg.to_string()),
238 "not_implemented" => PayError::NotImplemented(msg.to_string()),
239 "limit_exceeded" => PayError::LimitExceeded {
240 rule_id: value
241 .get("rule_id")
242 .and_then(|v| v.as_str())
243 .unwrap_or("")
244 .to_string(),
245 scope: serde_json::from_value(
246 value
247 .get("scope")
248 .cloned()
249 .unwrap_or_else(|| serde_json::json!("network")),
250 )
251 .unwrap_or(SpendScope::Network),
252 scope_key: value
253 .get("scope_key")
254 .and_then(|v| v.as_str())
255 .unwrap_or("")
256 .to_string(),
257 spent: value.get("spent").and_then(|v| v.as_u64()).unwrap_or(0),
258 max_spend: value.get("max_spend").and_then(|v| v.as_u64()).unwrap_or(0),
259 token: value
260 .get("token")
261 .and_then(|v| v.as_str())
262 .map(|s| s.to_string()),
263 remaining_s: value
264 .get("remaining_s")
265 .and_then(|v| v.as_u64())
266 .unwrap_or(0),
267 origin: Some(
268 value
269 .get("origin")
270 .and_then(|v| v.as_str())
271 .map(|s| s.to_string())
272 .unwrap_or_else(|| self.endpoint.clone()),
273 ),
274 },
275 _ => PayError::NetworkError(msg.to_string()),
276 })
277 }
278 "limit_exceeded" => Some(PayError::LimitExceeded {
279 rule_id: value
280 .get("rule_id")
281 .and_then(|v| v.as_str())
282 .unwrap_or("")
283 .to_string(),
284 scope: serde_json::from_value(
285 value
286 .get("scope")
287 .cloned()
288 .unwrap_or_else(|| serde_json::json!("network")),
289 )
290 .unwrap_or(SpendScope::Network),
291 scope_key: value
292 .get("scope_key")
293 .and_then(|v| v.as_str())
294 .unwrap_or("")
295 .to_string(),
296 spent: value.get("spent").and_then(|v| v.as_u64()).unwrap_or(0),
297 max_spend: value.get("max_spend").and_then(|v| v.as_u64()).unwrap_or(0),
298 token: value
299 .get("token")
300 .and_then(|v| v.as_str())
301 .map(|s| s.to_string()),
302 remaining_s: value
303 .get("remaining_s")
304 .and_then(|v| v.as_u64())
305 .unwrap_or(0),
306 origin: Some(
307 value
308 .get("origin")
309 .and_then(|v| v.as_str())
310 .map(|s| s.to_string())
311 .unwrap_or_else(|| self.endpoint.clone()),
312 ),
313 }),
314 _ => None,
315 }
316 }
317
318 fn first_output(
320 &self,
321 outputs: Vec<serde_json::Value>,
322 expected_codes: &[&str],
323 ) -> Result<serde_json::Value, PayError> {
324 for value in outputs {
325 let code = value
326 .get("code")
327 .and_then(|v| v.as_str())
328 .unwrap_or_default();
329 if code == "log" {
330 continue;
331 }
332 if let Some(err) = self.map_remote_error(&value) {
333 return Err(err);
334 }
335 if expected_codes.contains(&code) {
336 return Ok(value);
337 }
338 return Err(PayError::NetworkError(format!(
339 "unexpected remote output code '{code}'"
340 )));
341 }
342 Err(PayError::NetworkError(
343 "empty or log-only response from remote".to_string(),
344 ))
345 }
346
347 fn gen_id(&self) -> String {
348 format!("rpc_{}", crate::store::wallet::now_epoch_seconds())
349 }
350}
351
352#[async_trait]
353impl PayProvider for RemoteProvider {
354 fn network(&self) -> Network {
355 self.network
356 }
357
358 async fn ping(&self) -> Result<(), PayError> {
359 let outputs = self.call(&Input::Version).await;
360 for value in &outputs {
361 if let Some(err) = self.map_remote_error(value) {
362 return Err(err);
363 }
364 if value.get("code").and_then(|v| v.as_str()) == Some("version") {
365 let remote_version = value
366 .get("version")
367 .and_then(|v| v.as_str())
368 .unwrap_or("unknown");
369 let local = crate::config::VERSION;
370 if remote_version != local {
371 return Err(PayError::NetworkError(format!(
372 "version mismatch: local v{local}, remote v{remote_version}"
373 )));
374 }
375 }
376 }
377 Ok(())
378 }
379
380 async fn create_wallet(&self, request: &WalletCreateRequest) -> Result<WalletInfo, PayError> {
381 let out = self.first_output(
382 self.call(&Input::WalletCreate {
383 id: self.gen_id(),
384 network: self.network,
385 label: Some(request.label.clone()),
386 mint_url: request.mint_url.clone(),
387 rpc_endpoints: request.rpc_endpoints.clone(),
388 chain_id: request.chain_id,
389 mnemonic_secret: request.mnemonic_secret.clone(),
390 btc_esplora_url: request.btc_esplora_url.clone(),
391 btc_network: request.btc_network.clone(),
392 btc_address_type: request.btc_address_type.clone(),
393 btc_backend: request.btc_backend,
394 btc_core_url: request.btc_core_url.clone(),
395 btc_core_auth_secret: request.btc_core_auth_secret.clone(),
396 btc_electrum_url: request.btc_electrum_url.clone(),
397 })
398 .await,
399 &["wallet_created"],
400 )?;
401 Ok(WalletInfo {
402 id: out["wallet"].as_str().unwrap_or("").to_string(),
403 network: self.network,
404 address: out["address"].as_str().unwrap_or("").to_string(),
405 label: out["label"].as_str().map(|s| s.to_string()),
406 mnemonic: out["mnemonic"].as_str().map(|s| s.to_string()),
407 })
408 }
409
410 async fn create_ln_wallet(
411 &self,
412 request: LnWalletCreateRequest,
413 ) -> Result<WalletInfo, PayError> {
414 if self.network != Network::Ln {
415 return Err(PayError::InvalidAmount(
416 "ln_wallet_create can only be used with ln provider".to_string(),
417 ));
418 }
419 let out = self.first_output(
420 self.call(&Input::LnWalletCreate {
421 id: self.gen_id(),
422 request,
423 })
424 .await,
425 &["wallet_created"],
426 )?;
427 Ok(WalletInfo {
428 id: out["wallet"].as_str().unwrap_or("").to_string(),
429 network: self.network,
430 address: out["address"].as_str().unwrap_or("").to_string(),
431 label: out["label"].as_str().map(|s| s.to_string()),
432 mnemonic: out["mnemonic"].as_str().map(|s| s.to_string()),
433 })
434 }
435
436 async fn close_wallet(&self, wallet: &str) -> Result<(), PayError> {
437 self.first_output(
438 self.call(&Input::WalletClose {
439 id: self.gen_id(),
440 wallet: wallet.to_string(),
441 dangerously_skip_balance_check_and_may_lose_money: false,
442 })
443 .await,
444 &["wallet_closed"],
445 )?;
446 Ok(())
447 }
448
449 async fn list_wallets(&self) -> Result<Vec<WalletSummary>, PayError> {
450 let out = self.first_output(
451 self.call(&Input::WalletList {
452 id: self.gen_id(),
453 network: Some(self.network),
454 })
455 .await,
456 &["wallet_list"],
457 )?;
458 let wallets: Vec<WalletSummary> = serde_json::from_value(
459 out.get("wallets")
460 .cloned()
461 .unwrap_or(serde_json::Value::Array(vec![])),
462 )
463 .map_err(|e| PayError::NetworkError(format!("parse wallets: {e}")))?;
464 Ok(wallets)
465 }
466
467 async fn balance(&self, wallet: &str) -> Result<BalanceInfo, PayError> {
468 let out = self.first_output(
469 self.call(&Input::Balance {
470 id: self.gen_id(),
471 wallet: Some(wallet.to_string()),
472 network: None,
473 check: false,
474 })
475 .await,
476 &["wallet_balance"],
477 )?;
478 let parsed = out
479 .get("balance")
480 .cloned()
481 .map(serde_json::from_value::<BalanceInfo>)
482 .transpose()
483 .map_err(|e| PayError::NetworkError(format!("parse balance: {e}")))?;
484 Ok(parsed.unwrap_or_else(|| BalanceInfo::new(0, 0, "unknown")))
485 }
486
487 async fn check_balance(&self, wallet: &str) -> Result<BalanceInfo, PayError> {
488 let out = self.first_output(
489 self.call(&Input::Balance {
490 id: self.gen_id(),
491 wallet: Some(wallet.to_string()),
492 network: None,
493 check: true,
494 })
495 .await,
496 &["wallet_balance"],
497 )?;
498 let parsed = out
499 .get("balance")
500 .cloned()
501 .map(serde_json::from_value::<BalanceInfo>)
502 .transpose()
503 .map_err(|e| PayError::NetworkError(format!("parse balance: {e}")))?;
504 Ok(parsed.unwrap_or_else(|| BalanceInfo::new(0, 0, "unknown")))
505 }
506
507 async fn balance_all(&self) -> Result<Vec<WalletBalanceItem>, PayError> {
508 let out = self.first_output(
509 self.call(&Input::Balance {
510 id: self.gen_id(),
511 wallet: None,
512 network: None,
513 check: false,
514 })
515 .await,
516 &["wallet_balances", "wallet_balance"],
517 )?;
518 if let Some(wallets) = out.get("wallets") {
520 let items: Vec<WalletBalanceItem> = serde_json::from_value(wallets.clone())
521 .map_err(|e| PayError::NetworkError(format!("parse balances: {e}")))?;
522 return Ok(items);
523 }
524 Ok(vec![])
525 }
526
527 async fn receive_info(
528 &self,
529 wallet: &str,
530 amount: Option<Amount>,
531 ) -> Result<ReceiveInfo, PayError> {
532 let out = self.first_output(
533 self.call(&Input::Receive {
534 id: self.gen_id(),
535 wallet: wallet.to_string(),
536 network: Some(self.network),
537 amount,
538 onchain_memo: None,
539 wait_until_paid: false,
540 wait_timeout_s: None,
541 wait_poll_interval_ms: None,
542 wait_sync_limit: None,
543 write_qr_svg_file: false,
544 min_confirmations: None,
545 reference: None,
546 })
547 .await,
548 &["receive_info"],
549 )?;
550 let info: ReceiveInfo = serde_json::from_value(
551 out.get("receive_info")
552 .cloned()
553 .unwrap_or(serde_json::Value::Null),
554 )
555 .map_err(|e| PayError::NetworkError(format!("parse receive_info: {e}")))?;
556 Ok(info)
557 }
558
559 async fn receive_claim(&self, wallet: &str, quote_id: &str) -> Result<u64, PayError> {
560 let out = self.first_output(
561 self.call(&Input::ReceiveClaim {
562 id: self.gen_id(),
563 wallet: wallet.to_string(),
564 quote_id: quote_id.to_string(),
565 })
566 .await,
567 &["receive_claimed"],
568 )?;
569 Ok(out["amount"]["value"].as_u64().unwrap_or(0))
570 }
571
572 async fn cashu_send(
573 &self,
574 wallet: &str,
575 amount: Amount,
576 onchain_memo: Option<&str>,
577 mints: Option<&[String]>,
578 ) -> Result<CashuSendResult, PayError> {
579 let out = self.first_output(
580 self.call(&Input::CashuSend {
581 id: self.gen_id(),
582 wallet: Some(wallet.to_string()),
583 amount: amount.clone(),
584 onchain_memo: onchain_memo.map(|s| s.to_string()),
585 local_memo: None,
586 mints: mints.map(|m| m.to_vec()),
587 })
588 .await,
589 &["cashu_sent"],
590 )?;
591 Ok(CashuSendResult {
592 wallet: out["wallet"].as_str().unwrap_or(wallet).to_string(),
593 transaction_id: out["transaction_id"].as_str().unwrap_or("").to_string(),
594 status: serde_json::from_value(out["status"].clone()).unwrap_or(TxStatus::Pending),
595 fee: out
596 .get("fee")
597 .and_then(|v| serde_json::from_value(v.clone()).ok()),
598 token: out["token"].as_str().unwrap_or("").to_string(),
599 })
600 }
601
602 async fn cashu_receive(
603 &self,
604 wallet: &str,
605 token: &str,
606 ) -> Result<CashuReceiveResult, PayError> {
607 let out = self.first_output(
608 self.call(&Input::CashuReceive {
609 id: self.gen_id(),
610 wallet: Some(wallet.to_string()),
611 token: token.to_string(),
612 })
613 .await,
614 &["cashu_received"],
615 )?;
616 let amount: Amount = serde_json::from_value(
617 out.get("amount")
618 .cloned()
619 .unwrap_or(serde_json::json!({"value": 0, "unit": "sats"})),
620 )
621 .map_err(|e| PayError::NetworkError(format!("parse amount: {e}")))?;
622 Ok(CashuReceiveResult {
623 wallet: out["wallet"].as_str().unwrap_or(wallet).to_string(),
624 amount,
625 })
626 }
627
628 async fn send(
629 &self,
630 wallet: &str,
631 to: &str,
632 onchain_memo: Option<&str>,
633 mints: Option<&[String]>,
634 ) -> Result<SendResult, PayError> {
635 let out = self.first_output(
636 self.call(&Input::Send {
637 id: self.gen_id(),
638 wallet: Some(wallet.to_string()),
639 network: Some(self.network),
640 to: to.to_string(),
641 onchain_memo: onchain_memo.map(|s| s.to_string()),
642 local_memo: None,
643 mints: mints.map(|m| m.to_vec()),
644 })
645 .await,
646 &["sent"],
647 )?;
648 let amount: Amount = serde_json::from_value(
649 out.get("amount")
650 .cloned()
651 .unwrap_or(serde_json::json!({"value": 0, "unit": "sats"})),
652 )
653 .map_err(|e| PayError::NetworkError(format!("parse amount: {e}")))?;
654 Ok(SendResult {
655 wallet: out["wallet"].as_str().unwrap_or(wallet).to_string(),
656 transaction_id: out["transaction_id"].as_str().unwrap_or("").to_string(),
657 amount,
658 fee: out
659 .get("fee")
660 .and_then(|v| serde_json::from_value(v.clone()).ok()),
661 preimage: out
662 .get("preimage")
663 .and_then(|v| v.as_str())
664 .map(|s| s.to_string()),
665 })
666 }
667
668 async fn restore(&self, wallet: &str) -> Result<RestoreResult, PayError> {
669 let out = self.first_output(
670 self.call(&Input::Restore {
671 id: self.gen_id(),
672 wallet: wallet.to_string(),
673 })
674 .await,
675 &["restored"],
676 )?;
677 Ok(RestoreResult {
678 wallet: out["wallet"].as_str().unwrap_or(wallet).to_string(),
679 unspent: out["unspent"].as_u64().unwrap_or(0),
680 spent: out["spent"].as_u64().unwrap_or(0),
681 pending: out["pending"].as_u64().unwrap_or(0),
682 unit: out["unit"].as_str().unwrap_or("sats").to_string(),
683 })
684 }
685
686 async fn history_list(
687 &self,
688 wallet: &str,
689 limit: usize,
690 offset: usize,
691 ) -> Result<Vec<HistoryRecord>, PayError> {
692 let out = self.first_output(
693 self.call(&Input::HistoryList {
694 id: self.gen_id(),
695 wallet: Some(wallet.to_string()),
696 network: None,
697 onchain_memo: None,
698 limit: Some(limit),
699 offset: Some(offset),
700 since_epoch_s: None,
701 until_epoch_s: None,
702 })
703 .await,
704 &["history"],
705 )?;
706 let items: Vec<HistoryRecord> = serde_json::from_value(
707 out.get("items")
708 .cloned()
709 .unwrap_or(serde_json::Value::Array(vec![])),
710 )
711 .map_err(|e| PayError::NetworkError(format!("parse history items: {e}")))?;
712 Ok(items)
713 }
714
715 async fn history_status(&self, transaction_id: &str) -> Result<HistoryStatusInfo, PayError> {
716 let out = self.first_output(
717 self.call(&Input::HistoryStatus {
718 id: self.gen_id(),
719 transaction_id: transaction_id.to_string(),
720 })
721 .await,
722 &["history_status"],
723 )?;
724 Ok(HistoryStatusInfo {
725 transaction_id: out["transaction_id"]
726 .as_str()
727 .unwrap_or(transaction_id)
728 .to_string(),
729 status: serde_json::from_value(out["status"].clone()).unwrap_or(TxStatus::Pending),
730 confirmations: out
731 .get("confirmations")
732 .and_then(|v| v.as_u64())
733 .map(|v| v as u32),
734 preimage: out
735 .get("preimage")
736 .and_then(|v| v.as_str())
737 .map(|s| s.to_string()),
738 item: out
739 .get("item")
740 .and_then(|v| serde_json::from_value(v.clone()).ok()),
741 })
742 }
743
744 async fn history_sync(&self, wallet: &str, limit: usize) -> Result<HistorySyncStats, PayError> {
745 let out = self.first_output(
746 self.call(&Input::HistoryUpdate {
747 id: self.gen_id(),
748 wallet: Some(wallet.to_string()),
749 network: Some(self.network),
750 limit: Some(limit),
751 })
752 .await,
753 &["history_updated"],
754 )?;
755 Ok(HistorySyncStats {
756 records_scanned: out
757 .get("records_scanned")
758 .and_then(|v| v.as_u64())
759 .unwrap_or(0) as usize,
760 records_added: out
761 .get("records_added")
762 .and_then(|v| v.as_u64())
763 .unwrap_or(0) as usize,
764 records_updated: out
765 .get("records_updated")
766 .and_then(|v| v.as_u64())
767 .unwrap_or(0) as usize,
768 })
769 }
770}
771
772#[cfg(test)]
773mod tests {
774 use super::*;
775
776 #[test]
777 fn first_output_skips_log_events() {
778 let provider = RemoteProvider::new("http://127.0.0.1:1", "secret", Network::Cashu);
779 let out = provider
780 .first_output(
781 vec![
782 serde_json::json!({"code": "log", "event": "startup"}),
783 serde_json::json!({"code": "wallet_list", "wallets": []}),
784 ],
785 &["wallet_list"],
786 )
787 .expect("wallet_list output");
788 assert_eq!(out["code"], "wallet_list");
789 }
790
791 #[test]
792 fn first_output_maps_error() {
793 let provider = RemoteProvider::new("http://127.0.0.1:1", "secret", Network::Cashu);
794 let err = provider
795 .first_output(
796 vec![
797 serde_json::json!({"code": "log", "event": "wallet"}),
798 serde_json::json!({"code": "error", "error_code": "wallet_not_found", "error": "missing"}),
799 ],
800 &["wallet_list"],
801 )
802 .expect_err("error output should be mapped");
803 assert!(matches!(err, PayError::WalletNotFound(_)));
804 }
805
806 #[test]
807 fn first_output_maps_limit_exceeded() {
808 let provider = RemoteProvider::new("http://127.0.0.1:1", "secret", Network::Cashu);
809 let err = provider
810 .first_output(
811 vec![serde_json::json!({
812 "code": "limit_exceeded",
813 "rule_id": "r_abc123",
814 "spent": 1500,
815 "max_spend": 1000,
816 "remaining_s": 42
817 })],
818 &["sent"],
819 )
820 .expect_err("limit_exceeded should be mapped");
821 match err {
822 PayError::LimitExceeded {
823 rule_id,
824 spent,
825 max_spend,
826 remaining_s,
827 ..
828 } => {
829 assert_eq!(rule_id, "r_abc123");
830 assert_eq!(spent, 1500);
831 assert_eq!(max_spend, 1000);
832 assert_eq!(remaining_s, 42);
833 }
834 other => panic!("expected LimitExceeded, got: {other:?}"),
835 }
836 }
837}