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(¤t_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(¤t_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(¤t_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}