Skip to main content

agent_first_pay/provider/
remote.rs

1use crate::mode::rpc::crypto::Cipher;
2use crate::mode::rpc::proto::af_pay_client::AfPayClient;
3use crate::mode::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 value.get("origin").is_none()
173                    || value.get("origin") == Some(&serde_json::Value::Null) =>
174            {
175                // If no origin, stamp the endpoint so the client knows which node rejected
176                value["origin"] = serde_json::Value::String(endpoint.to_string());
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;
199use serde::de::DeserializeOwned;
200use serde::Deserialize;
201use std::sync::atomic::{AtomicU64, Ordering};
202
203static REMOTE_REQUEST_FALLBACK_COUNTER: AtomicU64 = AtomicU64::new(0);
204
205#[derive(Deserialize)]
206struct WalletCreatedOut {
207    wallet: String,
208    address: String,
209    #[serde(default)]
210    label: Option<String>,
211    #[serde(default)]
212    mnemonic: Option<String>,
213}
214
215#[derive(Deserialize)]
216struct WalletListOut {
217    #[serde(default)]
218    wallets: Vec<WalletSummary>,
219}
220
221#[derive(Deserialize)]
222struct WalletBalancesOut {
223    #[serde(default)]
224    wallets: Vec<WalletBalanceItem>,
225}
226
227#[derive(Deserialize)]
228struct LegacyWalletBalanceOut {
229    #[serde(default)]
230    balance: Option<BalanceInfo>,
231}
232
233#[derive(Deserialize)]
234struct ReceiveInfoOut {
235    receive_info: ReceiveInfo,
236}
237
238#[derive(Deserialize)]
239struct ReceiveClaimedOut {
240    amount: Amount,
241}
242
243#[derive(Deserialize)]
244struct CashuSentOut {
245    wallet: String,
246    transaction_id: String,
247    status: TxStatus,
248    #[serde(default)]
249    fee: Option<Amount>,
250    token: String,
251}
252
253#[derive(Deserialize)]
254struct CashuReceivedOut {
255    wallet: String,
256    amount: Amount,
257    #[serde(default)]
258    memo: Option<String>,
259}
260
261#[derive(Deserialize)]
262struct SentOut {
263    wallet: String,
264    transaction_id: String,
265    amount: Amount,
266    #[serde(default)]
267    fee: Option<Amount>,
268    #[serde(default)]
269    preimage: Option<String>,
270}
271
272#[derive(Deserialize)]
273struct RestoredOut {
274    wallet: String,
275    unspent: u64,
276    spent: u64,
277    pending: u64,
278    unit: String,
279}
280
281#[derive(Deserialize)]
282struct HistoryOut {
283    #[serde(default)]
284    items: Vec<HistoryRecord>,
285}
286
287#[derive(Deserialize)]
288struct HistoryStatusOut {
289    transaction_id: String,
290    status: TxStatus,
291    #[serde(default)]
292    confirmations: Option<u32>,
293    #[serde(default)]
294    preimage: Option<String>,
295    #[serde(default)]
296    item: Option<HistoryRecord>,
297}
298
299#[derive(Deserialize)]
300struct HistoryUpdatedOut {
301    #[serde(default)]
302    records_scanned: usize,
303    #[serde(default)]
304    records_added: usize,
305    #[serde(default)]
306    records_updated: usize,
307}
308
309pub struct RemoteProvider {
310    endpoint: String,
311    secret: String,
312    network: Network,
313}
314
315impl RemoteProvider {
316    pub fn new(endpoint: &str, secret: &str, network: Network) -> Self {
317        Self {
318            endpoint: endpoint.to_string(),
319            secret: secret.to_string(),
320            network,
321        }
322    }
323
324    async fn call(&self, input: &Input) -> Vec<serde_json::Value> {
325        rpc_call(&self.endpoint, &self.secret, input).await
326    }
327
328    fn map_remote_error(&self, value: &serde_json::Value) -> Option<PayError> {
329        let code = value
330            .get("code")
331            .and_then(|v| v.as_str())
332            .unwrap_or_default();
333        match code {
334            "error" => {
335                let msg = value
336                    .get("error")
337                    .and_then(|v| v.as_str())
338                    .unwrap_or("unknown error");
339                let error_code = value
340                    .get("error_code")
341                    .and_then(|v| v.as_str())
342                    .unwrap_or("remote_error");
343                Some(match error_code {
344                    "wallet_not_found" => PayError::WalletNotFound(msg.to_string()),
345                    "invalid_amount" => PayError::InvalidAmount(msg.to_string()),
346                    "not_implemented" => PayError::NotImplemented(msg.to_string()),
347                    "limit_exceeded" => PayError::LimitExceeded {
348                        rule_id: value
349                            .get("rule_id")
350                            .and_then(|v| v.as_str())
351                            .unwrap_or("")
352                            .to_string(),
353                        scope: serde_json::from_value(
354                            value
355                                .get("scope")
356                                .cloned()
357                                .unwrap_or_else(|| serde_json::json!("network")),
358                        )
359                        .unwrap_or(SpendScope::Network),
360                        scope_key: value
361                            .get("scope_key")
362                            .and_then(|v| v.as_str())
363                            .unwrap_or("")
364                            .to_string(),
365                        spent: value.get("spent").and_then(|v| v.as_u64()).unwrap_or(0),
366                        max_spend: value.get("max_spend").and_then(|v| v.as_u64()).unwrap_or(0),
367                        token: value
368                            .get("token")
369                            .and_then(|v| v.as_str())
370                            .map(|s| s.to_string()),
371                        remaining_s: value
372                            .get("remaining_s")
373                            .and_then(|v| v.as_u64())
374                            .unwrap_or(0),
375                        origin: Some(
376                            value
377                                .get("origin")
378                                .and_then(|v| v.as_str())
379                                .map(|s| s.to_string())
380                                .unwrap_or_else(|| self.endpoint.clone()),
381                        ),
382                    },
383                    _ => PayError::NetworkError(msg.to_string()),
384                })
385            }
386            "limit_exceeded" => Some(PayError::LimitExceeded {
387                rule_id: value
388                    .get("rule_id")
389                    .and_then(|v| v.as_str())
390                    .unwrap_or("")
391                    .to_string(),
392                scope: serde_json::from_value(
393                    value
394                        .get("scope")
395                        .cloned()
396                        .unwrap_or_else(|| serde_json::json!("network")),
397                )
398                .unwrap_or(SpendScope::Network),
399                scope_key: value
400                    .get("scope_key")
401                    .and_then(|v| v.as_str())
402                    .unwrap_or("")
403                    .to_string(),
404                spent: value.get("spent").and_then(|v| v.as_u64()).unwrap_or(0),
405                max_spend: value.get("max_spend").and_then(|v| v.as_u64()).unwrap_or(0),
406                token: value
407                    .get("token")
408                    .and_then(|v| v.as_str())
409                    .map(|s| s.to_string()),
410                remaining_s: value
411                    .get("remaining_s")
412                    .and_then(|v| v.as_u64())
413                    .unwrap_or(0),
414                origin: Some(
415                    value
416                        .get("origin")
417                        .and_then(|v| v.as_str())
418                        .map(|s| s.to_string())
419                        .unwrap_or_else(|| self.endpoint.clone()),
420                ),
421            }),
422            _ => None,
423        }
424    }
425
426    /// Extract the first non-log expected output.
427    fn first_output(
428        &self,
429        outputs: Vec<serde_json::Value>,
430        expected_codes: &[&str],
431    ) -> Result<serde_json::Value, PayError> {
432        for value in outputs {
433            let code = value
434                .get("code")
435                .and_then(|v| v.as_str())
436                .unwrap_or_default();
437            if code == "log" {
438                continue;
439            }
440            if let Some(err) = self.map_remote_error(&value) {
441                return Err(err);
442            }
443            if expected_codes.contains(&code) {
444                return Ok(value);
445            }
446            return Err(PayError::NetworkError(format!(
447                "unexpected remote output code '{code}'"
448            )));
449        }
450        Err(PayError::NetworkError(
451            "empty or log-only response from remote".to_string(),
452        ))
453    }
454
455    fn parse_output<T: DeserializeOwned>(
456        &self,
457        value: serde_json::Value,
458        label: &str,
459    ) -> Result<T, PayError> {
460        serde_json::from_value(value)
461            .map_err(|e| PayError::NetworkError(format!("parse {label}: {e}")))
462    }
463
464    fn balance_from_output(
465        &self,
466        value: serde_json::Value,
467        wallet: &str,
468    ) -> Result<BalanceInfo, PayError> {
469        if value.get("code").and_then(|v| v.as_str()) == Some("wallet_balance") {
470            let parsed: LegacyWalletBalanceOut = self.parse_output(value, "wallet_balance")?;
471            return Ok(parsed
472                .balance
473                .unwrap_or_else(|| BalanceInfo::new(0, 0, "unknown")));
474        }
475
476        let parsed: WalletBalancesOut = self.parse_output(value, "wallet_balances")?;
477        let mut wallets = parsed.wallets;
478        let item = wallets
479            .iter()
480            .position(|item| item.wallet.id == wallet)
481            .map(|idx| wallets.remove(idx))
482            .or_else(|| {
483                // Current daemon returns a single-item wallet_balances response for
484                // single-wallet balance queries. Use it even if older daemons omit id.
485                (wallets.len() == 1).then(|| wallets.remove(0))
486            });
487        let Some(item) = item else {
488            return Err(PayError::WalletNotFound(format!(
489                "wallet {wallet} not found in remote balance response"
490            )));
491        };
492        item.balance.ok_or_else(|| {
493            PayError::NetworkError(
494                item.error
495                    .unwrap_or_else(|| "remote balance response has no balance".to_string()),
496            )
497        })
498    }
499
500    fn gen_id(&self) -> String {
501        crate::store::wallet::generate_request_identifier().unwrap_or_else(|_| {
502            let seq = REMOTE_REQUEST_FALLBACK_COUNTER.fetch_add(1, Ordering::Relaxed);
503            format!(
504                "req_fallback_{}_{}",
505                crate::store::wallet::now_epoch_seconds(),
506                seq
507            )
508        })
509    }
510}
511
512#[async_trait]
513impl PayProvider for RemoteProvider {
514    fn network(&self) -> Network {
515        self.network
516    }
517
518    async fn ping(&self) -> Result<(), PayError> {
519        let outputs = self.call(&Input::Version).await;
520        for value in &outputs {
521            if let Some(err) = self.map_remote_error(value) {
522                return Err(err);
523            }
524            if value.get("code").and_then(|v| v.as_str()) == Some("version") {
525                let remote_version = value
526                    .get("version")
527                    .and_then(|v| v.as_str())
528                    .unwrap_or("unknown");
529                let local = crate::config::VERSION;
530                if remote_version != local {
531                    return Err(PayError::NetworkError(format!(
532                        "version mismatch: local v{local}, remote v{remote_version}"
533                    )));
534                }
535            }
536        }
537        Ok(())
538    }
539
540    async fn create_wallet(&self, request: &WalletCreateRequest) -> Result<WalletInfo, PayError> {
541        let out = self.first_output(
542            self.call(&Input::WalletCreate {
543                id: self.gen_id(),
544                network: self.network,
545                label: Some(request.label.clone()),
546                mint_url: request.mint_url.clone(),
547                rpc_endpoints: request.rpc_endpoints.clone(),
548                chain_id: request.chain_id,
549                mnemonic_secret: request.mnemonic_secret.clone(),
550                btc_esplora_url: request.btc_esplora_url.clone(),
551                btc_network: request.btc_network.clone(),
552                btc_address_type: request.btc_address_type.clone(),
553                btc_backend: request.btc_backend,
554                btc_core_url: request.btc_core_url.clone(),
555                btc_core_auth_secret: request.btc_core_auth_secret.clone(),
556                btc_electrum_url: request.btc_electrum_url.clone(),
557            })
558            .await,
559            &["wallet_created"],
560        )?;
561        let parsed: WalletCreatedOut = self.parse_output(out, "wallet_created")?;
562        Ok(WalletInfo {
563            id: parsed.wallet,
564            network: self.network,
565            address: parsed.address,
566            label: parsed.label,
567            mnemonic: parsed.mnemonic,
568        })
569    }
570
571    async fn create_ln_wallet(
572        &self,
573        request: LnWalletCreateRequest,
574    ) -> Result<WalletInfo, PayError> {
575        if self.network != Network::Ln {
576            return Err(PayError::InvalidAmount(
577                "ln_wallet_create can only be used with ln provider".to_string(),
578            ));
579        }
580        let out = self.first_output(
581            self.call(&Input::LnWalletCreate {
582                id: self.gen_id(),
583                request,
584            })
585            .await,
586            &["wallet_created"],
587        )?;
588        let parsed: WalletCreatedOut = self.parse_output(out, "wallet_created")?;
589        Ok(WalletInfo {
590            id: parsed.wallet,
591            network: self.network,
592            address: parsed.address,
593            label: parsed.label,
594            mnemonic: parsed.mnemonic,
595        })
596    }
597
598    async fn close_wallet(&self, wallet: &str) -> Result<(), PayError> {
599        self.first_output(
600            self.call(&Input::WalletClose {
601                id: self.gen_id(),
602                wallet: wallet.to_string(),
603                dangerously_skip_balance_check_and_may_lose_money: false,
604            })
605            .await,
606            &["wallet_closed"],
607        )?;
608        Ok(())
609    }
610
611    async fn list_wallets(&self) -> Result<Vec<WalletSummary>, PayError> {
612        let out = self.first_output(
613            self.call(&Input::WalletList {
614                id: self.gen_id(),
615                network: Some(self.network),
616            })
617            .await,
618            &["wallet_list"],
619        )?;
620        let parsed: WalletListOut = self.parse_output(out, "wallet_list")?;
621        Ok(parsed.wallets)
622    }
623
624    async fn balance(&self, wallet: &str) -> Result<BalanceInfo, PayError> {
625        let out = self.first_output(
626            self.call(&Input::Balance {
627                id: self.gen_id(),
628                wallet: Some(wallet.to_string()),
629                network: None,
630                check: false,
631            })
632            .await,
633            &["wallet_balances", "wallet_balance"],
634        )?;
635        self.balance_from_output(out, wallet)
636    }
637
638    async fn check_balance(&self, wallet: &str) -> Result<BalanceInfo, PayError> {
639        let out = self.first_output(
640            self.call(&Input::Balance {
641                id: self.gen_id(),
642                wallet: Some(wallet.to_string()),
643                network: None,
644                check: true,
645            })
646            .await,
647            &["wallet_balances", "wallet_balance"],
648        )?;
649        self.balance_from_output(out, wallet)
650    }
651
652    async fn balance_all(&self) -> Result<Vec<WalletBalanceItem>, PayError> {
653        let out = self.first_output(
654            self.call(&Input::Balance {
655                id: self.gen_id(),
656                wallet: None,
657                network: None,
658                check: false,
659            })
660            .await,
661            &["wallet_balances", "wallet_balance"],
662        )?;
663        // Could be wallet_balance (legacy single) or wallet_balances (current).
664        if out.get("code").and_then(|v| v.as_str()) == Some("wallet_balance") {
665            let legacy: LegacyWalletBalanceOut = self.parse_output(out, "wallet_balance")?;
666            let Some(balance) = legacy.balance else {
667                return Ok(vec![]);
668            };
669            return Ok(vec![WalletBalanceItem {
670                wallet: WalletSummary {
671                    id: String::new(),
672                    network: self.network,
673                    label: None,
674                    address: String::new(),
675                    backend: None,
676                    mint_url: None,
677                    rpc_endpoints: None,
678                    chain_id: None,
679                    created_at_epoch_s: 0,
680                },
681                balance: Some(balance),
682                error: None,
683            }]);
684        }
685        let parsed: WalletBalancesOut = self.parse_output(out, "wallet_balances")?;
686        Ok(parsed.wallets)
687    }
688
689    async fn receive_info(
690        &self,
691        wallet: &str,
692        amount: Option<Amount>,
693    ) -> Result<ReceiveInfo, PayError> {
694        let out = self.first_output(
695            self.call(&Input::Receive {
696                id: self.gen_id(),
697                wallet: wallet.to_string(),
698                network: Some(self.network),
699                amount,
700                onchain_memo: None,
701                wait_until_paid: false,
702                wait_timeout_s: None,
703                wait_poll_interval_ms: None,
704                wait_sync_limit: None,
705                write_qr_svg_file: false,
706                min_confirmations: None,
707                reference: None,
708            })
709            .await,
710            &["receive_info"],
711        )?;
712        let parsed: ReceiveInfoOut = self.parse_output(out, "receive_info")?;
713        Ok(parsed.receive_info)
714    }
715
716    async fn receive_claim(&self, wallet: &str, quote_id: &str) -> Result<u64, PayError> {
717        let out = self.first_output(
718            self.call(&Input::ReceiveClaim {
719                id: self.gen_id(),
720                wallet: wallet.to_string(),
721                quote_id: quote_id.to_string(),
722            })
723            .await,
724            &["receive_claimed"],
725        )?;
726        let parsed: ReceiveClaimedOut = self.parse_output(out, "receive_claimed")?;
727        Ok(parsed.amount.value)
728    }
729
730    async fn cashu_send(
731        &self,
732        wallet: &str,
733        amount: Amount,
734        onchain_memo: Option<&str>,
735        mints: Option<&[String]>,
736    ) -> Result<CashuSendResult, PayError> {
737        let out = self.first_output(
738            self.call(&Input::CashuSend {
739                id: self.gen_id(),
740                wallet: Some(wallet.to_string()),
741                amount: amount.clone(),
742                onchain_memo: onchain_memo.map(|s| s.to_string()),
743                local_memo: None,
744                mints: mints.map(|m| m.to_vec()),
745            })
746            .await,
747            &["cashu_sent"],
748        )?;
749        let parsed: CashuSentOut = self.parse_output(out, "cashu_sent")?;
750        Ok(CashuSendResult {
751            wallet: parsed.wallet,
752            transaction_id: parsed.transaction_id,
753            status: parsed.status,
754            fee: parsed.fee,
755            token: parsed.token,
756        })
757    }
758
759    async fn cashu_receive(
760        &self,
761        wallet: &str,
762        token: &str,
763    ) -> Result<CashuReceiveResult, PayError> {
764        let out = self.first_output(
765            self.call(&Input::CashuReceive {
766                id: self.gen_id(),
767                wallet: Some(wallet.to_string()),
768                token: token.to_string(),
769            })
770            .await,
771            &["cashu_received"],
772        )?;
773        let parsed: CashuReceivedOut = self.parse_output(out, "cashu_received")?;
774        Ok(CashuReceiveResult {
775            wallet: parsed.wallet,
776            amount: parsed.amount,
777            memo: parsed.memo,
778        })
779    }
780
781    async fn send(
782        &self,
783        wallet: &str,
784        to: &str,
785        onchain_memo: Option<&str>,
786        mints: Option<&[String]>,
787    ) -> Result<SendResult, PayError> {
788        let out = self.first_output(
789            self.call(&Input::Send {
790                id: self.gen_id(),
791                wallet: Some(wallet.to_string()),
792                network: Some(self.network),
793                to: to.to_string(),
794                onchain_memo: onchain_memo.map(|s| s.to_string()),
795                local_memo: None,
796                mints: mints.map(|m| m.to_vec()),
797            })
798            .await,
799            &["sent"],
800        )?;
801        let parsed: SentOut = self.parse_output(out, "sent")?;
802        Ok(SendResult {
803            wallet: parsed.wallet,
804            transaction_id: parsed.transaction_id,
805            amount: parsed.amount,
806            fee: parsed.fee,
807            preimage: parsed.preimage,
808        })
809    }
810
811    async fn restore(&self, wallet: &str) -> Result<RestoreResult, PayError> {
812        let out = self.first_output(
813            self.call(&Input::Restore {
814                id: self.gen_id(),
815                wallet: wallet.to_string(),
816            })
817            .await,
818            &["restored"],
819        )?;
820        let parsed: RestoredOut = self.parse_output(out, "restored")?;
821        Ok(RestoreResult {
822            wallet: parsed.wallet,
823            unspent: parsed.unspent,
824            spent: parsed.spent,
825            pending: parsed.pending,
826            unit: parsed.unit,
827        })
828    }
829
830    async fn history_list(
831        &self,
832        wallet: &str,
833        limit: usize,
834        offset: usize,
835    ) -> Result<Vec<HistoryRecord>, PayError> {
836        let out = self.first_output(
837            self.call(&Input::HistoryList {
838                id: self.gen_id(),
839                wallet: Some(wallet.to_string()),
840                network: None,
841                onchain_memo: None,
842                limit: Some(limit),
843                offset: Some(offset),
844                since_epoch_s: None,
845                until_epoch_s: None,
846            })
847            .await,
848            &["history"],
849        )?;
850        let parsed: HistoryOut = self.parse_output(out, "history")?;
851        Ok(parsed.items)
852    }
853
854    async fn history_status(&self, transaction_id: &str) -> Result<HistoryStatusInfo, PayError> {
855        let out = self.first_output(
856            self.call(&Input::HistoryStatus {
857                id: self.gen_id(),
858                transaction_id: transaction_id.to_string(),
859            })
860            .await,
861            &["history_status"],
862        )?;
863        let parsed: HistoryStatusOut = self.parse_output(out, "history_status")?;
864        Ok(HistoryStatusInfo {
865            transaction_id: parsed.transaction_id,
866            status: parsed.status,
867            confirmations: parsed.confirmations,
868            preimage: parsed.preimage,
869            item: parsed.item,
870        })
871    }
872
873    async fn history_sync(&self, wallet: &str, limit: usize) -> Result<HistorySyncStats, PayError> {
874        let out = self.first_output(
875            self.call(&Input::HistoryUpdate {
876                id: self.gen_id(),
877                wallet: Some(wallet.to_string()),
878                network: Some(self.network),
879                limit: Some(limit),
880            })
881            .await,
882            &["history_updated"],
883        )?;
884        let parsed: HistoryUpdatedOut = self.parse_output(out, "history_updated")?;
885        Ok(HistorySyncStats {
886            records_scanned: parsed.records_scanned,
887            records_added: parsed.records_added,
888            records_updated: parsed.records_updated,
889        })
890    }
891}
892
893#[cfg(test)]
894#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
895mod tests {
896    use super::*;
897
898    #[test]
899    fn first_output_skips_log_events() {
900        let provider = RemoteProvider::new("http://127.0.0.1:1", "secret", Network::Cashu);
901        let out = provider
902            .first_output(
903                vec![
904                    serde_json::json!({"code": "log", "event": "startup"}),
905                    serde_json::json!({"code": "wallet_list", "wallets": []}),
906                ],
907                &["wallet_list"],
908            )
909            .expect("wallet_list output");
910        assert_eq!(out["code"], "wallet_list");
911    }
912
913    #[test]
914    fn first_output_maps_error() {
915        let provider = RemoteProvider::new("http://127.0.0.1:1", "secret", Network::Cashu);
916        let err = provider
917            .first_output(
918                vec![
919                    serde_json::json!({"code": "log", "event": "wallet"}),
920                    serde_json::json!({"code": "error", "error_code": "wallet_not_found", "error": "missing"}),
921                ],
922                &["wallet_list"],
923            )
924            .expect_err("error output should be mapped");
925        assert!(matches!(err, PayError::WalletNotFound(_)));
926    }
927
928    #[test]
929    fn first_output_maps_limit_exceeded() {
930        let provider = RemoteProvider::new("http://127.0.0.1:1", "secret", Network::Cashu);
931        let err = provider
932            .first_output(
933                vec![serde_json::json!({
934                    "code": "limit_exceeded",
935                    "rule_id": "r_abc123",
936                    "spent": 1500,
937                    "max_spend": 1000,
938                    "remaining_s": 42
939                })],
940                &["sent"],
941            )
942            .expect_err("limit_exceeded should be mapped");
943        match err {
944            PayError::LimitExceeded {
945                rule_id,
946                spent,
947                max_spend,
948                remaining_s,
949                ..
950            } => {
951                assert_eq!(rule_id, "r_abc123");
952                assert_eq!(spent, 1500);
953                assert_eq!(max_spend, 1000);
954                assert_eq!(remaining_s, 42);
955            }
956            other => panic!("expected LimitExceeded, got: {other:?}"),
957        }
958    }
959
960    #[test]
961    fn balance_parses_current_wallet_balances_schema() {
962        let provider = RemoteProvider::new("http://127.0.0.1:1", "secret", Network::Cashu);
963        let balance = provider
964            .balance_from_output(
965                serde_json::json!({
966                    "code": "wallet_balances",
967                    "wallets": [{
968                        "id": "w_1",
969                        "network": "cashu",
970                        "address": "https://mint.example",
971                        "created_at_epoch_s": 1,
972                        "balance": {
973                            "confirmed": 42,
974                            "pending": 0,
975                            "unit": "sats"
976                        }
977                    }]
978                }),
979                "w_1",
980            )
981            .expect("balance should parse");
982        assert_eq!(balance.confirmed, 42);
983        assert_eq!(balance.unit, "sats");
984    }
985}