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;
5use std::io::Write;
6
7/// Send an Input to a remote RPC server, return the decrypted Output array.
8pub 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    // Serialize input to JSON
16    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    // Encrypt
22    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    // Build endpoint URL (tonic needs http:// prefix)
28    let url = if endpoint.starts_with("http://") || endpoint.starts_with("https://") {
29        endpoint.to_string()
30    } else {
31        format!("http://{endpoint}")
32    };
33
34    // Connect and call
35    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    // Decrypt response
57    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    // Parse as JSON array of Outputs
63    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
88/// Validate rpc_endpoint + rpc_secret pair. Returns (endpoint, secret) or prints error and exits.
89pub 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
121/// Render remote RPC outputs, filtering log events. Returns true if any output was an error.
122pub 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
145/// When a client connects via --rpc-endpoint, wrap the daemon's LimitStatus response
146/// so the connected daemon appears as a node in the topology.
147/// Also stamps `origin` on limit_exceeded errors that lack one.
148pub 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                // Extract daemon's limits + downstream, wrap as a downstream node
154                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 no origin, stamp the endpoint so the client knows which node rejected
173                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
193// ═══════════════════════════════════════════
194// RemoteProvider — PayProvider over RPC
195// ═══════════════════════════════════════════
196
197use 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    /// Extract the first non-log expected output.
319    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        // Could be wallet_balance (single) or wallet_balances (all)
519        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}