Skip to main content

webylib_server_client/
lib.rs

1//! Minimal asset-agnostic HTTP client for the webycash-server family.
2//!
3//! Every flavor (Webcash, RGB Fungible, RGB Collectible, Voucher) speaks
4//! the same wire protocol — `/api/v1/replace`, `/health_check`, `/burn`,
5//! `/mining_report`, `/issue` — only the token wire format differs. This
6//! crate ships ONE Client; the wallet crates (`wallet-webcash`,
7//! `wallet-rgb`, `wallet-voucher`) wrap it in flavor-specific verbs
8//! (`pay`, `transfer`, `insert`).
9//!
10//! The Client is sync over reqwest::blocking by default for
11//! straightforward CLI integration; an async surface lives behind
12//! the `async` feature.
13
14#![forbid(unsafe_code)]
15#![warn(missing_docs)]
16
17use serde::{Deserialize, Serialize};
18use sha2::{Digest, Sha256};
19use thiserror::Error;
20
21// ─────────────────────────────────────────────────────────────────────────────
22// HTLC types — mirrored exactly from `webycash-asset-rgb::htlc` so the wallet
23// can construct request bodies the RGB server will accept without depending
24// on the server crate. Keep in lockstep — wire format is the contract.
25// ─────────────────────────────────────────────────────────────────────────────
26
27/// Wallet-side counterpart to `webycash-asset-rgb::htlc::LockRequest`.
28/// Carries a *delta* from server-now (never an absolute timestamp) — see
29/// `docs/referee-zkp-based-swap.md` §8.
30#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
31pub struct HtlcLockRequest {
32    /// Hex of `sha256(X)` for the agreed preimage.
33    pub committed_h_hex: String,
34    /// Seconds from server-now until refund unlocks. Server stamps
35    /// `refund_after_unix = server_now + this`.
36    pub refund_after_seconds_from_now: u64,
37    /// Recipient secret hex (the future claim-path owner).
38    pub claim_owner_secret_hex: String,
39    /// Sender refund secret hex (the refund-path owner).
40    pub refund_owner_secret_hex: String,
41}
42
43/// Wallet-side counterpart to `webycash-asset-rgb::htlc::HtlcWitness`.
44#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
45pub struct HtlcWitness {
46    /// Hex preimage when taking the claim path; `None` for refund.
47    #[serde(default, skip_serializing_if = "Option::is_none")]
48    pub provided_x_hex: Option<String>,
49    /// Hex of `sha256(output_secret_hex_ascii)`. The server cross-checks
50    /// this against the actual output's hash.
51    pub output_owner_hash_hex: String,
52}
53
54impl HtlcWitness {
55    /// Convenience: build the claim-path witness given the preimage and
56    /// the output secret. Computes the output owner hash.
57    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    /// Convenience: build the refund-path witness.
65    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/// One entry in the `htlc_locks` array — pair an output index with the
74/// lock parameters the server should stamp.
75#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
76pub struct HtlcLockEntry {
77    /// Index into `new_webcashes` of the output to lock.
78    pub output_index: usize,
79    /// The lock parameters the server stamps.
80    pub request: HtlcLockRequest,
81}
82
83/// One entry in the `htlc_witnesses` array — pair an input index with
84/// its claim-or-refund witness.
85#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
86pub struct HtlcWitnessEntry {
87    /// Index into `webcashes` of the locked input.
88    pub input_index: usize,
89    /// The claim-or-refund witness.
90    pub witness: HtlcWitness,
91}
92
93/// Failure modes when talking to a webycash-server flavor.
94///
95/// - `Http`: server returned a non-2xx status. Body is the raw
96///   response (typically the Tornado-style HTML 500 envelope or a
97///   JSON error from the asset-gated handler).
98/// - `Transport`: TCP connect / read / write failed before a status
99///   line came back.
100/// - `Encode`: serde_json couldn't serialise the request body.
101#[derive(Debug, Error)]
102pub enum ClientError {
103    /// Server returned a non-2xx status. Body is the raw response.
104    #[error("HTTP error: {status}: {body}")]
105    Http {
106        /// HTTP status code returned by the server.
107        status: u16,
108        /// Raw response body (may be JSON or Tornado-style HTML 500).
109        body: String,
110    },
111    /// TCP connect / read / write failed before a status came back.
112    #[error("transport error: {0}")]
113    Transport(String),
114    /// `serde_json` couldn't serialise the request body.
115    #[error("body encode error: {0}")]
116    Encode(String),
117}
118
119/// Convenience alias used across the wallet crates for results from
120/// any `Client` method.
121pub type ClientResult<T> = Result<T, ClientError>;
122
123/// Minimal asset-agnostic HTTP client. One instance per server URL;
124/// methods correspond 1:1 to the server's endpoint set.
125#[derive(Clone, Debug)]
126pub struct Client {
127    base_url: String,
128}
129
130impl Client {
131    /// Construct a client bound to a server base URL, e.g.
132    /// `http://localhost:8181` or `https://webcash.org`.
133    ///
134    /// ```
135    /// use webylib_server_client::Client;
136    /// // Trailing slash on the base is normalised away.
137    /// let c = Client::new("http://localhost:8181/");
138    /// assert_eq!(c.base_url(), "http://localhost:8181/");
139    /// ```
140    pub fn new(base_url: impl Into<String>) -> Self {
141        Self {
142            base_url: base_url.into(),
143        }
144    }
145
146    /// Return the base URL the client was constructed with (verbatim;
147    /// no normalisation).
148    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    /// `POST /api/v1/replace` — splittable: N inputs → M outputs with
157    /// amount conservation. Non-splittable: 1:1 (same body shape; the
158    /// server enforces arity per its compiled flavor).
159    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    /// `POST /api/v1/replace` — same as [`Client::replace`] but with the
170    /// HTLC extensions wired up. Use this when an output should be
171    /// HTLC-locked, or when an input is locked and a witness must be
172    /// supplied.
173    ///
174    /// `htlc_locks` and `htlc_witnesses` are typed structs (see
175    /// [`HtlcLockEntry`] / [`HtlcWitnessEntry`]) that serialize into the
176    /// JSON shape documented in
177    /// `webycash-server/docs/referee-zkp-based-swap.md` §7.1. Pass empty slices
178    /// when not used.
179    ///
180    /// The HTLC extensions are accepted by the RGB server only;
181    /// webcash and voucher servers ignore them (their `ReplaceHook`
182    /// impl is a no-op accept). Per docs §1, this is by design.
183    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    /// `POST /api/v1/burn` — mark a single secret spent permanently.
202    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    /// `POST /api/v1/health_check` — bare-array body of public tokens.
212    /// Returns the raw response body (caller parses).
213    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    /// `POST /api/v1/mining_report` — submit a PoW preimage.
220    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    /// `POST /api/v1/issue` — operator-signed mint (RGB / Voucher only).
230    /// Caller supplies the canonical request body bytes AND the detached
231    /// Ed25519 signature over those bytes.
232    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    /// `GET /api/v1/target` — current mining target.
237    pub fn target(&self) -> ClientResult<String> {
238        self.get_raw(&self.endpoint("/api/v1/target"))
239    }
240
241    /// `GET /api/v1/stats` — economy statistics (circulation, epoch,
242    /// mining report count, current difficulty, mining/subsidy amounts).
243    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
284// ─────────────────────────────────────────────────────────────────────────────
285// No-deps HTTP transport. Avoids pulling reqwest into every consumer; for
286// production deployments a higher-perf transport can wrap Client.
287// ─────────────────────────────────────────────────────────────────────────────
288
289fn 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        // Same path appended in both shapes.
351        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        // No `\r\n\r\n` separator → body offset lands at raw.len() → empty.
392        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"); // port 1 is reserved/closed
399        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}