#![forbid(unsafe_code)]
#![warn(missing_docs)]
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use thiserror::Error;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct HtlcLockRequest {
pub committed_h_hex: String,
pub refund_after_seconds_from_now: u64,
pub claim_owner_secret_hex: String,
pub refund_owner_secret_hex: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct HtlcWitness {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provided_x_hex: Option<String>,
pub output_owner_hash_hex: String,
}
impl HtlcWitness {
pub fn claim(provided_x_hex: impl Into<String>, output_secret_hex: &str) -> Self {
Self {
provided_x_hex: Some(provided_x_hex.into()),
output_owner_hash_hex: hex::encode(Sha256::digest(output_secret_hex.as_bytes())),
}
}
pub fn refund(output_secret_hex: &str) -> Self {
Self {
provided_x_hex: None,
output_owner_hash_hex: hex::encode(Sha256::digest(output_secret_hex.as_bytes())),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct HtlcLockEntry {
pub output_index: usize,
pub request: HtlcLockRequest,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct HtlcWitnessEntry {
pub input_index: usize,
pub witness: HtlcWitness,
}
#[derive(Debug, Error)]
pub enum ClientError {
#[error("HTTP error: {status}: {body}")]
Http {
status: u16,
body: String,
},
#[error("transport error: {0}")]
Transport(String),
#[error("body encode error: {0}")]
Encode(String),
}
pub type ClientResult<T> = Result<T, ClientError>;
#[derive(Clone, Debug)]
pub struct Client {
base_url: String,
}
impl Client {
pub fn new(base_url: impl Into<String>) -> Self {
Self {
base_url: base_url.into(),
}
}
pub fn base_url(&self) -> &str {
&self.base_url
}
fn endpoint(&self, path: &str) -> String {
format!("{}{}", self.base_url.trim_end_matches('/'), path)
}
pub fn replace(&self, inputs: &[String], outputs: &[String]) -> ClientResult<()> {
let body = serde_json::json!({
"webcashes": inputs,
"new_webcashes": outputs,
"legalese": {"terms": true},
});
self.post_status(&self.endpoint("/api/v1/replace"), &body)?;
Ok(())
}
pub fn replace_with_htlc(
&self,
inputs: &[String],
outputs: &[String],
htlc_locks: &[HtlcLockEntry],
htlc_witnesses: &[HtlcWitnessEntry],
) -> ClientResult<()> {
let body = serde_json::json!({
"webcashes": inputs,
"new_webcashes": outputs,
"legalese": {"terms": true},
"htlc_locks": htlc_locks,
"htlc_witnesses": htlc_witnesses,
});
self.post_status(&self.endpoint("/api/v1/replace"), &body)?;
Ok(())
}
pub fn burn(&self, secret_token: &str) -> ClientResult<()> {
let body = serde_json::json!({
"webcash": secret_token,
"legalese": {"terms": true},
});
self.post_status(&self.endpoint("/api/v1/burn"), &body)?;
Ok(())
}
pub fn health_check(&self, public_tokens: &[String]) -> ClientResult<String> {
let body =
serde_json::to_string(public_tokens).map_err(|e| ClientError::Encode(e.to_string()))?;
self.post_raw(&self.endpoint("/api/v1/health_check"), &body)
}
pub fn mining_report(&self, preimage: &str) -> ClientResult<()> {
let body = serde_json::json!({
"preimage": preimage,
"legalese": {"terms": true},
});
self.post_status(&self.endpoint("/api/v1/mining_report"), &body)?;
Ok(())
}
pub fn issue(&self, body: &[u8], sig_hex: &str) -> ClientResult<()> {
self.post_signed(&self.endpoint("/api/v1/issue"), body, sig_hex)
}
pub fn target(&self) -> ClientResult<String> {
self.get_raw(&self.endpoint("/api/v1/target"))
}
pub fn stats(&self) -> ClientResult<String> {
self.get_raw(&self.endpoint("/api/v1/stats"))
}
fn get_raw(&self, url: &str) -> ClientResult<String> {
let resp = http_get(url).map_err(|e| ClientError::Transport(e.to_string()))?;
let (status, body) = parse_resp(&resp);
if !(200..300).contains(&status) {
return Err(ClientError::Http { status, body });
}
Ok(body)
}
fn post_status(&self, url: &str, body: &serde_json::Value) -> ClientResult<()> {
let body_str =
serde_json::to_string(body).map_err(|e| ClientError::Encode(e.to_string()))?;
let _ = self.post_raw(url, &body_str)?;
Ok(())
}
fn post_raw(&self, url: &str, body: &str) -> ClientResult<String> {
let resp = http_post(url, body, None).map_err(|e| ClientError::Transport(e.to_string()))?;
let (status, body) = parse_resp(&resp);
if !(200..300).contains(&status) {
return Err(ClientError::Http { status, body });
}
Ok(body)
}
fn post_signed(&self, url: &str, body: &[u8], sig_hex: &str) -> ClientResult<()> {
let body_str = std::str::from_utf8(body).map_err(|e| ClientError::Encode(e.to_string()))?;
let resp = http_post(url, body_str, Some(("X-Issuer-Signature", sig_hex)))
.map_err(|e| ClientError::Transport(e.to_string()))?;
let (status, body) = parse_resp(&resp);
if !(200..300).contains(&status) {
return Err(ClientError::Http { status, body });
}
Ok(())
}
}
fn http_get(url: &str) -> std::io::Result<String> {
http_send(url, "GET", "", None)
}
fn http_post(url: &str, body: &str, extra: Option<(&str, &str)>) -> std::io::Result<String> {
http_send(url, "POST", body, extra)
}
fn http_send(
url: &str,
method: &str,
body: &str,
extra: Option<(&str, &str)>,
) -> std::io::Result<String> {
use std::io::{Read, Write};
let after = url.strip_prefix("http://").unwrap_or(url);
let (host_port, path) = after
.split_once('/')
.map(|(h, p)| (h.to_string(), format!("/{p}")))
.unwrap_or((after.to_string(), "/".into()));
let mut s = std::net::TcpStream::connect(&host_port)?;
s.set_read_timeout(Some(std::time::Duration::from_secs(15)))?;
let extra_hdr = match extra {
Some((k, v)) if !v.is_empty() => format!("{k}: {v}\r\n"),
_ => String::new(),
};
let req = format!(
"{method} {path} HTTP/1.1\r\nHost: {host_port}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n{}\r\n",
body.len(),
extra_hdr,
);
s.write_all(req.as_bytes())?;
if !body.is_empty() {
s.write_all(body.as_bytes())?;
}
let mut buf = Vec::new();
s.read_to_end(&mut buf)?;
Ok(String::from_utf8_lossy(&buf).to_string())
}
fn parse_resp(raw: &str) -> (u16, String) {
let status: u16 = raw
.lines()
.next()
.unwrap_or("")
.split_whitespace()
.nth(1)
.and_then(|s| s.parse().ok())
.unwrap_or(0);
let body_start = raw.find("\r\n\r\n").map(|i| i + 4).unwrap_or(raw.len());
(status, raw[body_start..].to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn endpoint_drops_trailing_slash_from_base() {
let c1 = Client::new("http://x:1");
let c2 = Client::new("http://x:1/");
assert_eq!(c1.endpoint("/api/v1/replace"), "http://x:1/api/v1/replace");
assert_eq!(c2.endpoint("/api/v1/replace"), "http://x:1/api/v1/replace");
}
#[test]
fn endpoint_accepts_https_base() {
let c = Client::new("https://webcash.org");
assert_eq!(
c.endpoint("/api/v1/target"),
"https://webcash.org/api/v1/target"
);
}
#[test]
fn parse_resp_extracts_status_and_body() {
let raw = "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\r\n{\"ok\":true}";
let (status, body) = parse_resp(raw);
assert_eq!(status, 200);
assert_eq!(body, "{\"ok\":true}");
}
#[test]
fn parse_resp_handles_500() {
let raw = "HTTP/1.1 500 Internal Server Error\r\nContent-Type: text/html\r\n\r\nboom";
let (status, body) = parse_resp(raw);
assert_eq!(status, 500);
assert_eq!(body, "boom");
}
#[test]
fn parse_resp_unknown_status_yields_zero() {
let raw = "garbage\r\n\r\nbody";
let (status, _body) = parse_resp(raw);
assert_eq!(status, 0);
}
#[test]
fn parse_resp_no_blank_line_returns_empty_body() {
let raw = "HTTP/1.1 204 No Content\r\nServer: x\r\n";
let (status, body) = parse_resp(raw);
assert_eq!(status, 204);
assert_eq!(body, "");
}
#[test]
fn replace_fails_with_transport_error_on_unreachable_url() {
let c = Client::new("http://127.0.0.1:1"); let err = c
.replace(&["a".into()], &["b".into()])
.expect_err("must fail to connect");
assert!(matches!(err, ClientError::Transport(_)), "got {err:?}");
}
}