1#![forbid(unsafe_code)]
15#![warn(missing_docs)]
16
17use serde::{Deserialize, Serialize};
18use sha2::{Digest, Sha256};
19use thiserror::Error;
20
21#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
31pub struct HtlcLockRequest {
32 pub committed_h_hex: String,
34 pub refund_after_seconds_from_now: u64,
37 pub claim_owner_secret_hex: String,
39 pub refund_owner_secret_hex: String,
41}
42
43#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
45pub struct HtlcWitness {
46 #[serde(default, skip_serializing_if = "Option::is_none")]
48 pub provided_x_hex: Option<String>,
49 pub output_owner_hash_hex: String,
52}
53
54impl HtlcWitness {
55 pub fn claim(provided_x_hex: impl Into<String>, output_secret_hex: &str) -> Self {
58 Self {
59 provided_x_hex: Some(provided_x_hex.into()),
60 output_owner_hash_hex: hex::encode(Sha256::digest(output_secret_hex.as_bytes())),
61 }
62 }
63
64 pub fn refund(output_secret_hex: &str) -> Self {
66 Self {
67 provided_x_hex: None,
68 output_owner_hash_hex: hex::encode(Sha256::digest(output_secret_hex.as_bytes())),
69 }
70 }
71}
72
73#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
76pub struct HtlcLockEntry {
77 pub output_index: usize,
79 pub request: HtlcLockRequest,
81}
82
83#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
86pub struct HtlcWitnessEntry {
87 pub input_index: usize,
89 pub witness: HtlcWitness,
91}
92
93#[derive(Debug, Error)]
102pub enum ClientError {
103 #[error("HTTP error: {status}: {body}")]
105 Http {
106 status: u16,
108 body: String,
110 },
111 #[error("transport error: {0}")]
113 Transport(String),
114 #[error("body encode error: {0}")]
116 Encode(String),
117}
118
119pub type ClientResult<T> = Result<T, ClientError>;
122
123#[derive(Clone, Debug)]
126pub struct Client {
127 base_url: String,
128}
129
130impl Client {
131 pub fn new(base_url: impl Into<String>) -> Self {
141 Self {
142 base_url: base_url.into(),
143 }
144 }
145
146 pub fn base_url(&self) -> &str {
149 &self.base_url
150 }
151
152 fn endpoint(&self, path: &str) -> String {
153 format!("{}{}", self.base_url.trim_end_matches('/'), path)
154 }
155
156 pub fn replace(&self, inputs: &[String], outputs: &[String]) -> ClientResult<()> {
160 let body = serde_json::json!({
161 "webcashes": inputs,
162 "new_webcashes": outputs,
163 "legalese": {"terms": true},
164 });
165 self.post_status(&self.endpoint("/api/v1/replace"), &body)?;
166 Ok(())
167 }
168
169 pub fn replace_with_htlc(
184 &self,
185 inputs: &[String],
186 outputs: &[String],
187 htlc_locks: &[HtlcLockEntry],
188 htlc_witnesses: &[HtlcWitnessEntry],
189 ) -> ClientResult<()> {
190 let body = serde_json::json!({
191 "webcashes": inputs,
192 "new_webcashes": outputs,
193 "legalese": {"terms": true},
194 "htlc_locks": htlc_locks,
195 "htlc_witnesses": htlc_witnesses,
196 });
197 self.post_status(&self.endpoint("/api/v1/replace"), &body)?;
198 Ok(())
199 }
200
201 pub fn burn(&self, secret_token: &str) -> ClientResult<()> {
203 let body = serde_json::json!({
204 "webcash": secret_token,
205 "legalese": {"terms": true},
206 });
207 self.post_status(&self.endpoint("/api/v1/burn"), &body)?;
208 Ok(())
209 }
210
211 pub fn health_check(&self, public_tokens: &[String]) -> ClientResult<String> {
214 let body =
215 serde_json::to_string(public_tokens).map_err(|e| ClientError::Encode(e.to_string()))?;
216 self.post_raw(&self.endpoint("/api/v1/health_check"), &body)
217 }
218
219 pub fn mining_report(&self, preimage: &str) -> ClientResult<()> {
221 let body = serde_json::json!({
222 "preimage": preimage,
223 "legalese": {"terms": true},
224 });
225 self.post_status(&self.endpoint("/api/v1/mining_report"), &body)?;
226 Ok(())
227 }
228
229 pub fn issue(&self, body: &[u8], sig_hex: &str) -> ClientResult<()> {
233 self.post_signed(&self.endpoint("/api/v1/issue"), body, sig_hex)
234 }
235
236 pub fn target(&self) -> ClientResult<String> {
238 self.get_raw(&self.endpoint("/api/v1/target"))
239 }
240
241 pub fn stats(&self) -> ClientResult<String> {
244 self.get_raw(&self.endpoint("/api/v1/stats"))
245 }
246
247 fn get_raw(&self, url: &str) -> ClientResult<String> {
248 let resp = http_get(url).map_err(|e| ClientError::Transport(e.to_string()))?;
249 let (status, body) = parse_resp(&resp);
250 if !(200..300).contains(&status) {
251 return Err(ClientError::Http { status, body });
252 }
253 Ok(body)
254 }
255
256 fn post_status(&self, url: &str, body: &serde_json::Value) -> ClientResult<()> {
257 let body_str =
258 serde_json::to_string(body).map_err(|e| ClientError::Encode(e.to_string()))?;
259 let _ = self.post_raw(url, &body_str)?;
260 Ok(())
261 }
262
263 fn post_raw(&self, url: &str, body: &str) -> ClientResult<String> {
264 let resp = http_post(url, body, None).map_err(|e| ClientError::Transport(e.to_string()))?;
265 let (status, body) = parse_resp(&resp);
266 if !(200..300).contains(&status) {
267 return Err(ClientError::Http { status, body });
268 }
269 Ok(body)
270 }
271
272 fn post_signed(&self, url: &str, body: &[u8], sig_hex: &str) -> ClientResult<()> {
273 let body_str = std::str::from_utf8(body).map_err(|e| ClientError::Encode(e.to_string()))?;
274 let resp = http_post(url, body_str, Some(("X-Issuer-Signature", sig_hex)))
275 .map_err(|e| ClientError::Transport(e.to_string()))?;
276 let (status, body) = parse_resp(&resp);
277 if !(200..300).contains(&status) {
278 return Err(ClientError::Http { status, body });
279 }
280 Ok(())
281 }
282}
283
284fn http_get(url: &str) -> std::io::Result<String> {
290 http_send(url, "GET", "", None)
291}
292
293fn http_post(url: &str, body: &str, extra: Option<(&str, &str)>) -> std::io::Result<String> {
294 http_send(url, "POST", body, extra)
295}
296
297fn http_send(
298 url: &str,
299 method: &str,
300 body: &str,
301 extra: Option<(&str, &str)>,
302) -> std::io::Result<String> {
303 use std::io::{Read, Write};
304 let after = url.strip_prefix("http://").unwrap_or(url);
305 let (host_port, path) = after
306 .split_once('/')
307 .map(|(h, p)| (h.to_string(), format!("/{p}")))
308 .unwrap_or((after.to_string(), "/".into()));
309 let mut s = std::net::TcpStream::connect(&host_port)?;
310 s.set_read_timeout(Some(std::time::Duration::from_secs(15)))?;
311 let extra_hdr = match extra {
312 Some((k, v)) if !v.is_empty() => format!("{k}: {v}\r\n"),
313 _ => String::new(),
314 };
315 let req = format!(
316 "{method} {path} HTTP/1.1\r\nHost: {host_port}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n{}\r\n",
317 body.len(),
318 extra_hdr,
319 );
320 s.write_all(req.as_bytes())?;
321 if !body.is_empty() {
322 s.write_all(body.as_bytes())?;
323 }
324 let mut buf = Vec::new();
325 s.read_to_end(&mut buf)?;
326 Ok(String::from_utf8_lossy(&buf).to_string())
327}
328
329fn parse_resp(raw: &str) -> (u16, String) {
330 let status: u16 = raw
331 .lines()
332 .next()
333 .unwrap_or("")
334 .split_whitespace()
335 .nth(1)
336 .and_then(|s| s.parse().ok())
337 .unwrap_or(0);
338 let body_start = raw.find("\r\n\r\n").map(|i| i + 4).unwrap_or(raw.len());
339 (status, raw[body_start..].to_string())
340}
341
342#[cfg(test)]
343mod tests {
344 use super::*;
345
346 #[test]
347 fn endpoint_drops_trailing_slash_from_base() {
348 let c1 = Client::new("http://x:1");
349 let c2 = Client::new("http://x:1/");
350 assert_eq!(c1.endpoint("/api/v1/replace"), "http://x:1/api/v1/replace");
352 assert_eq!(c2.endpoint("/api/v1/replace"), "http://x:1/api/v1/replace");
353 }
354
355 #[test]
356 fn endpoint_accepts_https_base() {
357 let c = Client::new("https://webcash.org");
358 assert_eq!(
359 c.endpoint("/api/v1/target"),
360 "https://webcash.org/api/v1/target"
361 );
362 }
363
364 #[test]
365 fn parse_resp_extracts_status_and_body() {
366 let raw = "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\r\n{\"ok\":true}";
367 let (status, body) = parse_resp(raw);
368 assert_eq!(status, 200);
369 assert_eq!(body, "{\"ok\":true}");
370 }
371
372 #[test]
373 fn parse_resp_handles_500() {
374 let raw = "HTTP/1.1 500 Internal Server Error\r\nContent-Type: text/html\r\n\r\nboom";
375 let (status, body) = parse_resp(raw);
376 assert_eq!(status, 500);
377 assert_eq!(body, "boom");
378 }
379
380 #[test]
381 fn parse_resp_unknown_status_yields_zero() {
382 let raw = "garbage\r\n\r\nbody";
383 let (status, _body) = parse_resp(raw);
384 assert_eq!(status, 0);
385 }
386
387 #[test]
388 fn parse_resp_no_blank_line_returns_empty_body() {
389 let raw = "HTTP/1.1 204 No Content\r\nServer: x\r\n";
390 let (status, body) = parse_resp(raw);
391 assert_eq!(status, 204);
393 assert_eq!(body, "");
394 }
395
396 #[test]
397 fn replace_fails_with_transport_error_on_unreachable_url() {
398 let c = Client::new("http://127.0.0.1:1"); let err = c
400 .replace(&["a".into()], &["b".into()])
401 .expect_err("must fail to connect");
402 assert!(matches!(err, ClientError::Transport(_)), "got {err:?}");
403 }
404}