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