use anyswap_core::{
api::{
CancelRequest, CreateSwapRequest, ErrorCode, InfoResponse, RevealClaimRequest,
RevealPreimageRequest, SwapListQuery, SwapListResponse, SwapState,
},
auth::{AuthCore, AuthRequest, IDENTITY_PUBKEY_HEADER, SIGNATURE_HEADER, TIMESTAMP_HEADER},
utils::now,
};
use bitcoin::{
PrivateKey,
hashes::{Hash, sha256},
secp256k1::{Keypair, Secp256k1},
};
use reqwest::{
Method,
header::{CONTENT_TYPE, HeaderMap, HeaderValue},
};
use serde::Deserialize;
use thiserror::Error;
use uuid::Uuid;
#[derive(Clone, Debug, Error)]
pub enum ClientError {
#[error("transport: {0}")]
Transport(String),
#[error("server ({status}): {message}")]
Api {
status: u16,
message: String,
code: Option<ErrorCode>,
},
#[error("invalid auth header value: {0}")]
Header(String),
#[error("json: {0}")]
Json(String),
#[error("missing identity private key")]
MissingPrivateKey,
}
impl From<reqwest::Error> for ClientError {
fn from(e: reqwest::Error) -> Self {
Self::Transport(e.to_string())
}
}
impl From<reqwest::header::InvalidHeaderValue> for ClientError {
fn from(e: reqwest::header::InvalidHeaderValue) -> Self {
Self::Header(e.to_string())
}
}
impl From<serde_json::Error> for ClientError {
fn from(e: serde_json::Error) -> Self {
Self::Json(e.to_string())
}
}
impl ClientError {
pub fn transition_rejected(&self) -> bool {
matches!(
self,
Self::Api {
code: Some(ErrorCode::TransitionRejected),
..
}
)
}
}
#[derive(Clone)]
pub struct HttpClient {
http: reqwest::Client,
base_url: String,
private_key: Option<PrivateKey>,
}
impl HttpClient {
pub fn new(base_url: &str, private_key: Option<PrivateKey>) -> Self {
Self {
http: reqwest::Client::new(),
base_url: base_url.trim_end_matches('/').to_string(),
private_key,
}
}
pub fn set_private_key(&mut self, private_key: PrivateKey) -> &mut Self {
self.private_key = Some(private_key);
self
}
pub fn subscribe_url(&self) -> Result<String, ClientError> {
let identity_private_key = self
.private_key
.as_ref()
.ok_or(ClientError::MissingPrivateKey)?;
let keypair = identity_keypair(identity_private_key);
let pubkey = keypair.public_key();
let user_id = sha256::Hash::hash(&pubkey.serialize()).to_string();
let target = format!("/v1/subscribe/{user_id}");
let timestamp = now() as i64;
let signature = AuthCore::sign_request(
&AuthRequest {
method: Method::GET.as_str(),
target: &target,
body: &[],
swap_id: "",
timestamp,
},
&keypair,
);
let base = self.base_url.replacen("http", "ws", 1);
Ok(format!(
"{base}{target}?{IDENTITY_PUBKEY_HEADER}={}&{TIMESTAMP_HEADER}={timestamp}&{SIGNATURE_HEADER}={}",
hex::encode(pubkey.serialize()),
hex::encode(signature.as_ref()),
))
}
pub async fn get_info(&self) -> Result<InfoResponse, ClientError> {
let resp = self
.http
.get(format!("{}/v1/info", self.base_url))
.send()
.await?;
Ok(check(resp).await?.json().await?)
}
pub async fn create_swap(&self, req: &CreateSwapRequest) -> Result<(), ClientError> {
let path = "/v1/swap";
let body = serde_json::to_vec(req)?;
let identity_private_key = self
.private_key
.as_ref()
.ok_or(ClientError::MissingPrivateKey)?;
let identity_keypair = identity_keypair(identity_private_key);
let headers = self.prepare_headers(
&req.swap_id.to_string(),
&Method::POST,
path,
&body,
&identity_keypair,
)?;
let resp = self
.http
.post(format!("{}{}", self.base_url, path))
.headers(headers)
.header(CONTENT_TYPE, "application/json")
.body(body)
.send()
.await?;
check(resp).await?;
Ok(())
}
pub async fn get_swap(&self, swap_id: Uuid) -> Result<SwapState, ClientError> {
let path = format!("/v1/swap/{swap_id}");
let identity_private_key = self
.private_key
.as_ref()
.ok_or(ClientError::MissingPrivateKey)?;
let identity_keypair = identity_keypair(identity_private_key);
let headers = self.prepare_headers(
&swap_id.to_string(),
&Method::GET,
&path,
&[],
&identity_keypair,
)?;
let resp = self
.http
.get(format!("{}{}", self.base_url, path))
.headers(headers)
.send()
.await?;
Ok(check(resp).await?.json().await?)
}
pub async fn list_swaps(&self, query: &SwapListQuery) -> Result<SwapListResponse, ClientError> {
let path = "/v1/swap";
let identity_private_key = self
.private_key
.as_ref()
.ok_or(ClientError::MissingPrivateKey)?;
let identity_keypair = identity_keypair(identity_private_key);
let mut request = self
.http
.get(format!("{}{}", self.base_url, path))
.query(query)
.build()?;
let target = match request.url().query() {
Some(query) => format!("{path}?{query}"),
None => path.to_owned(),
};
let headers = self.prepare_headers("", &Method::GET, &target, &[], &identity_keypair)?;
request.headers_mut().extend(headers);
let resp = self.http.execute(request).await?;
Ok(check(resp).await?.json().await?)
}
pub async fn cancel(&self, swap_id: Uuid, req: &CancelRequest) -> Result<(), ClientError> {
self.post_protected(swap_id, "/cancel", req).await
}
pub async fn reveal_claim(
&self,
swap_id: Uuid,
req: &RevealClaimRequest,
) -> Result<(), ClientError> {
self.post_protected(swap_id, "/reveal/claim", req).await
}
pub async fn reveal_preimage(
&self,
swap_id: Uuid,
req: &RevealPreimageRequest,
) -> Result<(), ClientError> {
self.post_protected(swap_id, "/reveal/preimage", req).await
}
async fn post_protected(
&self,
swap_id: Uuid,
path_suffix: &str,
req: &impl serde::Serialize,
) -> Result<(), ClientError> {
let body = serde_json::to_vec(req)?;
let path = format!("/v1/swap/{swap_id}{path_suffix}");
let identity_private_key = self
.private_key
.as_ref()
.ok_or(ClientError::MissingPrivateKey)?;
let identity_keypair = identity_keypair(identity_private_key);
let headers = self.prepare_headers(
&swap_id.to_string(),
&Method::POST,
&path,
&body,
&identity_keypair,
)?;
let resp = self
.http
.post(format!("{}{}", self.base_url, path))
.headers(headers)
.header(CONTENT_TYPE, "application/json")
.body(body)
.send()
.await?;
check(resp).await?;
Ok(())
}
fn prepare_headers(
&self,
swap_id: &str,
method: &Method,
target: &str,
raw_body: &[u8],
identity_keypair: &Keypair,
) -> Result<HeaderMap, ClientError> {
let timestamp = now() as i64;
let pubkey = identity_keypair.public_key();
let signature = AuthCore::sign_request(
&AuthRequest {
method: method.as_str(),
target,
body: raw_body,
swap_id,
timestamp,
},
identity_keypair,
);
let mut headers = HeaderMap::new();
headers.insert(
IDENTITY_PUBKEY_HEADER,
HeaderValue::from_str(&hex::encode(pubkey.serialize()))?,
);
headers.insert(
TIMESTAMP_HEADER,
HeaderValue::from_str(×tamp.to_string())?,
);
headers.insert(
SIGNATURE_HEADER,
HeaderValue::from_str(&hex::encode(signature.as_ref()))?,
);
Ok(headers)
}
}
fn identity_keypair(identity_private_key: &PrivateKey) -> Keypair {
let secp = Secp256k1::new();
Keypair::from_secret_key(&secp, &identity_private_key.inner)
}
async fn check(resp: reqwest::Response) -> Result<reqwest::Response, ClientError> {
let status = resp.status();
if status.is_success() {
return Ok(resp);
}
#[derive(Deserialize)]
struct ErrorBody {
message: String,
#[serde(default)]
code: Option<String>,
}
let (message, code) = match resp.json::<ErrorBody>().await {
Ok(body) => (body.message, body.code.and_then(|c| c.parse().ok())),
Err(_) => (
status
.canonical_reason()
.unwrap_or("unknown error")
.to_string(),
None,
),
};
Err(ClientError::Api {
status: status.as_u16(),
message,
code,
})
}
#[cfg(test)]
mod tests {
use std::{
collections::HashMap,
io::{Read, Write},
net::{TcpListener, TcpStream},
time::Duration,
};
use anyswap_core::types::Chain;
use bitcoin::{
Network,
hashes::{Hash, sha256},
secp256k1::{SecretKey, schnorr::Signature},
};
use super::*;
struct RecordedRequest {
method: String,
path: String,
headers: HashMap<String, String>,
body: Vec<u8>,
}
fn test_keypair() -> Keypair {
let secp = Secp256k1::new();
let secret = SecretKey::from_slice(&[1; 32]).unwrap();
Keypair::from_secret_key(&secp, &secret)
}
fn test_identity_private_key() -> PrivateKey {
PrivateKey::new(SecretKey::from_slice(&[1; 32]).unwrap(), Network::Regtest)
}
fn read_request(stream: &mut TcpStream) -> RecordedRequest {
stream
.set_read_timeout(Some(Duration::from_secs(5)))
.unwrap();
let mut data = Vec::new();
let mut buf = [0; 1024];
let header_end = loop {
let n = stream.read(&mut buf).unwrap();
assert!(n > 0, "client closed connection before sending headers");
data.extend_from_slice(&buf[..n]);
if let Some(pos) = data.windows(4).position(|window| window == b"\r\n\r\n") {
break pos;
}
};
let headers = String::from_utf8(data[..header_end].to_vec()).unwrap();
let content_length = headers
.lines()
.find_map(|line| {
let (name, value) = line.split_once(':')?;
name.eq_ignore_ascii_case("content-length")
.then(|| value.trim().parse::<usize>().unwrap())
})
.unwrap_or(0);
let body_start = header_end + 4;
let total_len = body_start + content_length;
while data.len() < total_len {
let n = stream.read(&mut buf).unwrap();
assert!(n > 0, "client closed connection before sending body");
data.extend_from_slice(&buf[..n]);
}
let mut lines = headers.lines();
let request_line = lines.next().unwrap();
let mut request_parts = request_line.split_whitespace();
let method = request_parts.next().unwrap().to_string();
let path = request_parts.next().unwrap().to_string();
let headers = lines
.filter_map(|line| {
let (name, value) = line.split_once(':')?;
Some((name.to_ascii_lowercase(), value.trim().to_string()))
})
.collect();
RecordedRequest {
method,
path,
headers,
body: data[body_start..total_len].to_vec(),
}
}
fn auth_header<'a>(headers: &'a HashMap<String, String>, name: &str) -> &'a str {
headers.get(name).map(String::as_str).unwrap()
}
fn signature_and_timestamp(headers: &HeaderMap) -> (Signature, i64) {
let signature_hex = headers.get(SIGNATURE_HEADER).unwrap().to_str().unwrap();
let timestamp = headers.get(TIMESTAMP_HEADER).unwrap().to_str().unwrap();
(
Signature::from_slice(&hex::decode(signature_hex).unwrap()).unwrap(),
timestamp.parse().unwrap(),
)
}
#[test]
fn prepare_headers_signs_the_target_including_its_query() {
let client = HttpClient::new("http://localhost:8083", None);
let keypair = test_keypair();
let pubkey = keypair.public_key();
let target = "/v1/swap?user_id=alice&limit=20";
let headers = client
.prepare_headers("", &Method::GET, target, &[], &keypair)
.unwrap();
let (signature, timestamp) = signature_and_timestamp(&headers);
let signed = AuthRequest {
method: Method::GET.as_str(),
target,
body: &[],
swap_id: "",
timestamp,
};
assert!(AuthCore::validate_signature(
&AuthCore::request_digest(&signed, &pubkey),
&signature,
&pubkey
));
let widened = AuthRequest {
target: "/v1/swap?user_id=alice&limit=500",
..signed
};
assert!(!AuthCore::validate_signature(
&AuthCore::request_digest(&widened, &pubkey),
&signature,
&pubkey
));
}
#[test]
fn prepare_headers_sets_verifiable_signature() {
let client = HttpClient::new("http://localhost:8083", None);
let keypair = test_keypair();
let pubkey = keypair.public_key();
let headers = client
.prepare_headers("swap-1", &Method::GET, "/v1/swap/swap-1", &[], &keypair)
.unwrap();
assert_eq!(
headers.get(IDENTITY_PUBKEY_HEADER).unwrap(),
hex::encode(pubkey.serialize()).as_str()
);
let (signature, timestamp) = signature_and_timestamp(&headers);
assert!(timestamp > 0);
let signed = AuthRequest {
method: Method::GET.as_str(),
target: "/v1/swap/swap-1",
body: &[],
swap_id: "swap-1",
timestamp,
};
assert!(AuthCore::validate_signature(
&AuthCore::request_digest(&signed, &pubkey),
&signature,
&pubkey
));
}
#[tokio::test]
async fn create_swap_signature_verifies_against_body_swap_id() {
let swap_id = Uuid::from_u128(0x1234567890abcdef1234567890abcdef);
let pubkey = test_keypair().public_key();
let req = CreateSwapRequest {
protocol_version: "3.0.0".into(),
swap_id,
user_id: sha256::Hash::hash(&pubkey.serialize()).to_string(),
source_chain: Some(Chain::Bitcoin),
source_asset_id: Some(anyswap_core::types::NATIVE_ASSET.to_string()),
dest_chain: None,
dest_asset_id: None,
receive_amount: 10_000,
send_amount: 11_000,
quote_id: None,
claim_invoice: Some("lnbc1test".to_string()),
source_refund_pubkey: Some(hex::encode(pubkey.serialize())),
source_blinding_pubkey: None,
dest_claim_pubkey: None,
dest_blinding_pubkey: None,
user_claim_address: None,
user_refund_address: None,
payment_hash: None,
webhook: None,
};
let expected_body = serde_json::to_vec(&req).unwrap();
let expected_user_id = req.user_id.clone();
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let base_url = format!("http://{}", listener.local_addr().unwrap());
let server = std::thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap();
let request = read_request(&mut stream);
assert_eq!(request.method, Method::POST.as_str());
assert_eq!(request.path, "/v1/swap");
assert_eq!(request.body, expected_body);
let auth_params = anyswap_core::auth::AuthParams::from_fields(
auth_header(&request.headers, IDENTITY_PUBKEY_HEADER),
auth_header(&request.headers, TIMESTAMP_HEADER),
auth_header(&request.headers, SIGNATURE_HEADER),
)
.unwrap();
assert!(AuthCore::validate_pubkey(
&auth_params.pubkey,
&expected_user_id
));
let signed = AuthRequest {
method: Method::POST.as_str(),
target: &request.path,
body: &request.body,
swap_id: &swap_id.to_string(),
timestamp: auth_params.timestamp,
};
assert!(AuthCore::validate_signature(
&AuthCore::request_digest(&signed, &auth_params.pubkey),
&auth_params.signature,
&auth_params.pubkey
));
let wrong_swap_id = AuthRequest {
swap_id: "",
..signed
};
assert!(!AuthCore::validate_signature(
&AuthCore::request_digest(&wrong_swap_id, &auth_params.pubkey),
&auth_params.signature,
&auth_params.pubkey
));
stream
.write_all(b"HTTP/1.1 200 OK\r\ncontent-length: 0\r\n\r\n")
.unwrap();
});
let mut client = HttpClient::new(&base_url, None);
client.set_private_key(test_identity_private_key());
let result = client.create_swap(&req).await;
let server_result = server.join();
assert!(result.is_ok(), "{result:?}");
server_result.unwrap();
}
}