1use 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 futures_util::StreamExt;
59use reqwest::{
60 Method,
61 header::{CONTENT_TYPE, HeaderMap, HeaderValue},
62};
63use serde::{Deserialize, de::DeserializeOwned};
64use thiserror::Error;
65use uuid::Uuid;
66
67use crate::wallet::{Wallet, WalletError};
68
69const MAX_RESPONSE_BYTES: usize = 4 << 20;
72
73#[derive(Clone, Debug, Error)]
74pub enum ClientError {
75 #[error("transport: {0}")]
78 Transport(String),
79
80 #[error("server ({status}): {message}")]
81 Api {
82 status: u16,
83 message: String,
84 code: Option<ErrorCode>,
86 },
87
88 #[error("invalid auth header value: {0}")]
89 Header(String),
90
91 #[error("json: {0}")]
92 Json(String),
93
94 #[error("missing identity private key")]
95 MissingPrivateKey,
96}
97
98impl From<reqwest::Error> for ClientError {
99 fn from(e: reqwest::Error) -> Self {
100 Self::Transport(e.to_string())
101 }
102}
103
104impl From<reqwest::header::InvalidHeaderValue> for ClientError {
105 fn from(e: reqwest::header::InvalidHeaderValue) -> Self {
106 Self::Header(e.to_string())
107 }
108}
109
110impl From<serde_json::Error> for ClientError {
111 fn from(e: serde_json::Error) -> Self {
112 Self::Json(e.to_string())
113 }
114}
115
116impl ClientError {
117 pub fn transition_rejected(&self) -> bool {
120 matches!(
121 self,
122 Self::Api {
123 code: Some(ErrorCode::TransitionRejected),
124 ..
125 }
126 )
127 }
128}
129
130#[derive(Clone)]
132pub struct HttpClient {
133 http: reqwest::Client,
134 base_url: String,
135 identity: Option<Keypair>,
136}
137
138impl HttpClient {
139 pub fn new(base_url: &str, wallet: Option<&Wallet>) -> Result<Self, WalletError> {
143 let identity = wallet
144 .map(Wallet::identity_privkey)
145 .transpose()?
146 .map(|secret| Keypair::from_secret_key(anyswap_core::secp(), &secret));
147 Ok(Self {
148 http: reqwest::Client::new(),
149 base_url: base_url.trim_end_matches('/').to_string(),
150 identity,
151 })
152 }
153
154 fn identity(&self) -> Result<&Keypair, ClientError> {
155 self.identity.as_ref().ok_or(ClientError::MissingPrivateKey)
156 }
157
158 pub fn subscribe_url(&self) -> Result<String, ClientError> {
160 let keypair = self.identity()?;
161 let pubkey = keypair.public_key();
162 let user_id = sha256::Hash::hash(&pubkey.serialize()).to_string();
163 let target = format!("/v1/subscribe/{user_id}");
164 #[allow(clippy::disallowed_methods)]
166 let timestamp = now() as i64;
167 let signature = AuthCore::sign_request(
168 &AuthRequest {
169 method: Method::GET.as_str(),
170 target: &target,
171 body: &[],
172 swap_id: "",
173 timestamp,
174 },
175 keypair,
176 );
177 let base = self.base_url.replacen("http", "ws", 1);
178 Ok(format!(
179 "{base}{target}?{IDENTITY_PUBKEY_HEADER}={}&{TIMESTAMP_HEADER}={timestamp}&{SIGNATURE_HEADER}={}",
180 hex::encode(pubkey.serialize()),
181 hex::encode(signature.as_ref()),
182 ))
183 }
184
185 pub async fn get_info(&self) -> Result<InfoResponse, ClientError> {
188 let resp = self
189 .http
190 .get(format!("{}/v1/info", self.base_url))
191 .send()
192 .await?;
193 json(check(resp).await?).await
194 }
195
196 pub async fn create_swap(&self, req: &CreateSwapRequest) -> Result<(), ClientError> {
199 let path = "/v1/swap";
200 let body = serde_json::to_vec(req)?;
201 let headers = self.prepare_headers(
202 &req.swap_id.to_string(),
203 &Method::POST,
204 path,
205 &body,
206 self.identity()?,
207 )?;
208
209 let resp = self
210 .http
211 .post(format!("{}{}", self.base_url, path))
212 .headers(headers)
213 .header(CONTENT_TYPE, "application/json")
214 .body(body)
215 .send()
216 .await?;
217 check(resp).await?;
218 Ok(())
219 }
220
221 pub async fn get_swap(&self, swap_id: Uuid) -> Result<SwapState, ClientError> {
224 let path = format!("/v1/swap/{swap_id}");
225 let headers = self.prepare_headers(
226 &swap_id.to_string(),
227 &Method::GET,
228 &path,
229 &[],
230 self.identity()?,
231 )?;
232
233 let resp = self
234 .http
235 .get(format!("{}{}", self.base_url, path))
236 .headers(headers)
237 .send()
238 .await?;
239 json(check(resp).await?).await
240 }
241
242 pub async fn list_swaps(&self, query: &SwapListQuery) -> Result<SwapListResponse, ClientError> {
244 let path = "/v1/swap";
245 let identity = self.identity()?;
246
247 let mut request = self
248 .http
249 .get(format!("{}{}", self.base_url, path))
250 .query(query)
251 .build()?;
252 let target = match request.url().query() {
253 Some(query) => format!("{path}?{query}"),
254 None => path.to_owned(),
255 };
256 let headers = self.prepare_headers("", &Method::GET, &target, &[], identity)?;
257 request.headers_mut().extend(headers);
258
259 let resp = self.http.execute(request).await?;
260 json(check(resp).await?).await
261 }
262
263 pub async fn cancel(&self, swap_id: Uuid, req: &CancelRequest) -> Result<(), ClientError> {
267 self.post_protected(swap_id, "/cancel", req).await
268 }
269
270 pub async fn reveal_claim(
274 &self,
275 swap_id: Uuid,
276 req: &RevealClaimRequest,
277 ) -> Result<(), ClientError> {
278 self.post_protected(swap_id, "/reveal/claim", req).await
279 }
280
281 pub async fn reveal_preimage(
286 &self,
287 swap_id: Uuid,
288 req: &RevealPreimageRequest,
289 ) -> Result<(), ClientError> {
290 self.post_protected(swap_id, "/reveal/preimage", req).await
291 }
292
293 async fn post_protected(
294 &self,
295 swap_id: Uuid,
296 path_suffix: &str,
297 req: &impl serde::Serialize,
298 ) -> Result<(), ClientError> {
299 let body = serde_json::to_vec(req)?;
300 let path = format!("/v1/swap/{swap_id}{path_suffix}");
301 let headers = self.prepare_headers(
302 &swap_id.to_string(),
303 &Method::POST,
304 &path,
305 &body,
306 self.identity()?,
307 )?;
308
309 let resp = self
310 .http
311 .post(format!("{}{}", self.base_url, path))
312 .headers(headers)
313 .header(CONTENT_TYPE, "application/json")
314 .body(body)
315 .send()
316 .await?;
317 check(resp).await?;
318 Ok(())
319 }
320
321 fn prepare_headers(
323 &self,
324 swap_id: &str,
325 method: &Method,
326 target: &str,
327 raw_body: &[u8],
328 identity_keypair: &Keypair,
329 ) -> Result<HeaderMap, ClientError> {
330 #[allow(clippy::disallowed_methods)]
332 let timestamp = now() as i64;
333 let pubkey = identity_keypair.public_key();
334 let signature = AuthCore::sign_request(
335 &AuthRequest {
336 method: method.as_str(),
337 target,
338 body: raw_body,
339 swap_id,
340 timestamp,
341 },
342 identity_keypair,
343 );
344
345 let mut headers = HeaderMap::new();
346 headers.insert(
347 IDENTITY_PUBKEY_HEADER,
348 HeaderValue::from_str(&hex::encode(pubkey.serialize()))?,
349 );
350 headers.insert(
351 TIMESTAMP_HEADER,
352 HeaderValue::from_str(×tamp.to_string())?,
353 );
354 headers.insert(
355 SIGNATURE_HEADER,
356 HeaderValue::from_str(&hex::encode(signature.as_ref()))?,
357 );
358
359 Ok(headers)
360 }
361}
362
363async fn check(resp: reqwest::Response) -> Result<reqwest::Response, ClientError> {
364 let status = resp.status();
365 if status.is_success() {
366 return Ok(resp);
367 }
368
369 #[derive(Deserialize)]
370 struct ErrorBody {
371 message: String,
372 #[serde(default)]
373 code: Option<String>,
374 }
375
376 let (message, code) = match json::<ErrorBody>(resp).await {
377 Ok(body) => (body.message, body.code.and_then(|c| c.parse().ok())),
378 Err(_) => (
379 status
380 .canonical_reason()
381 .unwrap_or("unknown error")
382 .to_string(),
383 None,
384 ),
385 };
386 Err(ClientError::Api {
387 status: status.as_u16(),
388 message,
389 code,
390 })
391}
392
393async fn json<T: DeserializeOwned>(resp: reqwest::Response) -> Result<T, ClientError> {
397 let mut body = Vec::new();
398 let mut chunks = resp.bytes_stream();
399 while let Some(chunk) = chunks.next().await {
400 let chunk = chunk?;
401 if body.len() + chunk.len() > MAX_RESPONSE_BYTES {
402 return Err(ClientError::Transport(format!(
403 "response over {MAX_RESPONSE_BYTES} bytes"
404 )));
405 }
406 body.extend_from_slice(&chunk);
407 }
408 serde_json::from_slice(&body)
409 .map_err(|e| ClientError::Transport(format!("error decoding response body: {e}")))
410}
411
412#[cfg(test)]
413mod tests {
414 use std::{
415 collections::HashMap,
416 io::{Read, Write},
417 net::{TcpListener, TcpStream},
418 time::Duration,
419 };
420
421 use anyswap_core::{
422 api::Pagination,
423 types::{Chain, SwapNetwork},
424 };
425 use bitcoin::{
426 hashes::{Hash, sha256},
427 secp256k1::{SecretKey, schnorr::Signature},
428 };
429
430 use super::*;
431
432 struct RecordedRequest {
433 method: String,
434 path: String,
435 headers: HashMap<String, String>,
436 body: Vec<u8>,
437 }
438
439 fn test_keypair() -> Keypair {
440 let secret = SecretKey::from_slice(&[1; 32]).unwrap();
441 Keypair::from_secret_key(anyswap_core::secp(), &secret)
442 }
443
444 fn read_request(stream: &mut TcpStream) -> RecordedRequest {
445 stream
446 .set_read_timeout(Some(Duration::from_secs(5)))
447 .unwrap();
448
449 let mut data = Vec::new();
450 let mut buf = [0; 1024];
451 let header_end = loop {
452 let n = stream.read(&mut buf).unwrap();
453 assert!(n > 0, "client closed connection before sending headers");
454 data.extend_from_slice(&buf[..n]);
455
456 if let Some(pos) = data.windows(4).position(|window| window == b"\r\n\r\n") {
457 break pos;
458 }
459 };
460
461 let headers = String::from_utf8(data[..header_end].to_vec()).unwrap();
462 let content_length = headers
463 .lines()
464 .find_map(|line| {
465 let (name, value) = line.split_once(':')?;
466 name.eq_ignore_ascii_case("content-length")
467 .then(|| value.trim().parse::<usize>().unwrap())
468 })
469 .unwrap_or(0);
470 let body_start = header_end + 4;
471 let total_len = body_start + content_length;
472
473 while data.len() < total_len {
474 let n = stream.read(&mut buf).unwrap();
475 assert!(n > 0, "client closed connection before sending body");
476 data.extend_from_slice(&buf[..n]);
477 }
478
479 let mut lines = headers.lines();
480 let request_line = lines.next().unwrap();
481 let mut request_parts = request_line.split_whitespace();
482 let method = request_parts.next().unwrap().to_string();
483 let path = request_parts.next().unwrap().to_string();
484 let headers = lines
485 .filter_map(|line| {
486 let (name, value) = line.split_once(':')?;
487 Some((name.to_ascii_lowercase(), value.trim().to_string()))
488 })
489 .collect();
490
491 RecordedRequest {
492 method,
493 path,
494 headers,
495 body: data[body_start..total_len].to_vec(),
496 }
497 }
498
499 fn auth_header<'a>(headers: &'a HashMap<String, String>, name: &str) -> &'a str {
500 headers.get(name).map(String::as_str).unwrap()
501 }
502
503 fn signature_and_timestamp(headers: &HeaderMap) -> (Signature, i64) {
504 let signature_hex = headers.get(SIGNATURE_HEADER).unwrap().to_str().unwrap();
505 let timestamp = headers.get(TIMESTAMP_HEADER).unwrap().to_str().unwrap();
506
507 (
508 Signature::from_slice(&hex::decode(signature_hex).unwrap()).unwrap(),
509 timestamp.parse().unwrap(),
510 )
511 }
512
513 #[test]
514 fn prepare_headers_signs_the_target_including_its_query() {
515 let client = HttpClient::new("http://localhost:8083", None).unwrap();
516 let keypair = test_keypair();
517 let pubkey = keypair.public_key();
518 let target = "/v1/swap?user_id=alice&limit=20";
519
520 let headers = client
521 .prepare_headers("", &Method::GET, target, &[], &keypair)
522 .unwrap();
523 let (signature, timestamp) = signature_and_timestamp(&headers);
524 let signed = AuthRequest {
525 method: Method::GET.as_str(),
526 target,
527 body: &[],
528 swap_id: "",
529 timestamp,
530 };
531
532 assert!(AuthCore::validate_signature(
533 &AuthCore::request_digest(&signed, &pubkey),
534 &signature,
535 &pubkey
536 ));
537
538 let widened = AuthRequest {
539 target: "/v1/swap?user_id=alice&limit=500",
540 ..signed
541 };
542 assert!(!AuthCore::validate_signature(
543 &AuthCore::request_digest(&widened, &pubkey),
544 &signature,
545 &pubkey
546 ));
547 }
548
549 #[test]
550 fn prepare_headers_sets_verifiable_signature() {
551 let client = HttpClient::new("http://localhost:8083", None).unwrap();
552 let keypair = test_keypair();
553 let pubkey = keypair.public_key();
554
555 let headers = client
556 .prepare_headers("swap-1", &Method::GET, "/v1/swap/swap-1", &[], &keypair)
557 .unwrap();
558
559 assert_eq!(
560 headers.get(IDENTITY_PUBKEY_HEADER).unwrap(),
561 hex::encode(pubkey.serialize()).as_str()
562 );
563 let (signature, timestamp) = signature_and_timestamp(&headers);
564 assert!(timestamp > 0);
565
566 let signed = AuthRequest {
567 method: Method::GET.as_str(),
568 target: "/v1/swap/swap-1",
569 body: &[],
570 swap_id: "swap-1",
571 timestamp,
572 };
573
574 assert!(AuthCore::validate_signature(
575 &AuthCore::request_digest(&signed, &pubkey),
576 &signature,
577 &pubkey
578 ));
579 }
580
581 #[tokio::test]
582 async fn create_swap_signature_verifies_against_body_swap_id() {
583 let swap_id = Uuid::from_u128(0x1234567890abcdef1234567890abcdef);
584 let (wallet, _) = Wallet::generate(SwapNetwork::Regtest).unwrap();
585 let pubkey =
586 Keypair::from_secret_key(anyswap_core::secp(), &wallet.identity_privkey().unwrap())
587 .public_key();
588 let req = CreateSwapRequest {
589 protocol_version: "3.0.0".into(),
590 swap_id,
591 user_id: sha256::Hash::hash(&pubkey.serialize()).to_string(),
592 source_chain: Some(Chain::Bitcoin),
593 source_asset_id: Some(anyswap_core::types::NATIVE_ASSET.to_string()),
594 dest_chain: None,
595 dest_asset_id: None,
596 receive_amount: 10_000,
597 send_amount: 11_000,
598 quote_id: None,
599 claim_invoice: Some("lnbc1test".to_string()),
600 source_refund_pubkey: Some(hex::encode(pubkey.serialize())),
601 source_blinding_pubkey: None,
602 dest_claim_pubkey: None,
603 dest_blinding_pubkey: None,
604 user_claim_address: None,
605 user_refund_address: None,
606 payment_hash: None,
607 webhook: None,
608 };
609 let expected_body = serde_json::to_vec(&req).unwrap();
610 let expected_user_id = req.user_id.clone();
611
612 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
613 let base_url = format!("http://{}", listener.local_addr().unwrap());
614 let server = std::thread::spawn(move || {
615 let (mut stream, _) = listener.accept().unwrap();
616 let request = read_request(&mut stream);
617
618 assert_eq!(request.method, Method::POST.as_str());
619 assert_eq!(request.path, "/v1/swap");
620 assert_eq!(request.body, expected_body);
621
622 let auth_params = anyswap_core::auth::AuthParams::from_fields(
623 auth_header(&request.headers, IDENTITY_PUBKEY_HEADER),
624 auth_header(&request.headers, TIMESTAMP_HEADER),
625 auth_header(&request.headers, SIGNATURE_HEADER),
626 )
627 .unwrap();
628 assert!(AuthCore::validate_pubkey(
629 &auth_params.pubkey,
630 &expected_user_id
631 ));
632
633 let signed = AuthRequest {
634 method: Method::POST.as_str(),
635 target: &request.path,
636 body: &request.body,
637 swap_id: &swap_id.to_string(),
638 timestamp: auth_params.timestamp,
639 };
640 assert!(AuthCore::validate_signature(
641 &AuthCore::request_digest(&signed, &auth_params.pubkey),
642 &auth_params.signature,
643 &auth_params.pubkey
644 ));
645
646 let wrong_swap_id = AuthRequest {
647 swap_id: "",
648 ..signed
649 };
650 assert!(!AuthCore::validate_signature(
651 &AuthCore::request_digest(&wrong_swap_id, &auth_params.pubkey),
652 &auth_params.signature,
653 &auth_params.pubkey
654 ));
655
656 stream
657 .write_all(b"HTTP/1.1 200 OK\r\ncontent-length: 0\r\n\r\n")
658 .unwrap();
659 });
660
661 let client = HttpClient::new(&base_url, Some(&wallet)).unwrap();
662 let result = client.create_swap(&req).await;
663 let server_result = server.join();
664
665 assert!(result.is_ok(), "{result:?}");
666 server_result.unwrap();
667 }
668
669 #[tokio::test]
670 async fn refuses_a_response_over_the_size_limit() {
671 let page = SwapListResponse {
672 swaps: Vec::new(),
673 pagination: Pagination {
674 total: 0,
675 limit: 100,
676 offset: 0,
677 has_next: false,
678 },
679 };
680 let mut body = serde_json::to_vec(&page).unwrap();
681 body.resize(MAX_RESPONSE_BYTES + 1, b' ');
682
683 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
684 let base_url = format!("http://{}", listener.local_addr().unwrap());
685 let server = std::thread::spawn(move || {
686 let (mut stream, _) = listener.accept().unwrap();
687 read_request(&mut stream);
688 let head = format!("HTTP/1.1 200 OK\r\ncontent-length: {}\r\n\r\n", body.len());
689 let _ = stream.write_all(head.as_bytes());
690 let _ = stream.write_all(&body);
691 });
692
693 let (wallet, _) = Wallet::generate(SwapNetwork::Regtest).unwrap();
694 let client = HttpClient::new(&base_url, Some(&wallet)).unwrap();
695 let query = SwapListQuery {
696 user_id: "alice".into(),
697 limit: 100,
698 ..SwapListQuery::default()
699 };
700 let result = client.list_swaps(&query).await;
701 server.join().unwrap();
702
703 assert!(
704 matches!(&result, Err(ClientError::Transport(message)) if message.contains("over")),
705 "{result:?}"
706 );
707 }
708}