df-hyperliquid-core 0.0.9-beta

Hyperliquid SDK for Rust: markets, account, trading, transfers, earn and deploy, with exact decimals and signing. Native and wasm.
Documentation
//! A mainnet [`Client`] whose wire keeps every answer, so a test can hold what the venue said
//! against the recording the unit and mocked suites read.

use std::cell::RefCell;
use std::future::Future;
use std::path::PathBuf;

use df_hyperliquid_core::client::native::{Native, clock};
use df_hyperliquid_core::client::{Http, Request, Response, TransportError};
use df_hyperliquid_core::{Client, Network};
use serde_json::Value;

use crate::shape::added;
use crate::support::{entry, fixture};

/// The real wire, keeping each request's body and its answer's text.
pub struct Tap {
    wire: Native,
    kept: RefCell<Vec<(String, String)>>,
}

impl Http for Tap {
    fn send(&self, request: Request) -> impl Future<Output = Result<Response, TransportError>> {
        let body = String::from_utf8_lossy(&request.body).into_owned();
        async move {
            let answer = self.wire.send(request).await?;
            self.kept.borrow_mut().push((body, answer.text()));
            Ok(answer)
        }
    }
}

/// A reader on mainnet through a [`Tap`].
pub fn reader() -> Client<Tap> {
    Client::reader(Tap { wire: Native::new(), kept: RefCell::default() }, Network::Mainnet, clock)
}

/// The last answer the client got, as JSON.
pub fn last(client: &Client<Tap>) -> Value {
    let kept = client.http().kept.borrow();
    let (body, answer) = kept.last().expect("nothing was sent");
    serde_json::from_str(answer).unwrap_or_else(|error| panic!("{error}: the answer to {body} is not JSON: {answer}"))
}

/// Holds the last answer against a recording: `fixtures/<path>`, or one entry of it as
/// `<path>#<key>`. Fails naming every key path the venue has added since it was recorded, and
/// keeps the live answer as `target/tmp/live/<path>` to re-record from.
pub fn same_shape(client: &Client<Tap>, recording: &str) {
    same_shape_as_any(client, &[recording]);
}

/// [`same_shape`] against several recordings of one read, for fields the venue sends on some rows
/// only (a fill's `liquidation`): a path counts as added only when none of them has it.
pub fn same_shape_as_any(client: &Client<Tap>, recordings: &[&str]) {
    let live = last(client);
    keep(recordings[0], &live);
    let mut unseen: Option<Vec<String>> = None;
    for recording in recordings {
        let recorded: Value = serde_json::from_str(&recorded(recording)).unwrap();
        let missing = added(&recorded, &live);
        unseen = Some(match unseen {
            None => missing,
            Some(before) => before.into_iter().filter(|path| missing.contains(path)).collect(),
        });
    }
    let unseen = unseen.unwrap_or_default();
    assert!(unseen.is_empty(), "the venue now answers {recordings:?} with fields it did not have: {unseen:#?}");
}

fn recorded(recording: &str) -> String {
    match recording.split_once('#') {
        Some((path, key)) => entry(path, key),
        None => fixture(recording),
    }
}

fn keep(recording: &str, live: &Value) {
    let path = PathBuf::from(env!("CARGO_TARGET_TMPDIR")).join("live").join(recording.replace('#', "."));
    std::fs::create_dir_all(path.parent().unwrap()).unwrap();
    std::fs::write(&path, serde_json::to_string_pretty(live).unwrap()).unwrap();
}