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