Skip to main content

hashtree_cli/
cashu_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#[async_trait]
39pub trait CashuPaymentClient: Send + Sync {
40    async fn send_payment(&self, mint_url: &str, amount_sat: u64) -> Result<CashuSentPayment>;
41    async fn receive_payment(&self, encoded_token: &str) -> Result<CashuReceivedPayment>;
42    async fn revoke_payment(&self, mint_url: &str, operation_id: &str) -> Result<()>;
43    async fn mint_balance(&self, mint_url: &str) -> Result<CashuMintBalance>;
44}
45
46#[derive(Debug, Clone)]
47pub struct CashuHelperClient {
48    helper_path: PathBuf,
49    data_dir: PathBuf,
50}
51
52impl CashuHelperClient {
53    pub fn discover(data_dir: impl Into<PathBuf>) -> Result<Self> {
54        let current_exe =
55            std::env::current_exe().context("Failed to determine htree executable path")?;
56        let helper_path = helper_binary_path(&current_exe)?;
57        Ok(Self {
58            helper_path,
59            data_dir: data_dir.into(),
60        })
61    }
62
63    pub fn helper_path(&self) -> &Path {
64        &self.helper_path
65    }
66
67    pub fn data_dir(&self) -> &Path {
68        &self.data_dir
69    }
70
71    async fn run_json<T: DeserializeOwned>(
72        &self,
73        extra_args: &[OsString],
74        stdin: Option<&str>,
75    ) -> Result<T> {
76        let mut cmd = TokioCommand::new(&self.helper_path);
77        cmd.args(base_helper_args(&self.data_dir));
78        cmd.args(extra_args);
79        cmd.stdout(std::process::Stdio::piped());
80        cmd.stderr(std::process::Stdio::piped());
81        if stdin.is_some() {
82            cmd.stdin(std::process::Stdio::piped());
83        }
84
85        let mut child = cmd.spawn().with_context(|| {
86            format!(
87                "Failed to launch Cashu helper at {}",
88                self.helper_path.display()
89            )
90        })?;
91
92        if let Some(input) = stdin {
93            let mut child_stdin = child
94                .stdin
95                .take()
96                .context("Cashu helper stdin unavailable")?;
97            child_stdin
98                .write_all(input.as_bytes())
99                .await
100                .context("Failed writing Cashu helper stdin")?;
101            child_stdin
102                .shutdown()
103                .await
104                .context("Failed to close Cashu helper stdin")?;
105        }
106
107        let output = child
108            .wait_with_output()
109            .await
110            .context("Failed waiting for Cashu helper output")?;
111        if !output.status.success() {
112            let stderr = String::from_utf8_lossy(&output.stderr);
113            let detail = stderr.trim();
114            if detail.is_empty() {
115                bail!(
116                    "Cashu helper exited with status {}",
117                    output.status.code().unwrap_or_default()
118                );
119            }
120            bail!("Cashu helper failed: {detail}");
121        }
122
123        serde_json::from_slice(&output.stdout)
124            .context("Failed to decode JSON from Cashu helper output")
125    }
126}
127
128#[async_trait]
129impl CashuPaymentClient for CashuHelperClient {
130    async fn send_payment(&self, mint_url: &str, amount_sat: u64) -> Result<CashuSentPayment> {
131        self.run_json(
132            &[
133                OsString::from("internal"),
134                OsString::from("send"),
135                OsString::from(amount_sat.to_string()),
136                OsString::from("--mint"),
137                OsString::from(mint_url),
138            ],
139            None,
140        )
141        .await
142    }
143
144    async fn receive_payment(&self, encoded_token: &str) -> Result<CashuReceivedPayment> {
145        self.run_json(
146            &[
147                OsString::from("internal"),
148                OsString::from("receive"),
149                OsString::from("--token-stdin"),
150            ],
151            Some(encoded_token),
152        )
153        .await
154    }
155
156    async fn revoke_payment(&self, mint_url: &str, operation_id: &str) -> Result<()> {
157        let _: serde_json::Value = self
158            .run_json(
159                &[
160                    OsString::from("internal"),
161                    OsString::from("revoke"),
162                    OsString::from("--mint"),
163                    OsString::from(mint_url),
164                    OsString::from("--operation-id"),
165                    OsString::from(operation_id),
166                ],
167                None,
168            )
169            .await?;
170        Ok(())
171    }
172
173    async fn mint_balance(&self, mint_url: &str) -> Result<CashuMintBalance> {
174        self.run_json(
175            &[
176                OsString::from("internal"),
177                OsString::from("balance"),
178                OsString::from("--mint"),
179                OsString::from(mint_url),
180            ],
181            None,
182        )
183        .await
184    }
185}
186
187pub fn run_helper_status(helper_path: &Path, args: &[OsString]) -> Result<()> {
188    let status = Command::new(helper_path)
189        .args(args)
190        .status()
191        .with_context(|| format!("Failed to launch Cashu helper at {}", helper_path.display()))?;
192    if status.success() {
193        return Ok(());
194    }
195
196    match status.code() {
197        Some(code) => bail!("Cashu helper exited with status code {code}"),
198        None => bail!("Cashu helper terminated by signal"),
199    }
200}
201
202pub fn base_helper_args(data_dir: &Path) -> [OsString; 2] {
203    [
204        OsString::from("--data-dir"),
205        data_dir.as_os_str().to_os_string(),
206    ]
207}
208
209pub fn helper_binary_path(current_exe: &Path) -> Result<PathBuf> {
210    if let Some(path) = std::env::var_os(CASHU_HELPER_ENV) {
211        return Ok(PathBuf::from(path));
212    }
213    if let Some(path) = std::env::var_os(CARGO_HELPER_ENV) {
214        return Ok(PathBuf::from(path));
215    }
216
217    let helper_name = helper_binary_name();
218    let mut candidates = Vec::new();
219    if let Some(parent) = current_exe.parent() {
220        candidates.push(parent.join(helper_name));
221        if let Some(grandparent) = parent.parent() {
222            candidates.push(grandparent.join(helper_name));
223        }
224    }
225
226    if let Some(path) = candidates.into_iter().find(|path| path.exists()) {
227        return Ok(path);
228    }
229
230    bail!(
231        "Cashu helper executable not found. Install `hashtree-cashu-cli` so `htree-cashu` is in PATH next to `htree`, or set {CASHU_HELPER_ENV}."
232    )
233}
234
235pub fn helper_binary_name() -> &'static str {
236    if cfg!(windows) {
237        "htree-cashu.exe"
238    } else {
239        "htree-cashu"
240    }
241}
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246    use serde_json::json;
247    use std::env;
248    #[cfg(unix)]
249    use std::os::unix::fs::PermissionsExt;
250    use std::sync::{Mutex, OnceLock};
251
252    fn env_lock() -> &'static Mutex<()> {
253        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
254        LOCK.get_or_init(|| Mutex::new(()))
255    }
256
257    #[test]
258    fn test_helper_binary_path_prefers_env_override() {
259        let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner());
260        let temp_dir = tempfile::tempdir().unwrap();
261        let override_path = temp_dir.path().join("custom-helper");
262        std::fs::write(&override_path, b"").unwrap();
263
264        env::set_var(CASHU_HELPER_ENV, &override_path);
265        env::remove_var(CARGO_HELPER_ENV);
266
267        let resolved = helper_binary_path(Path::new("/tmp/htree")).unwrap();
268        assert_eq!(resolved, override_path);
269
270        env::remove_var(CASHU_HELPER_ENV);
271    }
272
273    #[test]
274    fn test_helper_binary_path_falls_back_to_sibling_binary() {
275        let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner());
276        env::remove_var(CASHU_HELPER_ENV);
277        env::remove_var(CARGO_HELPER_ENV);
278
279        let temp_dir = tempfile::tempdir().unwrap();
280        let current_exe = temp_dir.path().join("htree");
281        std::fs::write(&current_exe, b"").unwrap();
282        let sibling = temp_dir.path().join(helper_binary_name());
283        std::fs::write(&sibling, b"").unwrap();
284
285        let resolved = helper_binary_path(&current_exe).unwrap();
286        assert_eq!(resolved, sibling);
287    }
288
289    #[cfg(unix)]
290    #[tokio::test]
291    #[allow(clippy::await_holding_lock)]
292    async fn test_cashu_helper_client_send_and_receive_json() {
293        let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner());
294        env::remove_var(CARGO_HELPER_ENV);
295
296        let temp_dir = tempfile::tempdir().unwrap();
297        let helper_path = temp_dir.path().join("htree-cashu-stub");
298        let script = format!(
299            "#!/bin/sh\nif [ \"$3\" = \"internal\" ] && [ \"$4\" = \"send\" ]; then\n  printf '%s' '{}'\nelif [ \"$3\" = \"internal\" ] && [ \"$4\" = \"receive\" ]; then\n  cat >/dev/null\n  printf '%s' '{}'\nelse\n  printf '%s' '{}'\nfi\n",
300            json!({
301                "mint_url": "https://mint.example",
302                "unit": "sat",
303                "amount_sat": 3,
304                "send_fee_sat": 1,
305                "operation_id": "op-123",
306                "token": "cashuBtoken"
307            }),
308            json!({
309                "mint_url": "https://mint.example",
310                "unit": "sat",
311                "amount_sat": 3
312            }),
313            json!({"ok": true}),
314        );
315        std::fs::write(&helper_path, script).unwrap();
316        let mut perms = std::fs::metadata(&helper_path).unwrap().permissions();
317        perms.set_mode(0o755);
318        std::fs::set_permissions(&helper_path, perms).unwrap();
319
320        env::set_var(CASHU_HELPER_ENV, &helper_path);
321        let client = CashuHelperClient::discover(temp_dir.path()).unwrap();
322
323        let sent = client
324            .send_payment("https://mint.example", 3)
325            .await
326            .unwrap();
327        assert_eq!(sent.amount_sat, 3);
328        assert_eq!(sent.send_fee_sat, 1);
329        assert_eq!(sent.operation_id, "op-123");
330
331        let received = client.receive_payment("cashuBtoken").await.unwrap();
332        assert_eq!(received.amount_sat, 3);
333        assert_eq!(received.mint_url, "https://mint.example");
334
335        client
336            .revoke_payment("https://mint.example", "op-123")
337            .await
338            .unwrap();
339
340        env::remove_var(CASHU_HELPER_ENV);
341    }
342
343    #[cfg(unix)]
344    #[tokio::test]
345    #[allow(clippy::await_holding_lock)]
346    async fn test_cashu_helper_client_queries_mint_balance_json() {
347        let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner());
348        env::remove_var(CARGO_HELPER_ENV);
349
350        let temp_dir = tempfile::tempdir().unwrap();
351        let helper_path = temp_dir.path().join("htree-cashu-stub");
352        let script = format!(
353            "#!/bin/sh\nif [ \"$3\" = \"internal\" ] && [ \"$4\" = \"balance\" ]; then\n  printf '%s' '{}'\nelse\n  printf '%s' '{}'\nfi\n",
354            json!({
355                "mint_url": "https://mint.example",
356                "unit": "sat",
357                "balance_sat": 21
358            }),
359            json!({"ok": true}),
360        );
361        std::fs::write(&helper_path, script).unwrap();
362        let mut perms = std::fs::metadata(&helper_path).unwrap().permissions();
363        perms.set_mode(0o755);
364        std::fs::set_permissions(&helper_path, perms).unwrap();
365
366        env::set_var(CASHU_HELPER_ENV, &helper_path);
367        let client = CashuHelperClient::discover(temp_dir.path()).unwrap();
368        let balance = client.mint_balance("https://mint.example").await.unwrap();
369        assert_eq!(balance.mint_url, "https://mint.example");
370        assert_eq!(balance.unit, "sat");
371        assert_eq!(balance.balance_sat, 21);
372
373        env::remove_var(CASHU_HELPER_ENV);
374    }
375}