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