hypersdk 0.3.0

Rust SDK for Hyperliquid
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
//! Utility functions for HyperCore type serialization and signing.
//!
//! This module contains helper functions used by the types module for:
//! - Serialization of addresses, cloids, and U256 values as hex
//! - MessagePack (RMP) hashing for action signatures
//! - EIP-712 typed data generation
//! - Solidity struct definitions for EIP-712 signing

use alloy::{
    dyn_abi::{Eip712Types, PropertyDef, Resolver, TypedData},
    primitives::{Address, B256, U256, keccak256},
    sol_types::SolStruct,
};
use serde::{Deserialize, Deserializer, Serialize, Serializer};

use super::Cloid;
use crate::hypercore::Chain;

const HYPERLIQUID_EIP_PREFIX: &str = "HyperliquidTransaction:";

pub(super) fn serialize_agent_name<S: Serializer>(
    value: &Option<String>,
    serializer: S,
) -> Result<S::Ok, S::Error> {
    serializer.serialize_str(value.as_deref().unwrap_or_default())
}

/// Enrich the ordinary user-signed schema, preserving its field order.
pub(super) fn multisig_typed_data(
    mut data: TypedData,
    multi_sig_user: Address,
    lead: Address,
) -> TypedData {
    let mut types = Eip712Types::from(&data.resolver);
    let fields = types
        .get_mut(&data.primary_type)
        .expect("known action schema");
    let index = fields
        .iter()
        .position(|field| field.name() == "hyperliquidChain")
        .expect("user-signed actions include hyperliquidChain")
        + 1;
    fields.splice(
        index..index,
        [
            PropertyDef::new_unchecked("address", "payloadMultiSigUser"),
            PropertyDef::new_unchecked("address", "outerSigner"),
        ],
    );
    data.resolver = Resolver::from(types);
    data.message["payloadMultiSigUser"] =
        const_hex::encode_prefixed(multi_sig_user.as_slice()).into();
    data.message["outerSigner"] = const_hex::encode_prefixed(lead.as_slice()).into();
    data
}

/// Serde module for normalized decimal serialization.
///
/// Normalizes decimals by removing trailing zeros before serialization.
/// This matches the Python SDK's `float_to_wire` behavior which uses
/// `Decimal().normalize()` to ensure consistent MessagePack hashing.
///
/// Example: `dec!(10.0)` serializes as `"10"`, not `"10.0"`
pub(super) mod decimal_normalized {
    use std::str::FromStr;

    use rust_decimal::Decimal;
    use serde::{Deserialize, Deserializer, Serializer, de};

    pub fn serialize<S>(value: &Decimal, serializer: S) -> Result<S::Ok, S::Error>
    where
        S: Serializer,
    {
        // normalize() removes trailing zeros: 10.0 -> 10, 0.100 -> 0.1
        let normalized = value.normalize();
        serializer.serialize_str(&normalized.to_string())
    }

    pub fn deserialize<'de, D>(deserializer: D) -> Result<Decimal, D::Error>
    where
        D: Deserializer<'de>,
    {
        let s = String::deserialize(deserializer)?;
        Decimal::from_str(&s)
            .map(|d| d.normalize())
            .map_err(de::Error::custom)
    }
}

/// [`decimal_normalized`] for an optional decimal. `None` serializes as `null`.
pub(super) mod decimal_normalized_option {
    use rust_decimal::Decimal;
    use serde::{Deserialize, Deserializer, Serializer};

    pub fn serialize<S>(value: &Option<Decimal>, serializer: S) -> Result<S::Ok, S::Error>
    where
        S: Serializer,
    {
        match value {
            Some(value) => super::decimal_normalized::serialize(value, serializer),
            None => serializer.serialize_none(),
        }
    }

    pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<Decimal>, D::Error>
    where
        D: Deserializer<'de>,
    {
        #[derive(Deserialize)]
        struct Normalized(#[serde(with = "super::decimal_normalized")] Decimal);

        Ok(Option::<Normalized>::deserialize(deserializer)?.map(|Normalized(d)| d))
    }
}

/// Serde module for `OidOrCloid` that ensures the `Right(Cloid)` variant is always
/// serialized as a hex string (consistent across both JSON and MessagePack formats).
///
/// `either::serde_untagged` delegates to `B128`'s default impl, which uses raw bytes
/// in non-human-readable formats like MessagePack. Hyperliquid's server reconstructs
/// the hash from the JSON representation (hex string), so both sides must agree on
/// hex-string encoding for the hash to verify correctly.
pub(super) mod oid_or_cloid {
    use either::Either;
    use serde::{Deserializer, Serializer, de};

    use super::Cloid;

    pub fn serialize<S>(value: &Either<u64, Cloid>, serializer: S) -> Result<S::Ok, S::Error>
    where
        S: Serializer,
    {
        match value {
            Either::Left(oid) => serializer.serialize_u64(*oid),
            Either::Right(cloid) => serializer.serialize_str(&format!("{:#x}", cloid)),
        }
    }

    pub fn deserialize<'de, D>(deserializer: D) -> Result<Either<u64, Cloid>, D::Error>
    where
        D: Deserializer<'de>,
    {
        struct Visitor;

        impl<'de> serde::de::Visitor<'de> for Visitor {
            type Value = Either<u64, Cloid>;

            fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
                f.write_str("a u64 oid or a hex string cloid")
            }

            fn visit_u64<E: de::Error>(self, v: u64) -> Result<Self::Value, E> {
                Ok(Either::Left(v))
            }

            fn visit_str<E: de::Error>(self, v: &str) -> Result<Self::Value, E> {
                v.parse::<Cloid>()
                    .map(Either::Right)
                    .map_err(de::Error::custom)
            }
        }

        deserializer.deserialize_any(Visitor)
    }
}

/// Serializes a cloid (B128) as a hex string.
pub(super) fn serialize_cloid_as_hex<S>(value: &Cloid, serializer: S) -> Result<S::Ok, S::Error>
where
    S: Serializer,
{
    serializer.serialize_str(&format!("{:#x}", value))
}

/// Deserializes a cloid (B128) from a hex string.
pub(super) fn deserialize_cloid_from_hex<'de, D>(deserializer: D) -> Result<Cloid, D::Error>
where
    D: Deserializer<'de>,
{
    let s = String::deserialize(deserializer)?;
    s.parse::<Cloid>().map_err(serde::de::Error::custom)
}

/// Returns `true` if the cloid is zero (used for `skip_serializing_if`).
///
/// When the cloid is zero/default, the field is omitted from serialization
/// to match the Python SDK and server-side hashing behavior.
pub(super) fn is_cloid_zero(value: &Cloid) -> bool {
    value.is_zero()
}

/// Serializes a non-zero cloid as a hex string (same as `serialize_cloid_as_hex`).
///
/// This is used in combination with `skip_serializing_if = "is_cloid_zero"` so this
/// function is only called for non-zero cloids.
pub(super) fn serialize_cloid_option<S>(value: &Cloid, serializer: S) -> Result<S::Ok, S::Error>
where
    S: Serializer,
{
    serialize_cloid_as_hex(value, serializer)
}

/// Deserializes a cloid from a hex string, defaulting to zero if absent.
pub(super) fn deserialize_cloid_option<'de, D>(deserializer: D) -> Result<Cloid, D::Error>
where
    D: Deserializer<'de>,
{
    deserialize_cloid_from_hex(deserializer)
}

/// Serializes an address as a hex string.
pub(super) fn serialize_address_as_hex<S>(value: &Address, serializer: S) -> Result<S::Ok, S::Error>
where
    S: Serializer,
{
    serializer.serialize_str(&format!("{:#x}", value))
}

/// Serializes an optional address as a lowercase hex string, or `null` when absent.
pub(super) fn serialize_option_address_as_hex<S>(
    value: &Option<Address>,
    serializer: S,
) -> Result<S::Ok, S::Error>
where
    S: Serializer,
{
    match value {
        Some(address) => serialize_address_as_hex(address, serializer),
        None => serializer.serialize_none(),
    }
}

/// Serializes `(address, value)` pairs with each address as a lowercase hex string.
pub(super) fn serialize_address_pairs_as_hex<S, T>(
    pairs: &[(Address, T)],
    serializer: S,
) -> Result<S::Ok, S::Error>
where
    S: Serializer,
    T: Serialize,
{
    serializer.collect_seq(
        pairs
            .iter()
            .map(|(address, value)| (format!("{address:#x}"), value)),
    )
}

/// [`serialize_address_pairs_as_hex`] for an optional list. Pair it with
/// `skip_serializing_if = "Option::is_none"`.
pub(super) fn serialize_option_address_pairs_as_hex<S, T>(
    pairs: &Option<Vec<(Address, T)>>,
    serializer: S,
) -> Result<S::Ok, S::Error>
where
    S: Serializer,
    T: Serialize,
{
    match pairs {
        Some(pairs) => serialize_address_pairs_as_hex(pairs, serializer),
        None => serializer.serialize_none(),
    }
}

/// Deserializes an address from a hex string.
pub(super) fn deserialize_address_from_hex<'de, D>(deserializer: D) -> Result<Address, D::Error>
where
    D: Deserializer<'de>,
{
    let s = String::deserialize(deserializer)?;
    s.parse::<Address>().map_err(serde::de::Error::custom)
}

/// Serializes a U256 value as a hex string.
pub(super) fn serialize_as_hex<S>(value: &U256, serializer: S) -> Result<S::Ok, S::Error>
where
    S: Serializer,
{
    serializer.serialize_str(&format!("{:#x}", value))
}

/// Serializes SignersConfig as a JSON string, or "null" if authorized_users is empty.
///
/// When converting a multisig user back to a normal user, the signers field should be "null".
pub(super) fn serialize_signers_as_json<S>(
    value: &super::types::api::SignersConfig,
    serializer: S,
) -> Result<S::Ok, S::Error>
where
    S: Serializer,
{
    if value.authorized_users.is_empty() {
        serializer.serialize_str("null")
    } else {
        let json = serde_json::to_string(value).map_err(serde::ser::Error::custom)?;
        serializer.serialize_str(&json)
    }
}

pub(super) fn deserialize_signers_as_json<'de, D>(
    deserializer: D,
) -> Result<super::types::api::SignersConfig, D::Error>
where
    D: Deserializer<'de>,
{
    let s = String::deserialize(deserializer)?;
    if s == "null" {
        Ok(Default::default())
    } else {
        let data = serde_json::from_str(&s).map_err(serde::de::Error::custom)?;
        Ok(data)
    }
}

/// Deserializes a U256 value from a hex string.
pub(super) fn deserialize_from_hex<'de, D>(deserializer: D) -> Result<U256, D::Error>
where
    D: Deserializer<'de>,
{
    let s = String::deserialize(deserializer)?;
    let s = s.strip_prefix("0x").unwrap_or(&s);
    U256::from_str_radix(s, 16).map_err(serde::de::Error::custom)
}

/// Computes the RMP (MessagePack) hash of a value for signing.
///
/// This function serializes the value to MessagePack format, appends the nonce,
/// optional vault address, and optional expiry, then computes the Keccak256 hash.
///
/// # Arguments
///
/// * `value` - The value to hash (typically an Action)
/// * `nonce` - The nonce to append
/// * `maybe_vault_address` - Optional vault address for vault trading
/// * `maybe_expires_after` - Optional expiry timestamp in milliseconds
///
/// # Returns
///
/// The Keccak256 hash as a B256, or an error if serialization fails.
pub(super) fn rmp_hash<T: Serialize>(
    value: &T,
    nonce: u64,
    maybe_vault_address: Option<Address>,
    maybe_expires_after: Option<u64>,
) -> Result<B256, rmp_serde::encode::Error> {
    let mut bytes = rmp_serde::to_vec_named(value)?;
    bytes.extend(nonce.to_be_bytes());

    if let Some(vault_address) = maybe_vault_address {
        bytes.push(1);
        bytes.extend(vault_address.as_slice());
    } else {
        bytes.push(0);
    }

    if let Some(expires_after) = maybe_expires_after {
        bytes.push(0);
        bytes.extend(expires_after.to_be_bytes());
    }

    let signature = keccak256(bytes);
    Ok(B256::from(signature))
}

/// Returns the EIP-712 typed data for a message.
///
/// This function creates the TypedData structure required for EIP-712 signing,
/// including the domain, types, and message data.
///
/// # Arguments
///
/// * `msg` - The message to create typed data for
/// * `multi_sig` - Optional multisig information (multisig user address, outer signer address)
///
/// # Returns
///
/// A TypedData structure ready for EIP-712 signing.
///
/// # Type Parameters
///
/// * `T` - The Solidity struct type that defines the message structure
pub(super) fn get_typed_data<T: SolStruct>(
    msg: &impl Serialize,
    chain: Chain,
    multi_sig: Option<(Address, Address)>,
) -> TypedData {
    let mut resolver = Resolver::from_struct::<T>();
    resolver
        .ingest_string(T::eip712_encode_type())
        .expect("failed to ingest EIP-712 type");

    let mut types = Eip712Types::from(&resolver);
    let agent_type = types.remove(T::NAME).unwrap();

    let mut msg = serde_json::to_value(msg).unwrap();
    if let Some((multi_sig_address, lead)) = multi_sig {
        let obj = msg.as_object_mut().unwrap();
        obj.insert(
            "payloadMultiSigUser".into(),
            multi_sig_address.to_string().to_lowercase().into(),
        );
        obj.insert("outerSigner".into(), lead.to_string().to_lowercase().into());
    }

    types.insert(format!("{HYPERLIQUID_EIP_PREFIX}{}", T::NAME), agent_type);

    TypedData {
        domain: chain.domain(),
        resolver: Resolver::from(types),
        primary_type: format!("{HYPERLIQUID_EIP_PREFIX}{}", T::NAME),
        message: msg,
    }
}