df-hyperliquid-core 0.0.8-beta

Hyperliquid SDK for Rust: markets, account, trading, transfers, earn and deploy, with exact decimals and signing. Native and wasm.
Documentation
//! `mainnet.json` and `testnet.json` are live answers read on 2026-10-08, cut
//! to their first rows, as the venue wrote them:
//!
//! - mainnet: a HIP-3 deployer's stake (`0x88806a71d74ad0a510b350545c9ae490912f0888`),
//!   daily rewards, one withdrawal through the queue;
//! - testnet: two validators, and validator `bob node`'s own stake
//!   (`0x0594f60525fc6a5d05bb711fb6450f84fef307bd`), hourly rewards, two
//!   withdrawals through the queue.
//!
//! The figures each test expects were worked out from the recorded rows by
//! hand, not by this code.

use std::{
    future::{Future, ready},
    pin::pin,
    task::{Context, Poll, Waker},
};

use crate::client::{Network, TransportError};
use crate::utils::{Address, Dec};
use serde::Deserialize;
use serde_json::Value;

use super::*;
use crate::earn::staking::reads::{
    Delegation, DelegatorSummary, MAINNET_DELEGATION_LOCK_MS, MAINNET_UNSTAKING_MS, Reward, StakeDelta, StakeEvent,
    TESTNET_DELEGATION_LOCK_MS, TESTNET_UNSTAKING_MS, ValidatorSummary, delegation_lock_ms, locked_until, unstaking_ms,
};

const MAINNET: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/fixtures/stake/mainnet.json"));
const TESTNET: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/fixtures/stake/testnet.json"));
const DAY: u64 = 86_400_000;

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct Recorded {
    #[serde(default)]
    validator_summaries: Vec<ValidatorSummary>,
    delegator_summary: DelegatorSummary,
    delegations: Vec<Delegation>,
    delegator_rewards: Vec<Reward>,
    delegator_history: Vec<StakeEvent>,
}

fn recorded(network: Network) -> Recorded {
    serde_json::from_str(match network {
        Network::Mainnet => MAINNET,
        Network::Testnet => TESTNET,
    })
    .unwrap()
}

fn dec(text: &str) -> Dec {
    Dec::parse(text).unwrap()
}

fn address(text: &str) -> Address {
    text.parse().unwrap()
}

/// When each initiated withdrawal in the history was finalized: the next
/// newer `finalized` row.
fn queued(history: &[StakeEvent]) -> Vec<(u64, u64)> {
    history
        .iter()
        .enumerate()
        .filter(|(_, event)| matches!(&event.delta, StakeDelta::Withdrawal { phase, .. } if phase == "initiated"))
        .filter_map(|(at, event)| {
            let finalized = history[..at]
                .iter()
                .rev()
                .find(|newer| matches!(&newer.delta, StakeDelta::Withdrawal { phase, .. } if phase == "finalized"))?;
            Some((event.time, finalized.time))
        })
        .collect()
}

/// Seven days on mainnet; five minutes on testnet. Each recorded withdrawal
/// was finalized within a second of its queue's end.
#[test]
fn each_network_s_unstaking_queue_is_what_its_history_shows() {
    assert_eq!((MAINNET_UNSTAKING_MS, TESTNET_UNSTAKING_MS), (7 * DAY, 300_000));
    for network in Network::ALL {
        let history = recorded(network).delegator_history;
        let spans = queued(&history);
        assert!(!spans.is_empty(), "{network}");
        for (initiated, finalized) in spans {
            let ready = initiated + unstaking_ms(network);
            assert!(ready <= finalized && finalized - ready < 1_000, "{network}: {initiated} to {finalized}");
        }
    }
}

/// A day on mainnet; two minutes on testnet. Each live delegation's lock ends
/// exactly that long after the last delegation to it in the history.
#[test]
fn each_network_s_delegation_lock_is_what_its_delegations_show() {
    assert_eq!((MAINNET_DELEGATION_LOCK_MS, TESTNET_DELEGATION_LOCK_MS), (DAY, 120_000));
    for network in Network::ALL {
        let recorded = recorded(network);
        let mut checked = 0;
        for delegation in &recorded.delegations {
            let last = recorded.delegator_history.iter().find(|event| {
                matches!(&event.delta, StakeDelta::Delegate { validator, is_undelegate: false, .. }
                    if *validator == delegation.validator)
            });
            let Some(last) = last else { continue };
            assert_eq!(delegation.locked_until_timestamp, last.time + delegation_lock_ms(network), "{network}");
            assert_eq!(locked_until(network, last.time), delegation.locked_until_timestamp, "{network}");
            checked += 1;
        }
        assert!(checked > 0, "{network}: a delegation and its row are on file");
    }
}

#[test]
fn a_withdrawal_is_pending_from_its_start_until_its_queue_ends() {
    let mainnet = recorded(Network::Mainnet).delegator_history;
    let started = 1_787_996_509_903;
    let pending = pending_withdrawals(Network::Mainnet, &mainnet, started + 3 * DAY);
    assert_eq!(
        pending,
        [PendingWithdrawal { amount: dec("500000"), initiated: started, ready_at: started + 7 * DAY }],
        "four of seven days left"
    );
    assert_eq!(pending_withdrawals(Network::Mainnet, &mainnet, started - 1), [], "not yet asked for");
    assert_eq!(pending_withdrawals(Network::Mainnet, &mainnet, 1_788_601_310_048), [], "finalized");

    let testnet = recorded(Network::Testnet).delegator_history;
    let started = 1_788_524_534_578;
    let pending = pending_withdrawals(Network::Testnet, &testnet, started + 60_000);
    assert_eq!(pending.len(), 1);
    assert_eq!((pending[0].amount, pending[0].ready_at), (dec("10000"), started + 300_000));
    assert_eq!(pending_withdrawals(Network::Testnet, &testnet, 1_788_524_835_009), [], "finalized in five minutes");
    assert_eq!(
        pending_withdrawals(Network::Mainnet, &testnet, 1_788_524_835_009).len(),
        1,
        "mainnet's queue would still hold it"
    );
}

/// The month's rewards, summed exactly: thirty daily rows on mainnet, the
/// last three hourly rows on testnet.
#[test]
fn rewards_are_summed_exactly_over_a_window() {
    let mainnet = recorded(Network::Mainnet).delegator_rewards;
    let now = 1_791_450_000_000;
    assert_eq!(rewards_since(&mainnet, now - REWARDS_WINDOW_MS), Some(dec("909.24093562")));
    let testnet = recorded(Network::Testnet).delegator_rewards;
    assert_eq!(rewards_since(&testnet, 1_788_404_400_015), Some(dec("885.76419239")), "a row at the start counts");
    assert_eq!(rewards_since(&testnet, u64::MAX), Some(Dec::ZERO));
}

/// The summary, every delegation, the newest pending withdrawals the summary
/// counts, and a month's rewards.
#[test]
fn a_whole_staking_reads_from_four_answers() {
    let recorded = recorded(Network::Mainnet);
    let started = 1_787_996_509_903;
    let mut summary = recorded.delegator_summary.clone();
    summary.n_pending_withdrawals = 1;
    let staking = Staking::new(
        Network::Mainnet,
        summary,
        recorded.delegations.clone(),
        &recorded.delegator_rewards,
        &recorded.delegator_history,
        started + DAY,
    );
    assert_eq!(staking.pending.len(), 1);
    assert_eq!(staking.delegations.len(), 2);
    assert_eq!(staking.summary.delegated, dec("501216.46877001"));
    assert_eq!(staking.total(), Some(dec("501216.46877001")));
    let none = Staking::new(
        Network::Mainnet,
        recorded.delegator_summary.clone(),
        recorded.delegations.clone(),
        &recorded.delegator_rewards,
        &recorded.delegator_history,
        started + DAY,
    );
    assert!(none.pending.is_empty(), "the summary counts none, so none is pending");
    let delegated = recorded.delegations.iter().try_fold(Dec::ZERO, |sum, one| sum.checked_add(one.amount));
    assert_eq!(delegated, Some(recorded.delegator_summary.delegated), "the delegations add up to the summary");
}

#[test]
fn testnet_validators_read() {
    let validators = recorded(Network::Testnet).validator_summaries;
    assert_eq!(validators.len(), 2);
    let hypurr = validators.iter().find(|one| one.name == "Hypurr2").unwrap();
    assert_eq!(hypurr.validator, address("0x172054cfc01b32effe0bf6af7a15b36e1ad730b3"));
    assert_eq!(hypurr.stake_hype(), Some(dec("3210070.54148517")));
    assert_eq!(hypurr.stats("week").map(|week| week.predicted_apr), Some(dec("0.0329726982")));
    assert!(hypurr.is_active && !hypurr.is_jailed);
    let jailed = validators.iter().find(|one| one.name == "somebody").unwrap();
    assert!(jailed.is_jailed && !jailed.is_active && jailed.commission == Dec::ONE);
}

/// Answers each read from the recorded file by its `type`.
struct Recording(Value);

impl Transport for Recording {
    fn post(&self, _: &str, body: String) -> impl Future<Output = Result<String, TransportError>> {
        let request: Value = serde_json::from_str(&body).unwrap();
        let kind = request["type"].as_str().unwrap();
        assert_eq!(request["user"], "0x0594f60525fc6a5d05bb711fb6450f84fef307bd");
        ready(Ok(self.0[kind].to_string()))
    }
}

fn block_on<F: Future>(future: F) -> F::Output {
    let mut future = pin!(future);
    let mut context = Context::from_waker(Waker::noop());
    loop {
        if let Poll::Ready(value) = future.as_mut().poll(&mut context) {
            return value;
        }
    }
}

#[test]
fn the_four_reads_make_one_staking() {
    let transport = Recording(serde_json::from_str(TESTNET).unwrap());
    let user = address("0x0594f60525fc6a5d05bb711fb6450f84fef307bd");
    let staking = block_on(staking(&transport, Network::Testnet, user, 1_788_411_600_015)).unwrap();
    assert_eq!(staking.summary.delegated, dec("968879.07903711"));
    assert_eq!(staking.delegations[0].amount, dec("968879.07903711"));
    assert_eq!(staking.rewards, dec("2151.13385194"), "all six rows fall in the month");
    assert!(staking.pending.is_empty());
}