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 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
69/// The largest body read from the server. A full restore page, 100 swaps with
70/// their create requests, is under half a MiB.
71const MAX_RESPONSE_BYTES: usize = 4 << 20;
72
73#[derive(Clone, Debug, Error)]
74pub enum ClientError {
75    /// The server could not be reached, or the exchange broke before an
76    /// answer.
77    #[error("transport: {0}")]
78    Transport(String),
79
80    #[error("server ({status}): {message}")]
81    Api {
82        status: u16,
83        message: String,
84        /// Machine-readable error code, absent on responses without a known one.
85        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    /// The server's FSM refused a message it is past; the outcome is the same
118    /// as having delivered it.
119    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/// HTTP client for the AnySwap server API. See the [module docs](self) for swap flows.
131#[derive(Clone)]
132pub struct HttpClient {
133    http: reqwest::Client,
134    base_url: String,
135    identity: Option<Keypair>,
136}
137
138impl HttpClient {
139    /// A client for the server at `base_url`. It signs protected requests with
140    /// `wallet`'s identity key; without a wallet, only the public endpoints
141    /// work.
142    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    /// The signed `/v1/subscribe/{user_id}` WebSocket URL, auth in the query.
159    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        // The server checks the timestamp against its own clock.
165        #[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    /// Fetches server info: protocol version, network, and per-chain policies
186    /// (amount limits, confirmation requirements, swap_fee rates).
187    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    /// Initiates a swap. The server may cancel the swap right after creation;
197    /// the actual state is discovered via [`HttpClient::get_swap`].
198    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    /// Polls the current state of a swap. Convert the response to a typed
222    /// [`SwapIn`](crate::swap_in::SwapIn) via [`TryFrom`].
223    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    /// Fetches the paginated swap history for a user.
243    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    /// Requests swap cancellation. The outcome is observed via
264    /// [`HttpClient::get_swap`]: `Canceled` reveals the server's claim privkey
265    /// for an immediate keypath refund of any deposit.
266    pub async fn cancel(&self, swap_id: Uuid, req: &CancelRequest) -> Result<(), ClientError> {
267        self.post_protected(swap_id, "/cancel", req).await
268    }
269
270    /// Reveals the user's refund privkey after the claim invoice was paid
271    /// (swap-in only), letting the server claim cooperatively via the keypath
272    /// instead of the preimage script path.
273    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    /// Reveals the preimage off-chain (swap-out only) instead of claiming via
282    /// the script path. The server settles the held invoice and exposes its
283    /// refund privkey, letting the user finish with a cheap cooperative keypath
284    /// claim. Reveal only after the opening tx has the required confirmations.
285    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    // Builds the exact auth payload the server verifies.
322    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        // The server checks the timestamp against its own clock.
331        #[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(&timestamp.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
393/// `resp`'s body as `T`, read up to [`MAX_RESPONSE_BYTES`] so that one answer
394/// cannot exhaust the client's memory. A body that does not parse fails as
395/// transport, as `reqwest`'s own decoding does.
396async 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}