Skip to main content

cashu_service/
helper.rs

1use anyhow::{bail, Context, Result};
2use async_trait::async_trait;
3use serde::de::DeserializeOwned;
4use serde::{Deserialize, Serialize};
5use std::ffi::OsString;
6use std::path::{Path, PathBuf};
7use std::process::Command;
8use tokio::io::AsyncWriteExt;
9use tokio::process::Command as TokioCommand;
10
11pub const CASHU_HELPER_ENV: &str = "HTREE_CASHU_HELPER";
12pub const CARGO_HELPER_ENV: &str = "CARGO_BIN_EXE_htree-cashu";
13
14#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
15pub struct CashuSentPayment {
16    pub mint_url: String,
17    pub unit: String,
18    pub amount_sat: u64,
19    pub send_fee_sat: u64,
20    pub operation_id: String,
21    pub token: String,
22}
23
24#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
25pub struct CashuReceivedPayment {
26    pub mint_url: String,
27    pub unit: String,
28    pub amount_sat: u64,
29}
30
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
32pub struct CashuMintBalance {
33    pub mint_url: String,
34    pub unit: String,
35    pub balance_sat: u64,
36}
37
38#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
39pub struct CashuLightningPayment {
40    pub mint_url: String,
41    pub unit: String,
42    pub amount_sat: u64,
43    pub fee_paid_sat: u64,
44    pub quote_id: String,
45    pub preimage: String,
46}
47
48#[async_trait]
49pub trait CashuPaymentClient: Send + Sync {
50    async fn send_payment(&self, mint_url: &str, amount_sat: u64) -> Result<CashuSentPayment>;
51    async fn receive_payment(&self, encoded_token: &str) -> Result<CashuReceivedPayment>;
52    async fn revoke_payment(&self, mint_url: &str, operation_id: &str) -> Result<()>;
53    async fn mint_balance(&self, mint_url: &str) -> Result<CashuMintBalance>;
54}
55
56#[derive(Debug, Clone)]
57pub struct CashuHelperClient {
58    helper_path: PathBuf,
59    data_dir: PathBuf,
60}
61
62impl CashuHelperClient {
63    pub fn discover(data_dir: impl Into<PathBuf>) -> Result<Self> {
64        let current_exe =
65            std::env::current_exe().context("Failed to determine htree executable path")?;
66        let helper_path = helper_binary_path(&current_exe)?;
67        Ok(Self {
68            helper_path,
69            data_dir: data_dir.into(),
70        })
71    }
72
73    pub fn helper_path(&self) -> &Path {
74        &self.helper_path
75    }
76
77    pub fn data_dir(&self) -> &Path {
78        &self.data_dir
79    }
80
81    async fn run_json<T: DeserializeOwned>(
82        &self,
83        extra_args: &[OsString],
84        stdin: Option<&str>,
85    ) -> Result<T> {
86        let mut cmd = TokioCommand::new(&self.helper_path);
87        cmd.args(base_helper_args(&self.data_dir));
88        cmd.args(extra_args);
89        cmd.stdout(std::process::Stdio::piped());
90        cmd.stderr(std::process::Stdio::piped());
91        if stdin.is_some() {
92            cmd.stdin(std::process::Stdio::piped());
93        }
94
95        let mut child = cmd.spawn().with_context(|| {
96            format!(
97                "Failed to launch Cashu helper at {}",
98                self.helper_path.display()
99            )
100        })?;
101
102        if let Some(input) = stdin {
103            let mut child_stdin = child
104                .stdin
105                .take()
106                .context("Cashu helper stdin unavailable")?;
107            child_stdin
108                .write_all(input.as_bytes())
109                .await
110                .context("Failed writing Cashu helper stdin")?;
111            child_stdin
112                .shutdown()
113                .await
114                .context("Failed to close Cashu helper stdin")?;
115        }
116
117        let output = child
118            .wait_with_output()
119            .await
120            .context("Failed waiting for Cashu helper output")?;
121        if !output.status.success() {
122            let stderr = String::from_utf8_lossy(&output.stderr);
123            let detail = stderr.trim();
124            if detail.is_empty() {
125                bail!(
126                    "Cashu helper exited with status {}",
127                    output.status.code().unwrap_or_default()
128                );
129            }
130            bail!("Cashu helper failed: {detail}");
131        }
132
133        serde_json::from_slice(&output.stdout)
134            .context("Failed to decode JSON from Cashu helper output")
135    }
136}
137
138#[async_trait]
139impl CashuPaymentClient for CashuHelperClient {
140    async fn send_payment(&self, mint_url: &str, amount_sat: u64) -> Result<CashuSentPayment> {
141        self.run_json(
142            &[
143                OsString::from("internal"),
144                OsString::from("send"),
145                OsString::from(amount_sat.to_string()),
146                OsString::from("--mint"),
147                OsString::from(mint_url),
148            ],
149            None,
150        )
151        .await
152    }
153
154    async fn receive_payment(&self, encoded_token: &str) -> Result<CashuReceivedPayment> {
155        self.run_json(
156            &[
157                OsString::from("internal"),
158                OsString::from("receive"),
159                OsString::from("--token-stdin"),
160            ],
161            Some(encoded_token),
162        )
163        .await
164    }
165
166    async fn revoke_payment(&self, mint_url: &str, operation_id: &str) -> Result<()> {
167        let _: serde_json::Value = self
168            .run_json(
169                &[
170                    OsString::from("internal"),
171                    OsString::from("revoke"),
172                    OsString::from("--mint"),
173                    OsString::from(mint_url),
174                    OsString::from("--operation-id"),
175                    OsString::from(operation_id),
176                ],
177                None,
178            )
179            .await?;
180        Ok(())
181    }
182
183    async fn mint_balance(&self, mint_url: &str) -> Result<CashuMintBalance> {
184        self.run_json(
185            &[
186                OsString::from("internal"),
187                OsString::from("balance"),
188                OsString::from("--mint"),
189                OsString::from(mint_url),
190            ],
191            None,
192        )
193        .await
194    }
195}
196
197pub fn run_helper_status(helper_path: &Path, args: &[OsString]) -> Result<()> {
198    let status = Command::new(helper_path)
199        .args(args)
200        .status()
201        .with_context(|| format!("Failed to launch Cashu helper at {}", helper_path.display()))?;
202    if status.success() {
203        return Ok(());
204    }
205
206    match status.code() {
207        Some(code) => bail!("Cashu helper exited with status code {code}"),
208        None => bail!("Cashu helper terminated by signal"),
209    }
210}
211
212pub fn base_helper_args(data_dir: &Path) -> [OsString; 2] {
213    [
214        OsString::from("--data-dir"),
215        data_dir.as_os_str().to_os_string(),
216    ]
217}
218
219pub fn helper_binary_path(current_exe: &Path) -> Result<PathBuf> {
220    if let Some(path) = std::env::var_os(CASHU_HELPER_ENV) {
221        return Ok(PathBuf::from(path));
222    }
223    if let Some(path) = std::env::var_os(CARGO_HELPER_ENV) {
224        return Ok(PathBuf::from(path));
225    }
226
227    let helper_name = helper_binary_name();
228    let mut candidates = Vec::new();
229    if let Some(parent) = current_exe.parent() {
230        candidates.push(parent.join(helper_name));
231        if let Some(grandparent) = parent.parent() {
232            candidates.push(grandparent.join(helper_name));
233        }
234    }
235
236    if let Some(path) = candidates.into_iter().find(|path| path.exists()) {
237        return Ok(path);
238    }
239
240    bail!(
241        "Cashu helper executable not found. Install `hashtree-cashu-cli` so `htree-cashu` is in PATH next to `htree`, or set {CASHU_HELPER_ENV}."
242    )
243}
244
245pub fn helper_binary_name() -> &'static str {
246    if cfg!(windows) {
247        "htree-cashu.exe"
248    } else {
249        "htree-cashu"
250    }
251}
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256
257    #[test]
258    fn test_base_helper_args_uses_data_dir() {
259        let args = base_helper_args(Path::new("/tmp/hashtree"));
260        assert_eq!(args[0], OsString::from("--data-dir"));
261        assert_eq!(args[1], OsString::from("/tmp/hashtree"));
262    }
263}