Skip to main content

rustrade_execution/client/hyperliquid/
common.rs

1//! Shared utilities for Hyperliquid execution clients (perps and spot).
2//!
3//! Contains parsing helpers and stream wrappers used by both
4//! `HyperliquidClient` (perps) and `HyperliquidSpotClient`.
5//!
6//! Error mapping is in the `error` module.
7
8use crate::client::hyperliquid::error::map_sdk_error;
9use crate::error::UnindexedClientError;
10use crate::order::{TimeInForce, id::ClientOrderId};
11use chrono::{DateTime, TimeZone, Utc};
12use futures::Stream;
13use rust_decimal::Decimal;
14use rustrade_instrument::{Side, instrument::name::InstrumentNameExchange};
15use smol_str::format_smolstr;
16use std::pin::Pin;
17use std::str::FromStr;
18use std::task::{Context, Poll};
19use tokio_util::sync::CancellationToken;
20use tracing::{debug, warn};
21use uuid::Uuid;
22
23/// Stream wrapper that cancels background tasks when dropped.
24///
25/// Ensures spawned WebSocket processing tasks are cleaned up when the consumer
26/// drops the account stream, preventing task leaks.
27#[derive(Debug)]
28pub struct CancelOnDropStream<S> {
29    inner: S,
30    cancel_token: CancellationToken,
31}
32
33impl<S> CancelOnDropStream<S> {
34    /// Create a new cancel-on-drop stream wrapper.
35    pub(crate) fn new(inner: S, cancel_token: CancellationToken) -> Self {
36        Self {
37            inner,
38            cancel_token,
39        }
40    }
41}
42
43impl<S: Stream + Unpin> Stream for CancelOnDropStream<S> {
44    type Item = S::Item;
45
46    fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
47        Pin::new(&mut self.inner).poll_next(cx)
48    }
49}
50
51impl<S> Drop for CancelOnDropStream<S> {
52    fn drop(&mut self) {
53        self.cancel_token.cancel();
54    }
55}
56
57/// Parse a decimal string from SDK response, logging warnings on failure.
58/// One fill from the `userFills` info endpoint, as the venue actually returns it.
59///
60/// # Why this exists rather than `hyperliquid_rust_sdk::UserFillsResponse`
61///
62/// The SDK's type does not model `tid`, the only per-fill identifier the endpoint offers, and it
63/// does not model `feeToken`. Neither omission is visible at runtime: the SDK does not set
64/// `deny_unknown_fields`, so both are parsed away silently. Without `tid` the only id left is
65/// `hash`, which identifies the *transaction* — one aggressive order sweeping several resting
66/// orders produces several fills under a single hash, and they are then indistinguishable.
67///
68/// Only the fields this client reads are declared. Unknown fields are ignored, so `hash`,
69/// `closedPnl`, `dir`, `startPosition`, `crossed` and `builderFee` cost nothing by being absent
70/// here.
71///
72/// Drop this in favour of the SDK type if it ever models `tid`.
73#[derive(Debug, Clone, serde::Deserialize)]
74#[serde(rename_all = "camelCase")]
75pub struct UserFill {
76    pub coin: String,
77    pub side: String,
78    pub px: String,
79    pub sz: String,
80    pub time: u64,
81    pub oid: u64,
82    pub fee: String,
83
84    /// Identifies this fill. `hash` identifies the transaction that contained it, and one
85    /// transaction can contain many fills.
86    ///
87    /// Declared required rather than `Option<u64>` deliberately. If the venue ever stops sending
88    /// it, this client should fail loudly at the parse — falling back to `hash` would silently
89    /// restore a duplicate-id defect that is invisible until fills go missing during
90    /// reconciliation.
91    ///
92    /// Hyperliquid documents this as unique per fill, but qualified by coin rather than global,
93    /// which is why the dedup cache keys on the instrument alongside it.
94    pub tid: u64,
95
96    /// Asset the fee is denominated in.
97    ///
98    /// Optional because, unlike every other field here, nothing proves the endpoint sends it: the
99    /// SDK's own type omits it, so it has never been observed through this dependency. The caller
100    /// falls back to inferring the asset from the side when it is absent.
101    pub fee_token: Option<String>,
102}
103
104/// Fetch `userFills` for `address`, parsing it into [`UserFill`] rather than the SDK's lossy type.
105///
106/// Issued through the SDK's own [`HttpClient`](hyperliquid_rust_sdk::InfoClient::http_client), so
107/// base URL, TLS configuration and error type are exactly those of every other info request this
108/// client makes — only the response type differs.
109///
110/// # Errors
111///
112/// Returns a transport error if the POST fails, or a parse error if the response does not match
113/// [`UserFill`] — which includes the case where `tid` has stopped being sent.
114pub async fn user_fills(
115    info_client: &hyperliquid_rust_sdk::InfoClient,
116    address: ethers::types::H160,
117) -> Result<Vec<UserFill>, UnindexedClientError> {
118    // `{:?}` on H160 renders the checksummed 0x-prefixed form the endpoint expects. `Display`
119    // abbreviates the middle of the address ("0x1234…5678"), so it must not be used here.
120    let body = format!(r#"{{"type":"userFills","user":"{address:?}"}}"#);
121
122    let raw = info_client
123        .http_client
124        .post("/info", body)
125        .await
126        .map_err(map_sdk_error)?;
127
128    // `Internal` rather than a connectivity error: a response that does not parse is a schema
129    // change or a venue-side regression, not a transient fault, and retrying will not fix it.
130    serde_json::from_str(&raw).map_err(|e| {
131        UnindexedClientError::Internal(format!("Hyperliquid userFills response did not parse: {e}"))
132    })
133}
134
135pub fn parse_decimal(value: &str, field: &str) -> Option<Decimal> {
136    Decimal::from_str(value)
137        .map_err(|e| warn!(%field, %value, %e, "Failed to parse decimal"))
138        .ok()
139}
140
141/// Parse SDK side string to rustrade Side.
142///
143/// Hyperliquid API returns "B" for buy/long and "A" for sell/short (ask-side).
144/// Extra variants included for defensive parsing of potential future API changes.
145pub fn parse_side(side: &str) -> Option<Side> {
146    match side {
147        "B" | "b" | "BUY" | "Buy" | "buy" => Some(Side::Buy),
148        "A" | "a" | "S" | "s" | "SELL" | "Sell" | "sell" => Some(Side::Sell),
149        _ => {
150            warn!(%side, "Unknown side string");
151            None
152        }
153    }
154}
155
156/// Convert milliseconds timestamp to `DateTime<Utc>`.
157///
158/// Returns `None` for timestamps outside the representable range (year 292M+).
159pub fn millis_to_datetime(millis: u64) -> Option<DateTime<Utc>> {
160    Utc.timestamp_millis_opt(i64::try_from(millis).ok()?)
161        .single()
162}
163
164/// Round a price to 5 significant figures (Hyperliquid requirement).
165///
166/// Performs rounding using `Decimal` arithmetic to avoid floating-point precision
167/// errors. The final `f64` conversion happens only at the SDK interface boundary.
168pub fn round_to_5_sig_figs(value: Decimal) -> f64 {
169    use rust_decimal::prelude::ToPrimitive;
170
171    if value.is_zero() {
172        return 0.0;
173    }
174
175    // Use f64 only for computing magnitude (acceptable precision for this purpose;
176    // we only need to know which power of 10 the number is close to).
177    let abs_f = value.abs().to_f64().unwrap_or(0.0);
178    if abs_f == 0.0 {
179        return 0.0;
180    }
181
182    // Clamp magnitude to prevent overflow when computing scale factor
183    #[allow(clippy::cast_possible_truncation)]
184    let magnitude = abs_f.log10().floor().clamp(-30.0, 30.0) as i32;
185
186    // Round using Decimal arithmetic to preserve precision
187    let rounded = if magnitude >= 4 {
188        // Large numbers (e.g., 123456): scale down, round integer, scale back up
189        // Safety: magnitude >= 4 guarantees (magnitude - 4) is non-negative
190        #[allow(clippy::cast_sign_loss)]
191        let factor = Decimal::from(10i64.pow((magnitude - 4) as u32));
192        (value / factor).round() * factor
193    } else {
194        // Small/medium numbers: round to appropriate decimal places
195        #[allow(clippy::cast_sign_loss)]
196        let dp = (4 - magnitude) as u32;
197        value.round_dp(dp)
198    };
199
200    // SDK requires f64 — convert only at the interface boundary
201    rounded.to_f64().unwrap_or(0.0)
202}
203
204/// Map rustrade TimeInForce to Hyperliquid TIF string.
205pub fn map_tif(tif: &TimeInForce) -> &'static str {
206    match tif {
207        TimeInForce::GoodUntilCancelled { post_only: true } => "Alo",
208        TimeInForce::GoodUntilCancelled { post_only: false } => "Gtc",
209        TimeInForce::ImmediateOrCancel => "Ioc",
210        TimeInForce::FillOrKill => "Ioc", // Hyperliquid doesn't have FOK, use IOC
211        TimeInForce::GoodUntilEndOfDay => "Gtc", // No EOD on Hyperliquid
212        // Hyperliquid is a perpetuals DEX with no auction sessions and no native
213        // GTD. Coerce to GTC and surface the loss of semantics so downstream
214        // wrappers can decide whether to reject upstream.
215        TimeInForce::GoodTillDate { .. } | TimeInForce::AtOpen | TimeInForce::AtClose => {
216            warn!(time_in_force = ?tif, "Hyperliquid does not support this TimeInForce; coercing to Gtc");
217            "Gtc"
218        }
219    }
220}
221
222/// Convert a ClientOrderId to SDK cloid format (UUID) if valid.
223///
224/// Returns `Some(Uuid)` if the cid is a valid UUID, `None` otherwise.
225/// Non-UUID CIDs are logged at debug level since they're common in tests/examples.
226pub fn cid_to_cloid(cid: &ClientOrderId) -> Option<Uuid> {
227    match Uuid::parse_str(cid.0.as_str()) {
228        Ok(uuid) => Some(uuid),
229        Err(_) => {
230            debug!(cid = %cid.0, "CID is not a valid UUID, cloid will be None");
231            None
232        }
233    }
234}
235
236/// Build perp instrument name from Hyperliquid coin name (e.g., "BTC" -> "BTC-USD-PERP").
237pub fn perp_coin_to_instrument(coin: &str) -> InstrumentNameExchange {
238    InstrumentNameExchange::from(format_smolstr!("{}-USD-PERP", coin))
239}
240
241/// Extract coin name from perp instrument (e.g., "BTC-USD-PERP" -> "BTC").
242///
243/// Returns `String` because Hyperliquid SDK requires `String` for asset fields.
244pub fn instrument_to_perp_coin(instrument: &InstrumentNameExchange) -> String {
245    let s = instrument.as_ref();
246    // Expected format: "COIN-USD-PERP" or just "COIN"
247    match s.split_once('-') {
248        Some((coin, _)) => coin.to_string(),
249        None => s.to_string(),
250    }
251}
252
253/// Build spot instrument name from Hyperliquid coin pair (e.g., "PURR/USDC" -> "PURR-USDC-SPOT").
254///
255/// # Panics (debug builds only)
256///
257/// Debug-asserts if `coin` does not contain '/' — callers must verify `is_spot_coin()`
258/// before calling. The fallback path produces a malformed instrument name.
259pub fn spot_coin_to_instrument(coin: &str) -> InstrumentNameExchange {
260    match coin.split_once('/') {
261        Some((base, quote)) => {
262            InstrumentNameExchange::from(format_smolstr!("{}-{}-SPOT", base, quote))
263        }
264        None => {
265            debug_assert!(
266                false,
267                "spot_coin_to_instrument called with non-spot coin: {coin}"
268            );
269            InstrumentNameExchange::from(format_smolstr!("{}-SPOT", coin))
270        }
271    }
272}
273
274/// Extract coin pair from spot instrument (e.g., "PURR-USDC-SPOT" -> "PURR/USDC").
275///
276/// Returns `Option<String>` — `None` if the instrument doesn't match expected
277/// `BASE-QUOTE-SPOT` format. Callers should fail fast on `None` rather than
278/// send a malformed asset to the exchange.
279pub fn instrument_to_spot_coin(instrument: &InstrumentNameExchange) -> Option<String> {
280    let s = instrument.as_ref();
281    // Expected format: "BASE-QUOTE-SPOT" -> "BASE/QUOTE"
282    let without_suffix = s.strip_suffix("-SPOT")?;
283    let (base, quote) = without_suffix.split_once('-')?;
284    Some(format!("{}/{}", base, quote))
285}
286
287/// Check if a Hyperliquid coin name is a spot pair (contains '/').
288///
289/// Hyperliquid API uses pair format `"BASE/QUOTE"` (e.g., `"PURR/USDC"`) for spot coins
290/// and single symbols (e.g., `"BTC"`) for perpetuals. This naming convention is observed
291/// across all SDK examples and test fixtures. The invariant is validated by our spot
292/// fixture tests in `hyperliquid_spot_execution.rs`.
293pub fn is_spot_coin(coin: &str) -> bool {
294    coin.contains('/')
295}
296
297#[cfg(test)]
298// Test code: panics on bad input are acceptable
299#[allow(clippy::unwrap_used)]
300mod tests {
301    use super::*;
302    use rust_decimal_macros::dec;
303
304    #[test]
305    fn test_parse_decimal_valid() {
306        assert_eq!(parse_decimal("123.456", "test"), Some(dec!(123.456)));
307        assert_eq!(parse_decimal("0", "test"), Some(dec!(0)));
308        assert_eq!(parse_decimal("-50.5", "test"), Some(dec!(-50.5)));
309    }
310
311    #[test]
312    fn test_parse_decimal_invalid() {
313        assert_eq!(parse_decimal("", "test"), None);
314        assert_eq!(parse_decimal("abc", "test"), None);
315        assert_eq!(parse_decimal("12.34.56", "test"), None);
316    }
317
318    #[test]
319    fn test_parse_side() {
320        assert_eq!(parse_side("B"), Some(Side::Buy));
321        assert_eq!(parse_side("BUY"), Some(Side::Buy));
322        assert_eq!(parse_side("buy"), Some(Side::Buy));
323        assert_eq!(parse_side("A"), Some(Side::Sell));
324        assert_eq!(parse_side("S"), Some(Side::Sell));
325        assert_eq!(parse_side("SELL"), Some(Side::Sell));
326        assert_eq!(parse_side("sell"), Some(Side::Sell));
327        assert_eq!(parse_side("X"), None);
328        assert_eq!(parse_side(""), None);
329    }
330
331    #[test]
332    fn test_perp_coin_to_instrument() {
333        let inst = perp_coin_to_instrument("BTC");
334        assert_eq!(inst.as_ref(), "BTC-USD-PERP");
335
336        let inst = perp_coin_to_instrument("ETH");
337        assert_eq!(inst.as_ref(), "ETH-USD-PERP");
338    }
339
340    #[test]
341    fn test_instrument_to_perp_coin() {
342        let coin = instrument_to_perp_coin(&InstrumentNameExchange::from("BTC-USD-PERP"));
343        assert_eq!(coin, "BTC");
344
345        let coin = instrument_to_perp_coin(&InstrumentNameExchange::from("ETH-USD-PERP"));
346        assert_eq!(coin, "ETH");
347
348        // Just coin name without suffix
349        let coin = instrument_to_perp_coin(&InstrumentNameExchange::from("SOL"));
350        assert_eq!(coin, "SOL");
351    }
352
353    #[test]
354    fn test_spot_coin_to_instrument() {
355        let inst = spot_coin_to_instrument("PURR/USDC");
356        assert_eq!(inst.as_ref(), "PURR-USDC-SPOT");
357
358        let inst = spot_coin_to_instrument("HYPE/USDC");
359        assert_eq!(inst.as_ref(), "HYPE-USDC-SPOT");
360    }
361
362    #[test]
363    fn test_instrument_to_spot_coin() {
364        let coin = instrument_to_spot_coin(&InstrumentNameExchange::from("PURR-USDC-SPOT"));
365        assert_eq!(coin, Some("PURR/USDC".to_string()));
366
367        let coin = instrument_to_spot_coin(&InstrumentNameExchange::from("HYPE-USDC-SPOT"));
368        assert_eq!(coin, Some("HYPE/USDC".to_string()));
369
370        // Malformed instruments return None
371        assert_eq!(
372            instrument_to_spot_coin(&InstrumentNameExchange::from("BTC-USD-PERP")),
373            None
374        );
375        assert_eq!(
376            instrument_to_spot_coin(&InstrumentNameExchange::from("INVALID")),
377            None
378        );
379    }
380
381    #[test]
382    fn test_is_spot_coin() {
383        assert!(is_spot_coin("PURR/USDC"));
384        assert!(is_spot_coin("HYPE/USDC"));
385        assert!(!is_spot_coin("BTC"));
386        assert!(!is_spot_coin("ETH"));
387    }
388
389    #[test]
390    fn test_round_to_5_sig_figs() {
391        assert_eq!(round_to_5_sig_figs(dec!(0)), 0.0);
392        assert_eq!(round_to_5_sig_figs(dec!(12345)), 12345.0);
393        assert_eq!(round_to_5_sig_figs(dec!(123456)), 123460.0);
394        assert_eq!(round_to_5_sig_figs(dec!(0.00012345)), 0.00012345);
395        assert_eq!(round_to_5_sig_figs(dec!(0.000123456)), 0.00012346);
396        assert_eq!(round_to_5_sig_figs(dec!(1.23456789)), 1.2346);
397    }
398
399    #[test]
400    fn test_map_tif() {
401        assert_eq!(
402            map_tif(&TimeInForce::GoodUntilCancelled { post_only: false }),
403            "Gtc"
404        );
405        assert_eq!(
406            map_tif(&TimeInForce::GoodUntilCancelled { post_only: true }),
407            "Alo"
408        );
409        assert_eq!(map_tif(&TimeInForce::ImmediateOrCancel), "Ioc");
410        assert_eq!(map_tif(&TimeInForce::FillOrKill), "Ioc");
411        assert_eq!(map_tif(&TimeInForce::GoodUntilEndOfDay), "Gtc");
412    }
413
414    #[test]
415    fn test_millis_to_datetime() {
416        let dt = millis_to_datetime(1714100000000).unwrap();
417        assert_eq!(dt.timestamp_millis(), 1714100000000);
418
419        // Zero timestamp (Unix epoch) is valid
420        assert!(millis_to_datetime(0).is_some());
421    }
422}
423
424#[cfg(test)]
425// Test code: panics on bad input are acceptable
426#[allow(clippy::unwrap_used)]
427mod user_fills_tests {
428    use super::*;
429    use wiremock::matchers::{method, path};
430    use wiremock::{Mock, MockServer, ResponseTemplate};
431
432    /// One fill exactly as the `userFills` documentation shows it, including the two fields the
433    /// SDK's own response type drops.
434    const DOCUMENTED_FILL: &str = r#"{
435        "closedPnl": "0.0",
436        "coin": "AVAX",
437        "crossed": false,
438        "dir": "Open Long",
439        "hash": "0xa166e3fa63c25663024b03f2e0da011a00307e4017465df020210d3d432e7cb8",
440        "oid": 90542681,
441        "px": "18.435",
442        "side": "B",
443        "startPosition": "26.86",
444        "sz": "93.53",
445        "time": 1681222254710,
446        "fee": "0.01",
447        "feeToken": "USDC",
448        "builderFee": "0.01",
449        "tid": 118906512037719
450    }"#;
451
452    /// Build an `InfoClient` pointed at `uri`. `InfoClient::new` opens no connection -- it only
453    /// fills in the struct -- so this costs nothing and touches no network.
454    async fn info_client_against(uri: String) -> hyperliquid_rust_sdk::InfoClient {
455        let mut client = hyperliquid_rust_sdk::InfoClient::new(
456            None,
457            Some(hyperliquid_rust_sdk::BaseUrl::Localhost),
458        )
459        .await
460        .unwrap();
461        client.http_client.base_url = uri;
462        client
463    }
464
465    async fn serve(body: String) -> (MockServer, hyperliquid_rust_sdk::InfoClient) {
466        let server = MockServer::start().await;
467        Mock::given(method("POST"))
468            .and(path("/info"))
469            .respond_with(ResponseTemplate::new(200).set_body_raw(body, "application/json"))
470            .mount(&server)
471            .await;
472        let client = info_client_against(server.uri()).await;
473        (server, client)
474    }
475
476    #[tokio::test]
477    async fn a_fill_carries_the_tid_and_fee_token_the_sdk_type_drops() {
478        let (_server, client) = serve(format!("[{DOCUMENTED_FILL}]")).await;
479
480        let fills = user_fills(&client, ethers::types::H160::zero())
481            .await
482            .unwrap();
483
484        assert_eq!(fills.len(), 1);
485        assert_eq!(fills[0].tid, 118_906_512_037_719);
486        assert_eq!(fills[0].fee_token.as_deref(), Some("USDC"));
487        assert_eq!(fills[0].oid, 90_542_681);
488        assert_eq!(fills[0].coin, "AVAX");
489    }
490
491    #[tokio::test]
492    async fn one_transaction_can_carry_several_fills_and_they_are_told_apart_by_tid() {
493        // A single aggressive order sweeping three resting orders: one `hash`, one `oid`, three
494        // fills. This is the shape the old `TradeId(hash)` collapsed into one trade.
495        let sweep = r#"[
496            {"closedPnl":"0.0","coin":"AVAX","crossed":true,"dir":"Open Long",
497             "hash":"0xsweep","oid":1,"px":"18.40","side":"B","startPosition":"0",
498             "sz":"10","time":1681222254710,"fee":"0.01","feeToken":"USDC","tid":111},
499            {"closedPnl":"0.0","coin":"AVAX","crossed":true,"dir":"Open Long",
500             "hash":"0xsweep","oid":1,"px":"18.45","side":"B","startPosition":"10",
501             "sz":"10","time":1681222254710,"fee":"0.01","feeToken":"USDC","tid":222},
502            {"closedPnl":"0.0","coin":"AVAX","crossed":true,"dir":"Open Long",
503             "hash":"0xsweep","oid":1,"px":"18.50","side":"B","startPosition":"20",
504             "sz":"10","time":1681222254710,"fee":"0.01","feeToken":"USDC","tid":333}
505        ]"#;
506        let (_server, client) = serve(sweep.to_string()).await;
507
508        let fills = user_fills(&client, ethers::types::H160::zero())
509            .await
510            .unwrap();
511
512        let tids: Vec<u64> = fills.iter().map(|f| f.tid).collect();
513        assert_eq!(tids, vec![111, 222, 333]);
514    }
515
516    #[tokio::test]
517    async fn a_missing_fee_token_falls_back_rather_than_failing() {
518        // `feeToken` is the one field here that nothing proves the endpoint sends, so its absence
519        // must not cost the caller the whole response.
520        let without = DOCUMENTED_FILL.replace(r#""feeToken": "USDC","#, "");
521        let (_server, client) = serve(format!("[{without}]")).await;
522
523        let fills = user_fills(&client, ethers::types::H160::zero())
524            .await
525            .unwrap();
526
527        assert_eq!(fills.len(), 1);
528        assert!(fills[0].fee_token.is_none());
529    }
530
531    #[tokio::test]
532    async fn a_missing_tid_fails_loudly_instead_of_falling_back_to_the_hash() {
533        // Silently reverting to `hash` would reinstate the duplicate-id defect, and it would only
534        // become visible as fills going missing during reconciliation.
535        let without = DOCUMENTED_FILL.replace(r#""tid": 118906512037719"#, r#""unused": 0"#);
536        let (_server, client) = serve(format!("[{without}]")).await;
537
538        let error = user_fills(&client, ethers::types::H160::zero())
539            .await
540            .unwrap_err();
541
542        assert!(
543            matches!(&error, UnindexedClientError::Internal(msg) if msg.contains("userFills")),
544            "expected a parse failure naming the endpoint, got {error:?}"
545        );
546    }
547
548    #[tokio::test]
549    async fn the_request_names_the_address_in_full() {
550        // `Display` for H160 abbreviates the middle of an address, which the venue would not
551        // recognise. Only the `Debug` rendering is the full 0x-prefixed form.
552        let server = MockServer::start().await;
553        Mock::given(method("POST"))
554            .and(path("/info"))
555            .and(wiremock::matchers::body_string_contains(
556                "0x000000000000000000000000000000000000dead",
557            ))
558            .and(wiremock::matchers::body_string_contains("userFills"))
559            .respond_with(ResponseTemplate::new(200).set_body_raw("[]", "application/json"))
560            .mount(&server)
561            .await;
562        let client = info_client_against(server.uri()).await;
563
564        let address = "0x000000000000000000000000000000000000dEaD"
565            .parse::<ethers::types::H160>()
566            .unwrap();
567
568        // The mock only answers a body carrying the full address, so reaching `Ok` is the
569        // assertion.
570        assert!(user_fills(&client, address).await.unwrap().is_empty());
571    }
572}