Skip to main content

anyswap_sdk/
client.rs

1//! HTTP client for the AnySwap server API.
2//!
3//! The server is the responder; the user always initiates. POST methods trigger
4//! server-side transitions and return no body - the resulting state is always
5//! read back via [`HttpClient::get_swap`] and parsed into a typed
6//! [`SwapIn`](crate::swap_in::SwapIn) via `TryFrom`. Poll on a timer; WebSocket
7//! signals only shorten the wait, they never replace the snapshot.
8//!
9//! ## Swap-in (on-chain to Lightning)
10//!
11//! After [`HttpClient::create_swap`], poll with [`HttpClient::get_swap`]:
12//!
13//! 1. `AwaitOpeningTxConfirmation` - deposit to the swap script address; the server watches the
14//!    address for the opening tx and its confirmations.
15//! 3. `PayInvoice` - the server pays the user's `claim_invoice`.
16//! 4. `AwaitClaimKeyRevealMessage` - the invoice is paid; check that with
17//!    [`SwapIn::verify_payment`](crate::swap_in::SwapIn::verify_payment), then reveal the refund
18//!    key via [`HttpClient::reveal_claim`] so the server can claim cooperatively via the keypath
19//!    instead of the preimage script path.
20//! 5. `AwaitClaimTxConfirmation` then `Claimed` - swap finished.
21//!
22//! [`HttpClient::cancel`] before a successful claim results in `Canceled`,
23//! where the server reveals its claim privkey so a deposited user refunds
24//! immediately via [`SwapIn::refund_by_keypath`](crate::swap_in::SwapIn::refund_by_keypath).
25//!
26//! ## Swap-out (Lightning to on-chain)
27//!
28//! The user holds the preimage. After [`HttpClient::create_swap`], poll with
29//! [`HttpClient::get_swap`] and parse into a typed
30//! [`SwapOut`](crate::swap_out::SwapOut):
31//!
32//! 1. `AwaitClaimInvoicePay` - pay the held `claim_invoice` over Lightning.
33//! 2. `OpeningTxBroadcast` - the server funds the destination opening tx.
34//! 3. `AwaitPreimageRevealed` - once the opening tx has enough confirmations, claim either
35//!    cooperatively (reveal the preimage off-chain via [`HttpClient::reveal_preimage`], then
36//!    keypath spend) or unilaterally on-chain via
37//!    [`SwapOut::claim_by_preimage`](crate::swap_out::SwapOut::claim_by_preimage).
38//! 4. `Claimed` - the held invoice is settled. After an off-chain reveal the server exposes its
39//!    refund key; finish with
40//!    [`SwapOut::claim_by_keypath`](crate::swap_out::SwapOut::claim_by_keypath).
41//!
42//! If the user never claims, the server refunds the destination output after
43//! the CSV timeout (`AwaitRefundTxConfirmation` then `Refunded`). There is no
44//! way to abort a swap-out once its opening tx is broadcast.
45
46use anyswap_core::{
47    api::{
48        CancelRequest, CreateSwapRequest, ErrorCode, InfoResponse, RevealClaimRequest,
49        RevealPreimageRequest, SwapListQuery, SwapListResponse, SwapState,
50    },
51    auth::{AuthCore, AuthRequest, IDENTITY_PUBKEY_HEADER, SIGNATURE_HEADER, TIMESTAMP_HEADER},
52    utils::now,
53};
54use bitcoin::{
55    hashes::{Hash, sha256},
56    secp256k1::Keypair,
57};
58use reqwest::{
59    Method,
60    header::{CONTENT_TYPE, HeaderMap, HeaderValue},
61};
62use serde::Deserialize;
63use thiserror::Error;
64use uuid::Uuid;
65
66use crate::wallet::{Wallet, WalletError};
67
68#[derive(Clone, Debug, Error)]
69pub enum ClientError {
70    /// The server could not be reached, or the exchange broke before an
71    /// answer.
72    #[error("transport: {0}")]
73    Transport(String),
74
75    #[error("server ({status}): {message}")]
76    Api {
77        status: u16,
78        message: String,
79        /// Machine-readable error code, absent on responses without a known one.
80        code: Option<ErrorCode>,
81    },
82
83    #[error("invalid auth header value: {0}")]
84    Header(String),
85
86    #[error("json: {0}")]
87    Json(String),
88
89    #[error("missing identity private key")]
90    MissingPrivateKey,
91}
92
93impl From<reqwest::Error> for ClientError {
94    fn from(e: reqwest::Error) -> Self {
95        Self::Transport(e.to_string())
96    }
97}
98
99impl From<reqwest::header::InvalidHeaderValue> for ClientError {
100    fn from(e: reqwest::header::InvalidHeaderValue) -> Self {
101        Self::Header(e.to_string())
102    }
103}
104
105impl From<serde_json::Error> for ClientError {
106    fn from(e: serde_json::Error) -> Self {
107        Self::Json(e.to_string())
108    }
109}
110
111impl ClientError {
112    /// The server's FSM refused a message it is past; the outcome is the same
113    /// as having delivered it.
114    pub fn transition_rejected(&self) -> bool {
115        matches!(
116            self,
117            Self::Api {
118                code: Some(ErrorCode::TransitionRejected),
119                ..
120            }
121        )
122    }
123}
124
125/// HTTP client for the AnySwap server API. See the [module docs](self) for swap flows.
126#[derive(Clone)]
127pub struct HttpClient {
128    http: reqwest::Client,
129    base_url: String,
130    identity: Option<Keypair>,
131}
132
133impl HttpClient {
134    /// A client for the server at `base_url`. It signs protected requests with
135    /// `wallet`'s identity key; without a wallet, only the public endpoints
136    /// work.
137    pub fn new(base_url: &str, wallet: Option<&Wallet>) -> Result<Self, WalletError> {
138        let identity = wallet
139            .map(Wallet::identity_privkey)
140            .transpose()?
141            .map(|secret| Keypair::from_secret_key(anyswap_core::secp(), &secret));
142        Ok(Self {
143            http: reqwest::Client::new(),
144            base_url: base_url.trim_end_matches('/').to_string(),
145            identity,
146        })
147    }
148
149    fn identity(&self) -> Result<&Keypair, ClientError> {
150        self.identity.as_ref().ok_or(ClientError::MissingPrivateKey)
151    }
152
153    /// The signed `/v1/subscribe/{user_id}` WebSocket URL, auth in the query.
154    pub fn subscribe_url(&self) -> Result<String, ClientError> {
155        let keypair = self.identity()?;
156        let pubkey = keypair.public_key();
157        let user_id = sha256::Hash::hash(&pubkey.serialize()).to_string();
158        let target = format!("/v1/subscribe/{user_id}");
159        let timestamp = now() as i64;
160        let signature = AuthCore::sign_request(
161            &AuthRequest {
162                method: Method::GET.as_str(),
163                target: &target,
164                body: &[],
165                swap_id: "",
166                timestamp,
167            },
168            keypair,
169        );
170        let base = self.base_url.replacen("http", "ws", 1);
171        Ok(format!(
172            "{base}{target}?{IDENTITY_PUBKEY_HEADER}={}&{TIMESTAMP_HEADER}={timestamp}&{SIGNATURE_HEADER}={}",
173            hex::encode(pubkey.serialize()),
174            hex::encode(signature.as_ref()),
175        ))
176    }
177
178    /// Fetches server info: protocol version, network, and per-chain policies
179    /// (amount limits, confirmation requirements, swap_fee rates).
180    pub async fn get_info(&self) -> Result<InfoResponse, ClientError> {
181        let resp = self
182            .http
183            .get(format!("{}/v1/info", self.base_url))
184            .send()
185            .await?;
186        Ok(check(resp).await?.json().await?)
187    }
188
189    /// Initiates a swap. The server may cancel the swap right after creation;
190    /// the actual state is discovered via [`HttpClient::get_swap`].
191    pub async fn create_swap(&self, req: &CreateSwapRequest) -> Result<(), ClientError> {
192        let path = "/v1/swap";
193        let body = serde_json::to_vec(req)?;
194        let headers = self.prepare_headers(
195            &req.swap_id.to_string(),
196            &Method::POST,
197            path,
198            &body,
199            self.identity()?,
200        )?;
201
202        let resp = self
203            .http
204            .post(format!("{}{}", self.base_url, path))
205            .headers(headers)
206            .header(CONTENT_TYPE, "application/json")
207            .body(body)
208            .send()
209            .await?;
210        check(resp).await?;
211        Ok(())
212    }
213
214    /// Polls the current state of a swap. Convert the response to a typed
215    /// [`SwapIn`](crate::swap_in::SwapIn) via [`TryFrom`].
216    pub async fn get_swap(&self, swap_id: Uuid) -> Result<SwapState, ClientError> {
217        let path = format!("/v1/swap/{swap_id}");
218        let headers = self.prepare_headers(
219            &swap_id.to_string(),
220            &Method::GET,
221            &path,
222            &[],
223            self.identity()?,
224        )?;
225
226        let resp = self
227            .http
228            .get(format!("{}{}", self.base_url, path))
229            .headers(headers)
230            .send()
231            .await?;
232        Ok(check(resp).await?.json().await?)
233    }
234
235    /// Fetches the paginated swap history for a user.
236    pub async fn list_swaps(&self, query: &SwapListQuery) -> Result<SwapListResponse, ClientError> {
237        let path = "/v1/swap";
238        let identity = self.identity()?;
239
240        let mut request = self
241            .http
242            .get(format!("{}{}", self.base_url, path))
243            .query(query)
244            .build()?;
245        let target = match request.url().query() {
246            Some(query) => format!("{path}?{query}"),
247            None => path.to_owned(),
248        };
249        let headers = self.prepare_headers("", &Method::GET, &target, &[], identity)?;
250        request.headers_mut().extend(headers);
251
252        let resp = self.http.execute(request).await?;
253        Ok(check(resp).await?.json().await?)
254    }
255
256    /// Requests swap cancellation. The outcome is observed via
257    /// [`HttpClient::get_swap`]: `Canceled` reveals the server's claim privkey
258    /// for an immediate keypath refund of any deposit.
259    pub async fn cancel(&self, swap_id: Uuid, req: &CancelRequest) -> Result<(), ClientError> {
260        self.post_protected(swap_id, "/cancel", req).await
261    }
262
263    /// Reveals the user's refund privkey after the claim invoice was paid
264    /// (swap-in only), letting the server claim cooperatively via the keypath
265    /// instead of the preimage script path.
266    pub async fn reveal_claim(
267        &self,
268        swap_id: Uuid,
269        req: &RevealClaimRequest,
270    ) -> Result<(), ClientError> {
271        self.post_protected(swap_id, "/reveal/claim", req).await
272    }
273
274    /// Reveals the preimage off-chain (swap-out only) instead of claiming via
275    /// the script path. The server settles the held invoice and exposes its
276    /// refund privkey, letting the user finish with a cheap cooperative keypath
277    /// claim. Reveal only after the opening tx has the required confirmations.
278    pub async fn reveal_preimage(
279        &self,
280        swap_id: Uuid,
281        req: &RevealPreimageRequest,
282    ) -> Result<(), ClientError> {
283        self.post_protected(swap_id, "/reveal/preimage", req).await
284    }
285
286    async fn post_protected(
287        &self,
288        swap_id: Uuid,
289        path_suffix: &str,
290        req: &impl serde::Serialize,
291    ) -> Result<(), ClientError> {
292        let body = serde_json::to_vec(req)?;
293        let path = format!("/v1/swap/{swap_id}{path_suffix}");
294        let headers = self.prepare_headers(
295            &swap_id.to_string(),
296            &Method::POST,
297            &path,
298            &body,
299            self.identity()?,
300        )?;
301
302        let resp = self
303            .http
304            .post(format!("{}{}", self.base_url, path))
305            .headers(headers)
306            .header(CONTENT_TYPE, "application/json")
307            .body(body)
308            .send()
309            .await?;
310        check(resp).await?;
311        Ok(())
312    }
313
314    // Builds the exact auth payload the server verifies.
315    fn prepare_headers(
316        &self,
317        swap_id: &str,
318        method: &Method,
319        target: &str,
320        raw_body: &[u8],
321        identity_keypair: &Keypair,
322    ) -> Result<HeaderMap, ClientError> {
323        let timestamp = now() as i64;
324        let pubkey = identity_keypair.public_key();
325        let signature = AuthCore::sign_request(
326            &AuthRequest {
327                method: method.as_str(),
328                target,
329                body: raw_body,
330                swap_id,
331                timestamp,
332            },
333            identity_keypair,
334        );
335
336        let mut headers = HeaderMap::new();
337        headers.insert(
338            IDENTITY_PUBKEY_HEADER,
339            HeaderValue::from_str(&hex::encode(pubkey.serialize()))?,
340        );
341        headers.insert(
342            TIMESTAMP_HEADER,
343            HeaderValue::from_str(&timestamp.to_string())?,
344        );
345        headers.insert(
346            SIGNATURE_HEADER,
347            HeaderValue::from_str(&hex::encode(signature.as_ref()))?,
348        );
349
350        Ok(headers)
351    }
352}
353
354async fn check(resp: reqwest::Response) -> Result<reqwest::Response, ClientError> {
355    let status = resp.status();
356    if status.is_success() {
357        return Ok(resp);
358    }
359
360    #[derive(Deserialize)]
361    struct ErrorBody {
362        message: String,
363        #[serde(default)]
364        code: Option<String>,
365    }
366
367    let (message, code) = match resp.json::<ErrorBody>().await {
368        Ok(body) => (body.message, body.code.and_then(|c| c.parse().ok())),
369        Err(_) => (
370            status
371                .canonical_reason()
372                .unwrap_or("unknown error")
373                .to_string(),
374            None,
375        ),
376    };
377    Err(ClientError::Api {
378        status: status.as_u16(),
379        message,
380        code,
381    })
382}
383
384#[cfg(test)]
385mod tests {
386    use std::{
387        collections::HashMap,
388        io::{Read, Write},
389        net::{TcpListener, TcpStream},
390        time::Duration,
391    };
392
393    use anyswap_core::types::{Chain, SwapNetwork};
394    use bitcoin::{
395        hashes::{Hash, sha256},
396        secp256k1::{SecretKey, schnorr::Signature},
397    };
398
399    use super::*;
400
401    struct RecordedRequest {
402        method: String,
403        path: String,
404        headers: HashMap<String, String>,
405        body: Vec<u8>,
406    }
407
408    fn test_keypair() -> Keypair {
409        let secret = SecretKey::from_slice(&[1; 32]).unwrap();
410        Keypair::from_secret_key(anyswap_core::secp(), &secret)
411    }
412
413    fn read_request(stream: &mut TcpStream) -> RecordedRequest {
414        stream
415            .set_read_timeout(Some(Duration::from_secs(5)))
416            .unwrap();
417
418        let mut data = Vec::new();
419        let mut buf = [0; 1024];
420        let header_end = loop {
421            let n = stream.read(&mut buf).unwrap();
422            assert!(n > 0, "client closed connection before sending headers");
423            data.extend_from_slice(&buf[..n]);
424
425            if let Some(pos) = data.windows(4).position(|window| window == b"\r\n\r\n") {
426                break pos;
427            }
428        };
429
430        let headers = String::from_utf8(data[..header_end].to_vec()).unwrap();
431        let content_length = headers
432            .lines()
433            .find_map(|line| {
434                let (name, value) = line.split_once(':')?;
435                name.eq_ignore_ascii_case("content-length")
436                    .then(|| value.trim().parse::<usize>().unwrap())
437            })
438            .unwrap_or(0);
439        let body_start = header_end + 4;
440        let total_len = body_start + content_length;
441
442        while data.len() < total_len {
443            let n = stream.read(&mut buf).unwrap();
444            assert!(n > 0, "client closed connection before sending body");
445            data.extend_from_slice(&buf[..n]);
446        }
447
448        let mut lines = headers.lines();
449        let request_line = lines.next().unwrap();
450        let mut request_parts = request_line.split_whitespace();
451        let method = request_parts.next().unwrap().to_string();
452        let path = request_parts.next().unwrap().to_string();
453        let headers = lines
454            .filter_map(|line| {
455                let (name, value) = line.split_once(':')?;
456                Some((name.to_ascii_lowercase(), value.trim().to_string()))
457            })
458            .collect();
459
460        RecordedRequest {
461            method,
462            path,
463            headers,
464            body: data[body_start..total_len].to_vec(),
465        }
466    }
467
468    fn auth_header<'a>(headers: &'a HashMap<String, String>, name: &str) -> &'a str {
469        headers.get(name).map(String::as_str).unwrap()
470    }
471
472    fn signature_and_timestamp(headers: &HeaderMap) -> (Signature, i64) {
473        let signature_hex = headers.get(SIGNATURE_HEADER).unwrap().to_str().unwrap();
474        let timestamp = headers.get(TIMESTAMP_HEADER).unwrap().to_str().unwrap();
475
476        (
477            Signature::from_slice(&hex::decode(signature_hex).unwrap()).unwrap(),
478            timestamp.parse().unwrap(),
479        )
480    }
481
482    #[test]
483    fn prepare_headers_signs_the_target_including_its_query() {
484        let client = HttpClient::new("http://localhost:8083", None).unwrap();
485        let keypair = test_keypair();
486        let pubkey = keypair.public_key();
487        let target = "/v1/swap?user_id=alice&limit=20";
488
489        let headers = client
490            .prepare_headers("", &Method::GET, target, &[], &keypair)
491            .unwrap();
492        let (signature, timestamp) = signature_and_timestamp(&headers);
493        let signed = AuthRequest {
494            method: Method::GET.as_str(),
495            target,
496            body: &[],
497            swap_id: "",
498            timestamp,
499        };
500
501        assert!(AuthCore::validate_signature(
502            &AuthCore::request_digest(&signed, &pubkey),
503            &signature,
504            &pubkey
505        ));
506
507        let widened = AuthRequest {
508            target: "/v1/swap?user_id=alice&limit=500",
509            ..signed
510        };
511        assert!(!AuthCore::validate_signature(
512            &AuthCore::request_digest(&widened, &pubkey),
513            &signature,
514            &pubkey
515        ));
516    }
517
518    #[test]
519    fn prepare_headers_sets_verifiable_signature() {
520        let client = HttpClient::new("http://localhost:8083", None).unwrap();
521        let keypair = test_keypair();
522        let pubkey = keypair.public_key();
523
524        let headers = client
525            .prepare_headers("swap-1", &Method::GET, "/v1/swap/swap-1", &[], &keypair)
526            .unwrap();
527
528        assert_eq!(
529            headers.get(IDENTITY_PUBKEY_HEADER).unwrap(),
530            hex::encode(pubkey.serialize()).as_str()
531        );
532        let (signature, timestamp) = signature_and_timestamp(&headers);
533        assert!(timestamp > 0);
534
535        let signed = AuthRequest {
536            method: Method::GET.as_str(),
537            target: "/v1/swap/swap-1",
538            body: &[],
539            swap_id: "swap-1",
540            timestamp,
541        };
542
543        assert!(AuthCore::validate_signature(
544            &AuthCore::request_digest(&signed, &pubkey),
545            &signature,
546            &pubkey
547        ));
548    }
549
550    #[tokio::test]
551    async fn create_swap_signature_verifies_against_body_swap_id() {
552        let swap_id = Uuid::from_u128(0x1234567890abcdef1234567890abcdef);
553        let (wallet, _) = Wallet::generate(SwapNetwork::Regtest).unwrap();
554        let pubkey =
555            Keypair::from_secret_key(anyswap_core::secp(), &wallet.identity_privkey().unwrap())
556                .public_key();
557        let req = CreateSwapRequest {
558            protocol_version: "3.0.0".into(),
559            swap_id,
560            user_id: sha256::Hash::hash(&pubkey.serialize()).to_string(),
561            source_chain: Some(Chain::Bitcoin),
562            source_asset_id: Some(anyswap_core::types::NATIVE_ASSET.to_string()),
563            dest_chain: None,
564            dest_asset_id: None,
565            receive_amount: 10_000,
566            send_amount: 11_000,
567            quote_id: None,
568            claim_invoice: Some("lnbc1test".to_string()),
569            source_refund_pubkey: Some(hex::encode(pubkey.serialize())),
570            source_blinding_pubkey: None,
571            dest_claim_pubkey: None,
572            dest_blinding_pubkey: None,
573            user_claim_address: None,
574            user_refund_address: None,
575            payment_hash: None,
576            webhook: None,
577        };
578        let expected_body = serde_json::to_vec(&req).unwrap();
579        let expected_user_id = req.user_id.clone();
580
581        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
582        let base_url = format!("http://{}", listener.local_addr().unwrap());
583        let server = std::thread::spawn(move || {
584            let (mut stream, _) = listener.accept().unwrap();
585            let request = read_request(&mut stream);
586
587            assert_eq!(request.method, Method::POST.as_str());
588            assert_eq!(request.path, "/v1/swap");
589            assert_eq!(request.body, expected_body);
590
591            let auth_params = anyswap_core::auth::AuthParams::from_fields(
592                auth_header(&request.headers, IDENTITY_PUBKEY_HEADER),
593                auth_header(&request.headers, TIMESTAMP_HEADER),
594                auth_header(&request.headers, SIGNATURE_HEADER),
595            )
596            .unwrap();
597            assert!(AuthCore::validate_pubkey(
598                &auth_params.pubkey,
599                &expected_user_id
600            ));
601
602            let signed = AuthRequest {
603                method: Method::POST.as_str(),
604                target: &request.path,
605                body: &request.body,
606                swap_id: &swap_id.to_string(),
607                timestamp: auth_params.timestamp,
608            };
609            assert!(AuthCore::validate_signature(
610                &AuthCore::request_digest(&signed, &auth_params.pubkey),
611                &auth_params.signature,
612                &auth_params.pubkey
613            ));
614
615            let wrong_swap_id = AuthRequest {
616                swap_id: "",
617                ..signed
618            };
619            assert!(!AuthCore::validate_signature(
620                &AuthCore::request_digest(&wrong_swap_id, &auth_params.pubkey),
621                &auth_params.signature,
622                &auth_params.pubkey
623            ));
624
625            stream
626                .write_all(b"HTTP/1.1 200 OK\r\ncontent-length: 0\r\n\r\n")
627                .unwrap();
628        });
629
630        let client = HttpClient::new(&base_url, Some(&wallet)).unwrap();
631        let result = client.create_swap(&req).await;
632        let server_result = server.join();
633
634        assert!(result.is_ok(), "{result:?}");
635        server_result.unwrap();
636    }
637}