Skip to main content

agent_first_pay/provider/
remote.rs

1use crate::rpc::crypto::Cipher;
2use crate::rpc::proto::af_pay_client::AfPayClient;
3use crate::rpc::proto::EncryptedRequest;
4use agent_first_data::OutputFormat;
5
6/// Send an Input to a remote RPC server, return the decrypted Output array.
7pub 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    // Serialize input to JSON
15    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    // Encrypt
21    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    // Build endpoint URL (tonic needs http:// prefix)
27    let url = if endpoint.starts_with("http://") || endpoint.starts_with("https://") {
28        endpoint.to_string()
29    } else {
30        format!("http://{endpoint}")
31    };
32
33    // Connect and call
34    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    // Decrypt response
56    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    // Parse as JSON array of Outputs
62    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
87/// Validate rpc_endpoint + rpc_secret pair. Returns (endpoint, secret) or prints error and exits.
88pub 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
120/// Render remote RPC outputs, filtering log events. Returns true if any output was an error.
121pub 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 = agent_first_data::cli_output(value, format);
139        println!("{rendered}");
140    }
141    had_error
142}
143
144/// When a client connects via --rpc-endpoint, wrap the daemon's LimitStatus response
145/// so the connected daemon appears as a node in the topology.
146/// Also stamps `origin` on limit_exceeded errors that lack one.
147pub 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                // Extract daemon's limits + downstream, wrap as a downstream node
153                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 no origin, stamp the endpoint so the client knows which node rejected
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());
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
192// ═══════════════════════════════════════════
193// RemoteProvider — PayProvider over RPC
194// ═══════════════════════════════════════════
195
196use 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    /// Extract the first non-log expected output.
318    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        // Could be wallet_balance (single) or wallet_balances (all)
518        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}