diff --git a/Cargo.toml b/Cargo.toml index d29e294d..23429590 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,9 +13,9 @@ repository = "https://github.com/hyperliquid-dex/hyperliquid-rust-sdk" [dependencies] alloy = { version = "1.0", default-features = false, features = [ - "dyn-abi", - "sol-types", - "signer-local", + "dyn-abi", + "sol-types", + "signer-local", ] } chrono = "0.4.26" env_logger = "0.11.8" @@ -24,9 +24,9 @@ lazy_static = "1.0" log = "0.4.19" reqwest = "0.12.19" serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" +serde_json = { version = "1.0", features = ["preserve_order"] } rmp-serde = "1.0" thiserror = "2.0" tokio = { version = "1.0", features = ["full"] } -tokio-tungstenite = { version = "0.20.0", features = ["native-tls"] } +tokio-tungstenite = { version = "0.28.0", features = ["native-tls"] } uuid = { version = "1.0", features = ["v4"] } diff --git a/src/bin/agent.rs b/src/bin/agent.rs index 7fdcbe5e..40d0e157 100644 --- a/src/bin/agent.rs +++ b/src/bin/agent.rs @@ -1,5 +1,7 @@ use alloy::signers::local::PrivateKeySigner; -use hyperliquid_rust_sdk::{BaseUrl, ClientLimit, ClientOrder, ClientOrderRequest, ExchangeClient}; +use hyperliquid_rust_sdk::{ + ClientLimit, ClientOrder, ClientOrderRequest, ExchangeClient, HyperliquidChain, +}; use log::info; #[tokio::main] @@ -11,9 +13,10 @@ async fn main() { .parse() .unwrap(); - let exchange_client = ExchangeClient::new(None, wallet, Some(BaseUrl::Testnet), None, None) - .await - .unwrap(); + let exchange_client = + ExchangeClient::new(None, wallet, Some(HyperliquidChain::Testnet), None, None) + .await + .unwrap(); /* Create a new wallet with the agent. @@ -27,9 +30,10 @@ async fn main() { info!("Agent address: {:?}", wallet.address()); - let exchange_client = ExchangeClient::new(None, wallet, Some(BaseUrl::Testnet), None, None) - .await - .unwrap(); + let exchange_client = + ExchangeClient::new(None, wallet, Some(HyperliquidChain::Testnet), None, None) + .await + .unwrap(); let order = ClientOrderRequest { asset: "ETH".to_string(), diff --git a/src/bin/approve_builder_fee.rs b/src/bin/approve_builder_fee.rs index 91ab1d5e..cced988d 100644 --- a/src/bin/approve_builder_fee.rs +++ b/src/bin/approve_builder_fee.rs @@ -1,5 +1,5 @@ use alloy::{primitives::address, signers::local::PrivateKeySigner}; -use hyperliquid_rust_sdk::{BaseUrl, ExchangeClient}; +use hyperliquid_rust_sdk::{ExchangeClient, HyperliquidChain}; use log::info; #[tokio::main] @@ -11,10 +11,15 @@ async fn main() { .parse() .unwrap(); - let exchange_client = - ExchangeClient::new(None, wallet.clone(), Some(BaseUrl::Testnet), None, None) - .await - .unwrap(); + let exchange_client = ExchangeClient::new( + None, + wallet.clone(), + Some(HyperliquidChain::Testnet), + None, + None, + ) + .await + .unwrap(); let max_fee_rate = "0.1%"; let builder = address!("0x1ab189B7801140900C711E458212F9c76F8dAC79"); diff --git a/src/bin/bridge_withdraw.rs b/src/bin/bridge_withdraw.rs index 5bdb3f65..b13dd672 100644 --- a/src/bin/bridge_withdraw.rs +++ b/src/bin/bridge_withdraw.rs @@ -1,5 +1,5 @@ use alloy::signers::local::PrivateKeySigner; -use hyperliquid_rust_sdk::{BaseUrl, ExchangeClient}; +use hyperliquid_rust_sdk::{ExchangeClient, HyperliquidChain}; use log::info; #[tokio::main] @@ -11,9 +11,10 @@ async fn main() { .parse() .unwrap(); - let exchange_client = ExchangeClient::new(None, wallet, Some(BaseUrl::Testnet), None, None) - .await - .unwrap(); + let exchange_client = + ExchangeClient::new(None, wallet, Some(HyperliquidChain::Testnet), None, None) + .await + .unwrap(); let usd = "5"; // 5 USD let destination = "0x0D1d9635D0640821d15e323ac8AdADfA9c111414"; diff --git a/src/bin/claim_rewards.rs b/src/bin/claim_rewards.rs index 5f0bb839..901328e5 100644 --- a/src/bin/claim_rewards.rs +++ b/src/bin/claim_rewards.rs @@ -1,5 +1,5 @@ use alloy::signers::local::PrivateKeySigner; -use hyperliquid_rust_sdk::{BaseUrl, ExchangeClient, ExchangeResponseStatus}; +use hyperliquid_rust_sdk::{ExchangeClient, ExchangeResponseStatus, HyperliquidChain}; use log::info; #[tokio::main] @@ -11,9 +11,10 @@ async fn main() { .parse() .unwrap(); - let exchange_client = ExchangeClient::new(None, wallet, Some(BaseUrl::Testnet), None, None) - .await - .unwrap(); + let exchange_client = + ExchangeClient::new(None, wallet, Some(HyperliquidChain::Testnet), None, None) + .await + .unwrap(); let response = exchange_client.claim_rewards(None).await.unwrap(); diff --git a/src/bin/class_transfer.rs b/src/bin/class_transfer.rs index 33b5d41e..5d066cc5 100644 --- a/src/bin/class_transfer.rs +++ b/src/bin/class_transfer.rs @@ -1,5 +1,5 @@ use alloy::signers::local::PrivateKeySigner; -use hyperliquid_rust_sdk::{BaseUrl, ExchangeClient}; +use hyperliquid_rust_sdk::{ExchangeClient, HyperliquidChain}; use log::info; #[tokio::main] @@ -11,15 +11,16 @@ async fn main() { .parse() .unwrap(); - let exchange_client = ExchangeClient::new(None, wallet, Some(BaseUrl::Testnet), None, None) - .await - .unwrap(); + let exchange_client = + ExchangeClient::new(None, wallet, Some(HyperliquidChain::Testnet), None, None) + .await + .unwrap(); let usdc = 1.0; // 1 USD let to_perp = false; let res = exchange_client - .class_transfer(usdc, to_perp, None) + .usd_class_transfer(usdc, to_perp, None) .await .unwrap(); info!("Class transfer result: {res:?}"); diff --git a/src/bin/convert_to_multi_sig_user.rs b/src/bin/convert_to_multi_sig_user.rs new file mode 100644 index 00000000..73b7993a --- /dev/null +++ b/src/bin/convert_to_multi_sig_user.rs @@ -0,0 +1,102 @@ +use alloy::{primitives::Address, signers::local::PrivateKeySigner}; +use hyperliquid_rust_sdk::{ExchangeClient, HyperliquidChain}; +use log::info; + +async fn setup_exchange_client() -> (Address, ExchangeClient) { + // Key was randomly generated for testing and shouldn't be used with any real funds + let wallet: PrivateKeySigner = + "e908f86dbb4d55ac876378565aafeabc187f6690f046459397b17d9b9a19688e" + .parse() + .unwrap(); + + let address = wallet.address(); + let exchange_client = + ExchangeClient::new(None, wallet, Some(HyperliquidChain::Testnet), None, None) + .await + .unwrap(); + + (address, exchange_client) +} + +#[tokio::main] +async fn main() { + env_logger::init(); + + let (address, exchange_client) = setup_exchange_client().await; + + // Ensure we're using the actual user's wallet, not an agent + if address != exchange_client.wallet.address() { + panic!("Agents do not have permission to convert to multi-sig user"); + } + + // Addresses that will be authorized to sign for the multi-sig account + let authorized_user_1: Address = "0x0000000000000000000000000000000000000000" + .parse() + .unwrap(); + let authorized_user_2: Address = "0x0000000000000000000000000000000000000001" + .parse() + .unwrap(); + + // Threshold: minimum number of signatures required to execute any transaction + // This matches the Python example where threshold is 1 + let threshold = 1; + + info!("=== Convert to Multi-Sig User Example ==="); + info!("Current user address: {}", address); + info!("Connected to: {:?}", exchange_client.http_client.chain); + info!(""); + info!("Configuration:"); + info!(" Authorized user 1: {}", authorized_user_1); + info!(" Authorized user 2: {}", authorized_user_2); + info!(" Threshold: {}", threshold); + info!(""); + + // Step 1: Convert the user to a multi-sig account + info!("Step 1: Converting to multi-sig account..."); + match exchange_client.convert_to_multi_sig(threshold, None).await { + Ok(response) => { + info!("Convert to multi-sig response: {:?}", response); + info!("Successfully converted to multi-sig!"); + } + Err(e) => { + info!("Convert to multi-sig failed (this is expected if already converted or on testnet): {}", e); + } + } + + // Step 2: Add authorized addresses + info!("Step 2: Adding authorized addresses..."); + match exchange_client + .update_multi_sig_addresses( + vec![authorized_user_1, authorized_user_2], + vec![], // No addresses to remove + None, + ) + .await + { + Ok(response) => { + info!("Update multi-sig addresses response: {:?}", response); + info!("Successfully added authorized addresses!"); + } + Err(e) => { + info!("Update multi-sig addresses failed: {}", e); + } + } + + info!(""); + info!("Multi-sig setup complete!"); + info!("Now you can use the multi-sig methods with the authorized wallets:"); + info!("- multi_sig_order()"); + info!("- multi_sig_usdc_transfer()"); + info!("- multi_sig_spot_transfer()"); + info!(""); + info!("IMPORTANT: After converting to multi-sig:"); + info!("1. The account can only be controlled by the authorized addresses"); + info!( + "2. You need {} signatures to execute any transaction", + threshold + ); + info!("3. Make sure you have access to the authorized private keys!"); + info!("4. This is a one-way conversion - test on testnet first!"); + + info!("Example completed - multi-sig conversion functionality demonstrated"); +} diff --git a/src/bin/info.rs b/src/bin/info.rs index f6df1178..8c96de8e 100644 --- a/src/bin/info.rs +++ b/src/bin/info.rs @@ -1,5 +1,5 @@ use alloy::primitives::Address; -use hyperliquid_rust_sdk::{BaseUrl, InfoClient}; +use hyperliquid_rust_sdk::{HyperliquidChain, InfoClient}; use log::info; const ADDRESS: &str = "0xc64cc00b46101bd40aa1c3121195e85c0b0918d8"; @@ -7,7 +7,9 @@ const ADDRESS: &str = "0xc64cc00b46101bd40aa1c3121195e85c0b0918d8"; #[tokio::main] async fn main() { env_logger::init(); - let info_client = InfoClient::new(None, Some(BaseUrl::Testnet)).await.unwrap(); + let info_client = InfoClient::new(None, Some(HyperliquidChain::Testnet)) + .await + .unwrap(); open_orders_example(&info_client).await; user_state_example(&info_client).await; user_states_example(&info_client).await; diff --git a/src/bin/leverage.rs b/src/bin/leverage.rs index 78705216..f876526c 100644 --- a/src/bin/leverage.rs +++ b/src/bin/leverage.rs @@ -1,5 +1,5 @@ use alloy::signers::local::PrivateKeySigner; -use hyperliquid_rust_sdk::{BaseUrl, ExchangeClient, InfoClient}; +use hyperliquid_rust_sdk::{ExchangeClient, HyperliquidChain, InfoClient}; use log::info; #[tokio::main] @@ -13,10 +13,13 @@ async fn main() { .unwrap(); let address = wallet.address(); - let exchange_client = ExchangeClient::new(None, wallet, Some(BaseUrl::Testnet), None, None) + let exchange_client = + ExchangeClient::new(None, wallet, Some(HyperliquidChain::Testnet), None, None) + .await + .unwrap(); + let info_client = InfoClient::new(None, Some(HyperliquidChain::Testnet)) .await .unwrap(); - let info_client = InfoClient::new(None, Some(BaseUrl::Testnet)).await.unwrap(); let response = exchange_client .update_leverage(5, "ETH", false, None) diff --git a/src/bin/market_order_and_cancel.rs b/src/bin/market_order_and_cancel.rs index e1160adf..277d0a3a 100644 --- a/src/bin/market_order_and_cancel.rs +++ b/src/bin/market_order_and_cancel.rs @@ -2,8 +2,8 @@ use std::{thread::sleep, time::Duration}; use alloy::signers::local::PrivateKeySigner; use hyperliquid_rust_sdk::{ - BaseUrl, ExchangeClient, ExchangeDataStatus, ExchangeResponseStatus, MarketCloseParams, - MarketOrderParams, + ExchangeClient, ExchangeDataStatus, ExchangeResponseStatus, HyperliquidChain, + MarketCloseParams, MarketOrderParams, }; use log::info; @@ -16,9 +16,10 @@ async fn main() { .parse() .unwrap(); - let exchange_client = ExchangeClient::new(None, wallet, Some(BaseUrl::Testnet), None, None) - .await - .unwrap(); + let exchange_client = + ExchangeClient::new(None, wallet, Some(HyperliquidChain::Testnet), None, None) + .await + .unwrap(); // Market open order let market_open_params = MarketOrderParams { diff --git a/src/bin/market_order_with_builder_and_cancel.rs b/src/bin/market_order_with_builder_and_cancel.rs index ffa46f50..2e3e75f0 100644 --- a/src/bin/market_order_with_builder_and_cancel.rs +++ b/src/bin/market_order_with_builder_and_cancel.rs @@ -2,7 +2,7 @@ use std::{thread::sleep, time::Duration}; use alloy::signers::local::PrivateKeySigner; use hyperliquid_rust_sdk::{ - BaseUrl, BuilderInfo, ExchangeClient, ExchangeDataStatus, ExchangeResponseStatus, + BuilderInfo, ExchangeClient, ExchangeDataStatus, ExchangeResponseStatus, HyperliquidChain, MarketCloseParams, MarketOrderParams, }; use log::info; @@ -16,9 +16,10 @@ async fn main() { .parse() .unwrap(); - let exchange_client = ExchangeClient::new(None, wallet, Some(BaseUrl::Testnet), None, None) - .await - .unwrap(); + let exchange_client = + ExchangeClient::new(None, wallet, Some(HyperliquidChain::Testnet), None, None) + .await + .unwrap(); // Market open order let market_open_params = MarketOrderParams { diff --git a/src/bin/multi_sig_order.rs b/src/bin/multi_sig_order.rs new file mode 100644 index 00000000..561eb053 --- /dev/null +++ b/src/bin/multi_sig_order.rs @@ -0,0 +1,125 @@ +use alloy::{primitives::Address, signers::local::PrivateKeySigner}; +use hyperliquid_rust_sdk::{ + ClientLimit, ClientOrder, ClientOrderRequest, ExchangeClient, HyperliquidChain, +}; +use log::info; + +fn setup_multi_sig_wallets() -> Vec { + // These are example private keys - in production, these would be the authorized + // user wallets that have permission to sign for the multi-sig account + let wallets = vec![ + "0x1234567890123456789012345678901234567890123456789012345678901234", + "0x2345678901234567890123456789012345678901234567890123456789012345", + "0x3456789012345678901234567890123456789012345678901234567890123456", + ]; + + wallets + .into_iter() + .map(|key| key.parse().unwrap()) + .collect() +} + +async fn setup_exchange_client() -> (Address, ExchangeClient) { + // Key was randomly generated for testing and shouldn't be used with any real funds + let wallet: PrivateKeySigner = + "e908f86dbb4d55ac876378565aafeabc187f6690f046459397b17d9b9a19688e" + .parse() + .unwrap(); + + let address = wallet.address(); + let exchange_client = + ExchangeClient::new(None, wallet, Some(HyperliquidChain::Testnet), None, None) + .await + .unwrap(); + + (address, exchange_client) +} + +#[tokio::main] +async fn main() { + env_logger::init(); + + let (address, exchange_client) = setup_exchange_client().await; + + // Set up the multi-sig wallets that are authorized to sign for the multi-sig user + // Each wallet must belong to a user that has been added as an authorized signer + let multi_sig_wallets = setup_multi_sig_wallets(); + + // The outer signer is required to be an authorized user or an agent of the + // authorized user of the multi-sig user. + + // Address of the multi-sig user that the action will be executed for + // Executing the action requires at least the specified threshold of signatures + // required for that multi-sig user + let multi_sig_user: Address = "0x0000000000000000000000000000000000000005" + .parse() + .unwrap(); + + info!("=== Multi-Sig Order Example ==="); + info!("Multi-sig user address: {}", multi_sig_user); + info!("Outer signer (current wallet): {}", address); + info!( + "Exchange client connected to: {:?}", + exchange_client.http_client.chain + ); + info!( + "Authorized wallets ({} total): {:?}", + multi_sig_wallets.len(), + multi_sig_wallets + .iter() + .map(|w| w.address()) + .collect::>() + ); + + // Define the multi-sig inner action - in this case, placing an order + // This matches the Python example: asset index 4, buy, price 1100, size 0.2 + let order = ClientOrderRequest { + asset: "ETH".to_string(), // Asset index 4 in Python corresponds to ETH + is_buy: true, + reduce_only: false, + limit_px: 1100.0, + sz: 0.2, + cloid: None, + order_type: ClientOrder::Limit(ClientLimit { + tif: "Gtc".to_string(), + }), + }; + + info!(""); + info!("Order details: {:?}", order); + info!("Executing multi-sig order..."); + info!( + "Collecting signatures from {} authorized wallets...", + multi_sig_wallets.len() + ); + + // Execute the multi-sig order + // This will collect signatures from all provided wallets and submit them together + // The action will only succeed if enough valid signatures are provided (>= threshold) + match exchange_client + .multi_sig_order(multi_sig_user, order, &multi_sig_wallets) + .await + { + Ok(response) => { + info!("✓ Multi-sig order placed successfully!"); + info!("Response: {:?}", response); + } + Err(e) => { + info!("✗ Multi-sig order failed: {}", e); + info!(""); + info!("This is expected if:"); + info!(" • The multi-sig user is not properly configured"); + info!(" • The provided wallets are not authorized signers"); + info!(" • Not enough signatures provided to meet threshold"); + info!(""); + info!("To use in production:"); + info!(" 1. Convert a user to multi-sig: convert_to_multi_sig()"); + info!(" 2. Add authorized addresses: update_multi_sig_addresses()"); + info!(" 3. Use those authorized wallets to sign transactions"); + info!(" 4. Ensure you provide >= threshold number of valid signatures"); + } + } + + info!(""); + info!("Example completed"); +} diff --git a/src/bin/multi_sig_order_signature_collection.rs b/src/bin/multi_sig_order_signature_collection.rs new file mode 100644 index 00000000..3862dd02 --- /dev/null +++ b/src/bin/multi_sig_order_signature_collection.rs @@ -0,0 +1,152 @@ +/// Example: Multi-sig order placement with signature collection workflow +/// +/// This demonstrates how to collect signatures for L1 actions (orders) +/// where each signer creates their signature independently. +/// +/// Usage: +/// cargo run --bin multi_sig_order_signature_collection +use alloy::signers::{local::PrivateKeySigner, Signature}; +use hyperliquid_rust_sdk::sign_multi_sig_l1_action_single; +use log::info; +use std::str::FromStr; + +type Result = std::result::Result>; + +fn main() -> Result<()> { + env_logger::init(); + + info!("=== Multi-Sig Order Signature Collection Demo ===\n"); + + demonstrate_order_signature_collection()?; + + Ok(()) +} + +fn demonstrate_order_signature_collection() -> Result<()> { + // Setup: Define the multi-sig parameters + let multi_sig_user = + alloy::primitives::Address::from_str("0x0000000000000000000000000000000000000005")?; + let outer_signer = + alloy::primitives::Address::from_str("0x0d1d9635d0640821d15e323ac8adadfa9c111414")?; + let nonce = 1234567890u64; + + info!("Multi-sig parameters:"); + info!(" Multi-sig user: {}", multi_sig_user); + info!(" Outer signer: {}", outer_signer); + info!(" Nonce: {}\n", nonce); + + // Create the order action + // All signers must create the exact same action + let action = serde_json::from_value(serde_json::json!({ + "type": "order", + "orders": [{ + "a": 0, // asset index (0 = BTC) + "b": true, // is_buy + "p": "30000", // limit price + "s": "0.1", // size + "r": false, // reduce_only + "t": {"limit": {"tif": "Gtc"}} + }], + "grouping": "na" + })) + .unwrap(); + + info!("Order action:"); + info!("{}\n", serde_json::to_string_pretty(&action)?); + + // Step 1: Each signer creates their signature independently + info!("Step 1: Each signer creates their signature\n"); + + let signer1_wallet = "0xe908f86dbb4d55ac876378565aafeabc187f6690f046459397b17d9b9a19688e" + .parse::()?; + let signer2_wallet = "0x0000000000000000000000000000000000000000000000000000000000000001" + .parse::()?; + + info!("Signer 1 address: {}", signer1_wallet.address()); + info!("Signer 2 address: {}", signer2_wallet.address()); + + // Signer 1 signs the L1 action + let sig1 = sign_multi_sig_l1_action_single( + &signer1_wallet, + &action, + multi_sig_user, + outer_signer, + None, // vault_address + nonce, + None, // expires_after + false, // is_mainnet = false (testnet) + )?; + info!("\nSigner 1 signature: {}", sig1); + + // Signer 2 signs the L1 action + let sig2 = sign_multi_sig_l1_action_single( + &signer2_wallet, + &action, + multi_sig_user, + outer_signer, + None, + nonce, + None, + false, + )?; + info!("Signer 2 signature: {}", sig2); + + // Step 2: Signatures are serialized and transmitted + info!("\nStep 2: Signatures are exported for transmission\n"); + + let sig1_string = sig1.to_string(); + let sig2_string = sig2.to_string(); + + info!("Sig 1 exported: {}", sig1_string); + info!("Sig 2 exported: {}", sig2_string); + + // Step 3: Submitter collects and imports signatures + info!("\nStep 3: Submitter collects signatures\n"); + + let collected_signatures = [sig1_string, sig2_string]; + info!("Collected {} signatures", collected_signatures.len()); + + // Import signatures + let signatures: Vec = collected_signatures + .iter() + .map(|s| s.parse().expect("Failed to import signature")) + .collect(); + + info!("Successfully imported {} signatures", signatures.len()); + + // Step 4: Show how to submit (commented out to avoid actual submission) + info!("\nStep 4: Submit order (example - not executed)\n"); + + info!("To submit, the outer signer would run:"); + info!("```rust"); + info!("let submitter_wallet = \"YOUR_KEY\".parse::()?;"); + info!("let sdk = ExchangeClient::new(submitter_wallet, Some(BaseUrl::Testnet), None).await?;"); + info!(""); + info!("let order = ClientOrderRequest {{"); + info!(" asset: \"BTC\".to_string(),"); + info!(" is_buy: true,"); + info!(" reduce_only: false,"); + info!(" limit_px: 30000.0,"); + info!(" sz: 0.1,"); + info!(" order_type: ClientOrderType::Limit(ClientLimit {{"); + info!(" tif: \"Gtc\".to_string(),"); + info!(" }}),"); + info!(" cloid: None,"); + info!("}};"); + info!(""); + info!("sdk.multi_sig_order_with_signatures("); + info!(" multi_sig_user,"); + info!(" order,"); + info!(" signatures,"); + info!(").await?;"); + info!("```"); + + info!("\n=== Demo Complete ==="); + info!("\nKey differences for L1 actions (orders):"); + info!("1. Use sign_multi_sig_l1_action_single instead of sign_multi_sig_user_signed_action_single"); + info!("2. Sign the JSON action directly (type + orders/cancels/etc)"); + info!("3. Must specify vault_address and expires_after parameters"); + info!("4. Network parameter (is_mainnet) affects the signature"); + + Ok(()) +} diff --git a/src/bin/multi_sig_register_token.rs b/src/bin/multi_sig_register_token.rs new file mode 100644 index 00000000..4cc73e60 --- /dev/null +++ b/src/bin/multi_sig_register_token.rs @@ -0,0 +1,77 @@ +use alloy::{primitives::Address, signers::local::PrivateKeySigner}; +use hyperliquid_rust_sdk::{ExchangeClient, HyperliquidChain}; +use log::info; + +fn setup_multi_sig_wallets() -> Vec { + let wallets = vec![ + "0x1234567890123456789012345678901234567890123456789012345678901234", + "0x2345678901234567890123456789012345678901234567890123456789012345", + "0x3456789012345678901234567890123456789012345678901234567890123456", + ]; + + wallets + .into_iter() + .map(|key| key.parse().unwrap()) + .collect() +} + +async fn setup_exchange_client() -> (Address, ExchangeClient) { + // Key was randomly generated for testing and shouldn't be used with any real funds + let wallet: PrivateKeySigner = + "e908f86dbb4d55ac876378565aafeabc187f6690f046459397b17d9b9a19688e" + .parse() + .unwrap(); + + let address = wallet.address(); + let exchange_client = + ExchangeClient::new(None, wallet, Some(HyperliquidChain::Testnet), None, None) + .await + .unwrap(); + + (address, exchange_client) +} + +#[tokio::main] +async fn main() { + env_logger::init(); + + let (address, exchange_client) = setup_exchange_client().await; + + // The multi-sig user address (this would be the address that was converted to multi-sig) + let multi_sig_user: Address = "0x0000000000000000000000000000000000000005" + .parse() + .unwrap(); + + // Set up the multi-sig wallets that are authorized to sign for the multi-sig user + let multi_sig_wallets = setup_multi_sig_wallets(); + + info!("Multi-sig user: {}", multi_sig_user); + info!("Outer signer (current wallet): {}", address); + info!( + "Exchange client connected to: {:?}", + exchange_client.http_client.chain + ); + info!( + "Multi-sig wallets: {:?}", + multi_sig_wallets + .iter() + .map(|w| w.address()) + .collect::>() + ); + + info!("Multi-sig register token functionality requires custom action handling"); + info!("The spot token registration action (spotDeploy) is a complex action that:"); + info!("1. Requires specific permission levels"); + info!("2. Has custom parameters for token specification"); + info!("3. Is typically used for specialized spot market operations"); + info!(""); + info!("For multi-sig spot token registration, you would:"); + info!("1. Create a custom spotDeploy action with registerToken2 parameters"); + info!("2. Hash the action using the Actions::hash method"); + info!("3. Sign with multiple authorized wallets using sign_l1_action_multi_sig"); + info!("4. Submit using the post_multi_sig method"); + info!(""); + info!("This is an advanced operation - consult Hyperliquid documentation for details"); + + info!("Example completed - multi-sig register token requirements explained"); +} diff --git a/src/bin/multi_sig_signature_collection.rs b/src/bin/multi_sig_signature_collection.rs new file mode 100644 index 00000000..0f4edb9e --- /dev/null +++ b/src/bin/multi_sig_signature_collection.rs @@ -0,0 +1,154 @@ +/// Example: Multi-sig USDC transfer with signature collection workflow +/// +/// This demonstrates the recommended approach where signatures are collected +/// from different parties independently, rather than having all private keys +/// in one place. +/// +/// Usage: +/// cargo run --bin multi_sig_signature_collection +use alloy::signers::{local::PrivateKeySigner, Signature}; +use hyperliquid_rust_sdk::{ + sign_multi_sig_user_signed_action_single, MultiSigExtension, SendAsset, +}; +use log::info; +use std::str::FromStr; + +type Result = std::result::Result>; + +fn main() -> Result<()> { + env_logger::init(); + + info!("=== Multi-Sig Signature Collection Demo ===\n"); + + // Simulate the workflow + demonstrate_signature_collection()?; + + Ok(()) +} + +fn demonstrate_signature_collection() -> Result<()> { + // Setup: Define the multi-sig parameters + // In a real scenario, these would be coordinated among all parties + let multi_sig_user = + alloy::primitives::Address::from_str("0x0000000000000000000000000000000000000005")?; + let outer_signer = + alloy::primitives::Address::from_str("0x0d1d9635d0640821d15e323ac8adadfa9c111414")?; + let destination = "0x1234567890123456789012345678901234567890"; + let amount = "100"; + let nonce = 1234567890u64; + + info!("Multi-sig parameters:"); + info!(" Multi-sig user: {}", multi_sig_user); + info!(" Outer signer: {}", outer_signer); + info!(" Destination: {}", destination); + info!(" Amount: {}", amount); + info!(" Nonce: {}\n", nonce); + + // Step 1: Each signer creates their signature independently + info!("Step 1: Each signer creates their signature\n"); + + let signer1_wallet = "0xe908f86dbb4d55ac876378565aafeabc187f6690f046459397b17d9b9a19688e" + .parse::()?; + let signer2_wallet = "0x0000000000000000000000000000000000000000000000000000000000000001" + .parse::()?; + + info!("Signer 1 address: {}", signer1_wallet.address()); + info!("Signer 2 address: {}", signer2_wallet.address()); + + // Both signers create the same SendAsset action + let send_asset = create_send_asset(multi_sig_user, outer_signer, destination, amount, nonce); + + // Signer 1 signs + let sig1 = sign_multi_sig_user_signed_action_single(&signer1_wallet, &send_asset)?; + info!("\nSigner 1 signature: {}", sig1); + + // Signer 2 signs + let sig2 = sign_multi_sig_user_signed_action_single(&signer2_wallet, &send_asset)?; + info!("Signer 2 signature: {}", sig2); + + // Step 2: Signatures are serialized and transmitted + info!("\nStep 2: Signatures are exported for transmission\n"); + + let sig1_string = export_signature(&sig1); + let sig2_string = export_signature(&sig2); + + info!("Sig 1 exported: {}", sig1_string); + info!("Sig 2 exported: {}", sig2_string); + + // Step 3: Submitter collects and imports signatures + info!("\nStep 3: Submitter collects signatures\n"); + + let collected_signatures = [sig1_string, sig2_string]; + info!("Collected {} signatures", collected_signatures.len()); + + // Import signatures + let signatures: Vec = collected_signatures + .iter() + .map(|s| import_signature(s).expect("Failed to import signature")) + .collect(); + + info!("Successfully imported {} signatures", signatures.len()); + + // Step 4: Show how to submit (commented out to avoid actual submission) + info!("\nStep 4: Submit transaction (example - not executed)\n"); + + info!("To submit, the outer signer would run:"); + info!("```rust"); + info!("let submitter_wallet = \"YOUR_KEY\".parse::()?;"); + info!("let sdk = ExchangeClient::new(submitter_wallet, Some(BaseUrl::Testnet), None).await?;"); + info!(""); + info!("sdk.multi_sig_usdc_transfer_with_signatures("); + info!(" multi_sig_user,"); + info!(" \"{}\",", amount); + info!(" \"{}\",", destination); + info!(" signatures,"); + info!(").await?;"); + info!("```"); + + info!("\n=== Demo Complete ==="); + info!("\nKey takeaways:"); + info!("1. Each signer creates their signature independently"); + info!("2. Signatures can be serialized as hex strings for transmission"); + info!("3. The submitter collects and combines signatures"); + info!("4. All signers must sign identical action parameters"); + info!("5. The outer_signer (submitter) doesn't need to be a multi-sig participant"); + + Ok(()) +} + +/// Create a SendAsset action - must be identical for all signers +fn create_send_asset( + multi_sig_user: alloy::primitives::Address, + outer_signer: alloy::primitives::Address, + destination: &str, + amount: &str, + nonce: u64, +) -> SendAsset { + SendAsset { + signature_chain_id: 421614, + hyperliquid_chain: "Testnet".to_string(), + destination: destination.to_lowercase(), + source_dex: "".to_string(), + destination_dex: "".to_string(), + token: "USDC".to_string(), + amount: amount.to_string(), + from_sub_account: "".to_string(), + nonce, + multi_sig_ext: Some(MultiSigExtension { + payload_multi_sig_user: format!("{:#x}", multi_sig_user).to_lowercase(), + outer_signer: format!("{:#x}", outer_signer).to_lowercase(), + }), + } +} + +/// Export a signature as a hex string +fn export_signature(sig: &Signature) -> String { + sig.to_string() +} + +/// Import a signature from a hex string +fn import_signature(sig_str: &str) -> Result { + sig_str + .parse() + .map_err(|e| format!("Failed to parse signature: {:?}", e).into()) +} diff --git a/src/bin/multi_sig_usd_send.rs b/src/bin/multi_sig_usd_send.rs new file mode 100644 index 00000000..b4296367 --- /dev/null +++ b/src/bin/multi_sig_usd_send.rs @@ -0,0 +1,116 @@ +use alloy::{primitives::Address, signers::local::PrivateKeySigner}; +use hyperliquid_rust_sdk::{ExchangeClient, HyperliquidChain}; +use log::info; + +fn setup_multi_sig_wallets() -> Vec { + // These are example private keys - in production, these would be the authorized + // user wallets that have permission to sign for the multi-sig account + let wallets = vec![ + "0x1234567890123456789012345678901234567890123456789012345678901234", + "0x2345678901234567890123456789012345678901234567890123456789012345", + "0x3456789012345678901234567890123456789012345678901234567890123456", + ]; + + wallets + .into_iter() + .map(|key| key.parse().unwrap()) + .collect() +} + +async fn setup_exchange_client() -> (Address, ExchangeClient) { + // Key was randomly generated for testing and shouldn't be used with any real funds + let wallet: PrivateKeySigner = + "e908f86dbb4d55ac876378565aafeabc187f6690f046459397b17d9b9a19688e" + .parse() + .unwrap(); + + let address = wallet.address(); + let exchange_client = + ExchangeClient::new(None, wallet, Some(HyperliquidChain::Testnet), None, None) + .await + .unwrap(); + + (address, exchange_client) +} + +#[tokio::main] +async fn main() { + env_logger::init(); + + let (address, exchange_client) = setup_exchange_client().await; + + // Set up the multi-sig wallets that are authorized to sign for the multi-sig user + // Each wallet must belong to a user that has been added as an authorized signer + let multi_sig_wallets = setup_multi_sig_wallets(); + + // The outer signer is required to be an authorized user or an agent of the + // authorized user of the multi-sig user. + + // Address of the multi-sig user that the action will be executed for + // Executing the action requires at least the specified threshold of signatures + // required for that multi-sig user + let multi_sig_user: Address = "0x0000000000000000000000000000000000000005" + .parse() + .unwrap(); + + // Destination address for the USDC transfer + let destination = "0x0000000000000000000000000000000000000000"; + + // Amount to send (in USDC) + let amount = "100.0"; + + info!("=== Multi-Sig USD Send Example ==="); + info!("Multi-sig user address: {}", multi_sig_user); + info!("Outer signer (current wallet): {}", address); + info!( + "Exchange client connected to: {:?}", + exchange_client.http_client.chain + ); + info!("Destination: {}", destination); + info!("Amount: {} USDC", amount); + info!( + "Authorized wallets ({} total): {:?}", + multi_sig_wallets.len(), + multi_sig_wallets + .iter() + .map(|w| w.address()) + .collect::>() + ); + + info!(""); + info!("Executing multi-sig USD transfer..."); + info!( + "Collecting signatures from {} authorized wallets...", + multi_sig_wallets.len() + ); + + // Execute the multi-sig USDC transfer + // This will collect signatures from all provided wallets and submit them together + // The action will only succeed if enough valid signatures are provided (>= threshold) + match exchange_client + .multi_sig_usdc_transfer(multi_sig_user, amount, destination, &multi_sig_wallets) + .await + { + Ok(response) => { + info!("✓ Multi-sig USD send successful!"); + info!("Response: {:?}", response); + } + Err(e) => { + info!("✗ Multi-sig USD send failed: {}", e); + info!(""); + info!("This is expected if:"); + info!(" • The multi-sig user is not properly configured"); + info!(" • The provided wallets are not authorized signers"); + info!(" • Not enough signatures provided to meet threshold"); + info!(""); + info!("To use in production:"); + info!(" 1. Convert a user to multi-sig: convert_to_multi_sig()"); + info!(" 2. Add authorized addresses: update_multi_sig_addresses()"); + info!(" 3. Use those authorized wallets to sign transactions"); + info!(" 4. Ensure you provide >= threshold number of valid signatures"); + } + } + + info!(""); + info!("Example completed"); +} diff --git a/src/bin/order_and_cancel.rs b/src/bin/order_and_cancel.rs index eed0105f..267d42d3 100644 --- a/src/bin/order_and_cancel.rs +++ b/src/bin/order_and_cancel.rs @@ -2,8 +2,8 @@ use std::{thread::sleep, time::Duration}; use alloy::signers::local::PrivateKeySigner; use hyperliquid_rust_sdk::{ - BaseUrl, ClientCancelRequest, ClientLimit, ClientOrder, ClientOrderRequest, ExchangeClient, - ExchangeDataStatus, ExchangeResponseStatus, + ClientCancelRequest, ClientLimit, ClientOrder, ClientOrderRequest, ExchangeClient, + ExchangeDataStatus, ExchangeResponseStatus, HyperliquidChain, }; use log::info; @@ -16,9 +16,10 @@ async fn main() { .parse() .unwrap(); - let exchange_client = ExchangeClient::new(None, wallet, Some(BaseUrl::Testnet), None, None) - .await - .unwrap(); + let exchange_client = + ExchangeClient::new(None, wallet, Some(HyperliquidChain::Testnet), None, None) + .await + .unwrap(); let order = ClientOrderRequest { asset: "ETH".to_string(), diff --git a/src/bin/order_and_cancel_cloid.rs b/src/bin/order_and_cancel_cloid.rs index 61cd5c57..6fa169ea 100644 --- a/src/bin/order_and_cancel_cloid.rs +++ b/src/bin/order_and_cancel_cloid.rs @@ -2,7 +2,8 @@ use std::{thread::sleep, time::Duration}; use alloy::signers::local::PrivateKeySigner; use hyperliquid_rust_sdk::{ - BaseUrl, ClientCancelRequestCloid, ClientLimit, ClientOrder, ClientOrderRequest, ExchangeClient, + ClientCancelRequestCloid, ClientLimit, ClientOrder, ClientOrderRequest, ExchangeClient, + HyperliquidChain, }; use log::info; use uuid::Uuid; @@ -16,9 +17,10 @@ async fn main() { .parse() .unwrap(); - let exchange_client = ExchangeClient::new(None, wallet, Some(BaseUrl::Testnet), None, None) - .await - .unwrap(); + let exchange_client = + ExchangeClient::new(None, wallet, Some(HyperliquidChain::Testnet), None, None) + .await + .unwrap(); // Order and Cancel with cloid let cloid = Uuid::new_v4(); diff --git a/src/bin/order_and_schedule_cancel.rs b/src/bin/order_and_schedule_cancel.rs index 090d183b..b730d4de 100644 --- a/src/bin/order_and_schedule_cancel.rs +++ b/src/bin/order_and_schedule_cancel.rs @@ -2,8 +2,8 @@ use alloy::signers::local::PrivateKeySigner; use log::info; use hyperliquid_rust_sdk::{ - BaseUrl, ClientLimit, ClientOrder, ClientOrderRequest, ExchangeClient, ExchangeDataStatus, - ExchangeResponseStatus, + ClientLimit, ClientOrder, ClientOrderRequest, ExchangeClient, ExchangeDataStatus, + ExchangeResponseStatus, HyperliquidChain, }; use std::{thread::sleep, time::Duration}; @@ -16,9 +16,10 @@ async fn main() { .parse() .unwrap(); - let exchange_client = ExchangeClient::new(None, wallet, Some(BaseUrl::Testnet), None, None) - .await - .unwrap(); + let exchange_client = + ExchangeClient::new(None, wallet, Some(HyperliquidChain::Testnet), None, None) + .await + .unwrap(); info!("Testing Schedule Cancel Dead Man's Switch functionality..."); diff --git a/src/bin/order_with_builder_and_cancel.rs b/src/bin/order_with_builder_and_cancel.rs index 23975f6d..049b92ef 100644 --- a/src/bin/order_with_builder_and_cancel.rs +++ b/src/bin/order_with_builder_and_cancel.rs @@ -2,8 +2,8 @@ use std::{thread::sleep, time::Duration}; use alloy::signers::local::PrivateKeySigner; use hyperliquid_rust_sdk::{ - BaseUrl, BuilderInfo, ClientCancelRequest, ClientLimit, ClientOrder, ClientOrderRequest, - ExchangeClient, ExchangeDataStatus, ExchangeResponseStatus, + BuilderInfo, ClientCancelRequest, ClientLimit, ClientOrder, ClientOrderRequest, ExchangeClient, + ExchangeDataStatus, ExchangeResponseStatus, HyperliquidChain, }; use log::info; @@ -16,9 +16,10 @@ async fn main() { .parse() .unwrap(); - let exchange_client = ExchangeClient::new(None, wallet, Some(BaseUrl::Testnet), None, None) - .await - .unwrap(); + let exchange_client = + ExchangeClient::new(None, wallet, Some(HyperliquidChain::Testnet), None, None) + .await + .unwrap(); let order = ClientOrderRequest { asset: "ETH".to_string(), diff --git a/src/bin/set_referrer.rs b/src/bin/set_referrer.rs index 37b470ae..f92fa809 100644 --- a/src/bin/set_referrer.rs +++ b/src/bin/set_referrer.rs @@ -1,5 +1,5 @@ use alloy::signers::local::PrivateKeySigner; -use hyperliquid_rust_sdk::{BaseUrl, ExchangeClient}; +use hyperliquid_rust_sdk::{ExchangeClient, HyperliquidChain}; use log::info; #[tokio::main] @@ -11,9 +11,10 @@ async fn main() { .parse() .unwrap(); - let exchange_client = ExchangeClient::new(None, wallet, Some(BaseUrl::Testnet), None, None) - .await - .unwrap(); + let exchange_client = + ExchangeClient::new(None, wallet, Some(HyperliquidChain::Testnet), None, None) + .await + .unwrap(); let code = "TESTNET".to_string(); diff --git a/src/bin/spot_order.rs b/src/bin/spot_order.rs index e111aa27..bedc6ed2 100644 --- a/src/bin/spot_order.rs +++ b/src/bin/spot_order.rs @@ -2,8 +2,8 @@ use std::{thread::sleep, time::Duration}; use alloy::signers::local::PrivateKeySigner; use hyperliquid_rust_sdk::{ - BaseUrl, ClientCancelRequest, ClientLimit, ClientOrder, ClientOrderRequest, ExchangeClient, - ExchangeDataStatus, ExchangeResponseStatus, + ClientCancelRequest, ClientLimit, ClientOrder, ClientOrderRequest, ExchangeClient, + ExchangeDataStatus, ExchangeResponseStatus, HyperliquidChain, }; use log::info; @@ -16,9 +16,10 @@ async fn main() { .parse() .unwrap(); - let exchange_client = ExchangeClient::new(None, wallet, Some(BaseUrl::Testnet), None, None) - .await - .unwrap(); + let exchange_client = + ExchangeClient::new(None, wallet, Some(HyperliquidChain::Testnet), None, None) + .await + .unwrap(); let order = ClientOrderRequest { asset: "XYZTWO/USDC".to_string(), diff --git a/src/bin/spot_transfer.rs b/src/bin/spot_transfer.rs index a84b088b..b4c9cb9a 100644 --- a/src/bin/spot_transfer.rs +++ b/src/bin/spot_transfer.rs @@ -1,5 +1,5 @@ use alloy::signers::local::PrivateKeySigner; -use hyperliquid_rust_sdk::{BaseUrl, ExchangeClient}; +use hyperliquid_rust_sdk::{ExchangeClient, HyperliquidChain}; use log::info; #[tokio::main] @@ -11,9 +11,10 @@ async fn main() { .parse() .unwrap(); - let exchange_client = ExchangeClient::new(None, wallet, Some(BaseUrl::Testnet), None, None) - .await - .unwrap(); + let exchange_client = + ExchangeClient::new(None, wallet, Some(HyperliquidChain::Testnet), None, None) + .await + .unwrap(); let amount = "1"; let destination = "0x0D1d9635D0640821d15e323ac8AdADfA9c111414"; diff --git a/src/bin/usdc_transfer.rs b/src/bin/usdc_transfer.rs index 0c5682de..550ab20b 100644 --- a/src/bin/usdc_transfer.rs +++ b/src/bin/usdc_transfer.rs @@ -1,5 +1,5 @@ use alloy::signers::local::PrivateKeySigner; -use hyperliquid_rust_sdk::{BaseUrl, ExchangeClient}; +use hyperliquid_rust_sdk::{ExchangeClient, HyperliquidChain}; use log::info; #[tokio::main] @@ -11,9 +11,10 @@ async fn main() { .parse() .unwrap(); - let exchange_client = ExchangeClient::new(None, wallet, Some(BaseUrl::Testnet), None, None) - .await - .unwrap(); + let exchange_client = + ExchangeClient::new(None, wallet, Some(HyperliquidChain::Testnet), None, None) + .await + .unwrap(); let amount = "1"; // 1 USD let destination = "0x0D1d9635D0640821d15e323ac8AdADfA9c111414"; diff --git a/src/bin/using_big_blocks.rs b/src/bin/using_big_blocks.rs index d9cd8c73..7021662b 100644 --- a/src/bin/using_big_blocks.rs +++ b/src/bin/using_big_blocks.rs @@ -1,5 +1,5 @@ use alloy::signers::local::PrivateKeySigner; -use hyperliquid_rust_sdk::{BaseUrl, ExchangeClient}; +use hyperliquid_rust_sdk::{ExchangeClient, HyperliquidChain}; use log::info; #[tokio::main] @@ -11,10 +11,15 @@ async fn main() { .parse() .unwrap(); - let exchange_client = - ExchangeClient::new(None, wallet.clone(), Some(BaseUrl::Testnet), None, None) - .await - .unwrap(); + let exchange_client = ExchangeClient::new( + None, + wallet.clone(), + Some(HyperliquidChain::Testnet), + None, + None, + ) + .await + .unwrap(); let res = exchange_client .enable_big_blocks(false, Some(&wallet)) diff --git a/src/bin/vault_transfer.rs b/src/bin/vault_transfer.rs index f5e79294..082ccdba 100644 --- a/src/bin/vault_transfer.rs +++ b/src/bin/vault_transfer.rs @@ -1,5 +1,5 @@ use alloy::signers::local::PrivateKeySigner; -use hyperliquid_rust_sdk::{BaseUrl, ExchangeClient}; +use hyperliquid_rust_sdk::{ExchangeClient, HyperliquidChain}; use log::info; #[tokio::main] @@ -11,9 +11,10 @@ async fn main() { .parse() .unwrap(); - let exchange_client = ExchangeClient::new(None, wallet, Some(BaseUrl::Testnet), None, None) - .await - .unwrap(); + let exchange_client = + ExchangeClient::new(None, wallet, Some(HyperliquidChain::Testnet), None, None) + .await + .unwrap(); let usd = 5_000_000; // at least 5 USD let is_deposit = true; diff --git a/src/bin/ws_active_asset_ctx.rs b/src/bin/ws_active_asset_ctx.rs index 95a856a7..9bcc4a32 100644 --- a/src/bin/ws_active_asset_ctx.rs +++ b/src/bin/ws_active_asset_ctx.rs @@ -1,4 +1,4 @@ -use hyperliquid_rust_sdk::{BaseUrl, InfoClient, Message, Subscription}; +use hyperliquid_rust_sdk::{HyperliquidChain, InfoClient, Message, Subscription}; use log::info; use tokio::{ spawn, @@ -9,7 +9,9 @@ use tokio::{ #[tokio::main] async fn main() { env_logger::init(); - let mut info_client = InfoClient::new(None, Some(BaseUrl::Testnet)).await.unwrap(); + let mut info_client = InfoClient::new(None, Some(HyperliquidChain::Testnet)) + .await + .unwrap(); let coin = "BTC".to_string(); let (sender, mut receiver) = unbounded_channel(); diff --git a/src/bin/ws_active_asset_data.rs b/src/bin/ws_active_asset_data.rs index 64f96766..d570a82f 100644 --- a/src/bin/ws_active_asset_data.rs +++ b/src/bin/ws_active_asset_data.rs @@ -1,5 +1,5 @@ use alloy::primitives::address; -use hyperliquid_rust_sdk::{BaseUrl, InfoClient, Message, Subscription}; +use hyperliquid_rust_sdk::{HyperliquidChain, InfoClient, Message, Subscription}; use log::info; use tokio::{ spawn, @@ -10,7 +10,9 @@ use tokio::{ #[tokio::main] async fn main() { env_logger::init(); - let mut info_client = InfoClient::new(None, Some(BaseUrl::Testnet)).await.unwrap(); + let mut info_client = InfoClient::new(None, Some(HyperliquidChain::Testnet)) + .await + .unwrap(); let user = address!("0xc64cc00b46101bd40aa1c3121195e85c0b0918d8"); let coin = "BTC".to_string(); diff --git a/src/bin/ws_all_mids.rs b/src/bin/ws_all_mids.rs index ee780db0..c06621ed 100644 --- a/src/bin/ws_all_mids.rs +++ b/src/bin/ws_all_mids.rs @@ -1,4 +1,4 @@ -use hyperliquid_rust_sdk::{BaseUrl, InfoClient, Message, Subscription}; +use hyperliquid_rust_sdk::{HyperliquidChain, InfoClient, Message, Subscription}; use log::info; use tokio::{ spawn, @@ -10,7 +10,9 @@ use tokio::{ async fn main() { env_logger::init(); - let mut info_client = InfoClient::new(None, Some(BaseUrl::Testnet)).await.unwrap(); + let mut info_client = InfoClient::new(None, Some(HyperliquidChain::Testnet)) + .await + .unwrap(); let (sender, mut receiver) = unbounded_channel(); let subscription_id = info_client diff --git a/src/bin/ws_bbo.rs b/src/bin/ws_bbo.rs index f62b4941..a3be3d0a 100644 --- a/src/bin/ws_bbo.rs +++ b/src/bin/ws_bbo.rs @@ -1,4 +1,4 @@ -use hyperliquid_rust_sdk::{BaseUrl, InfoClient, Message, Subscription}; +use hyperliquid_rust_sdk::{HyperliquidChain, InfoClient, Message, Subscription}; use log::info; use tokio::{ spawn, @@ -9,7 +9,9 @@ use tokio::{ #[tokio::main] async fn main() { env_logger::init(); - let mut info_client = InfoClient::new(None, Some(BaseUrl::Testnet)).await.unwrap(); + let mut info_client = InfoClient::new(None, Some(HyperliquidChain::Testnet)) + .await + .unwrap(); let coin = "BTC".to_string(); let (sender, mut receiver) = unbounded_channel(); diff --git a/src/bin/ws_candles.rs b/src/bin/ws_candles.rs index 255b152c..03d57dbe 100644 --- a/src/bin/ws_candles.rs +++ b/src/bin/ws_candles.rs @@ -1,4 +1,4 @@ -use hyperliquid_rust_sdk::{BaseUrl, InfoClient, Message, Subscription}; +use hyperliquid_rust_sdk::{HyperliquidChain, InfoClient, Message, Subscription}; use log::info; use tokio::{ spawn, @@ -9,7 +9,9 @@ use tokio::{ #[tokio::main] async fn main() { env_logger::init(); - let mut info_client = InfoClient::new(None, Some(BaseUrl::Mainnet)).await.unwrap(); + let mut info_client = InfoClient::new(None, Some(HyperliquidChain::Mainnet)) + .await + .unwrap(); let (sender, mut receiver) = unbounded_channel(); let subscription_id = info_client diff --git a/src/bin/ws_l2_book.rs b/src/bin/ws_l2_book.rs index 5ae34a74..1f485cad 100644 --- a/src/bin/ws_l2_book.rs +++ b/src/bin/ws_l2_book.rs @@ -1,4 +1,4 @@ -use hyperliquid_rust_sdk::{BaseUrl, InfoClient, Message, Subscription}; +use hyperliquid_rust_sdk::{HyperliquidChain, InfoClient, Message, Subscription}; use log::info; use tokio::{ spawn, @@ -10,7 +10,9 @@ use tokio::{ async fn main() { env_logger::init(); - let mut info_client = InfoClient::new(None, Some(BaseUrl::Testnet)).await.unwrap(); + let mut info_client = InfoClient::new(None, Some(HyperliquidChain::Testnet)) + .await + .unwrap(); let (sender, mut receiver) = unbounded_channel(); let subscription_id = info_client diff --git a/src/bin/ws_notification.rs b/src/bin/ws_notification.rs index 5ab19926..c90fffad 100644 --- a/src/bin/ws_notification.rs +++ b/src/bin/ws_notification.rs @@ -1,5 +1,5 @@ use alloy::primitives::address; -use hyperliquid_rust_sdk::{BaseUrl, InfoClient, Message, Subscription}; +use hyperliquid_rust_sdk::{HyperliquidChain, InfoClient, Message, Subscription}; use log::info; use tokio::{ spawn, @@ -10,7 +10,9 @@ use tokio::{ #[tokio::main] async fn main() { env_logger::init(); - let mut info_client = InfoClient::new(None, Some(BaseUrl::Testnet)).await.unwrap(); + let mut info_client = InfoClient::new(None, Some(HyperliquidChain::Testnet)) + .await + .unwrap(); let user = address!("0xc64cc00b46101bd40aa1c3121195e85c0b0918d8"); let (sender, mut receiver) = unbounded_channel(); diff --git a/src/bin/ws_orders.rs b/src/bin/ws_orders.rs index 617d92d4..cf3e03a1 100644 --- a/src/bin/ws_orders.rs +++ b/src/bin/ws_orders.rs @@ -1,5 +1,5 @@ use alloy::primitives::address; -use hyperliquid_rust_sdk::{BaseUrl, InfoClient, Message, Subscription}; +use hyperliquid_rust_sdk::{HyperliquidChain, InfoClient, Message, Subscription}; use log::info; use tokio::{ spawn, @@ -10,7 +10,9 @@ use tokio::{ #[tokio::main] async fn main() { env_logger::init(); - let mut info_client = InfoClient::new(None, Some(BaseUrl::Testnet)).await.unwrap(); + let mut info_client = InfoClient::new(None, Some(HyperliquidChain::Testnet)) + .await + .unwrap(); let user = address!("0xc64cc00b46101bd40aa1c3121195e85c0b0918d8"); let (sender, mut receiver) = unbounded_channel(); diff --git a/src/bin/ws_spot_price.rs b/src/bin/ws_spot_price.rs index 2247621d..d4b404b1 100644 --- a/src/bin/ws_spot_price.rs +++ b/src/bin/ws_spot_price.rs @@ -1,13 +1,15 @@ use std::time::Duration; -use hyperliquid_rust_sdk::{BaseUrl, InfoClient, Message, Subscription}; +use hyperliquid_rust_sdk::{HyperliquidChain, InfoClient, Message, Subscription}; use log::info; use tokio::{spawn, sync::mpsc::unbounded_channel, time::sleep}; #[tokio::main] async fn main() { env_logger::init(); - let mut info_client = InfoClient::new(None, Some(BaseUrl::Mainnet)).await.unwrap(); + let mut info_client = InfoClient::new(None, Some(HyperliquidChain::Mainnet)) + .await + .unwrap(); let (sender, mut receiver) = unbounded_channel(); let subscription_id = info_client diff --git a/src/bin/ws_trades.rs b/src/bin/ws_trades.rs index 7b7877bd..b4eb5021 100644 --- a/src/bin/ws_trades.rs +++ b/src/bin/ws_trades.rs @@ -1,4 +1,4 @@ -use hyperliquid_rust_sdk::{BaseUrl, InfoClient, Message, Subscription}; +use hyperliquid_rust_sdk::{HyperliquidChain, InfoClient, Message, Subscription}; use log::info; use tokio::{ spawn, @@ -10,7 +10,9 @@ use tokio::{ async fn main() { env_logger::init(); - let mut info_client = InfoClient::new(None, Some(BaseUrl::Testnet)).await.unwrap(); + let mut info_client = InfoClient::new(None, Some(HyperliquidChain::Testnet)) + .await + .unwrap(); let (sender, mut receiver) = unbounded_channel(); let subscription_id = info_client diff --git a/src/bin/ws_user_events.rs b/src/bin/ws_user_events.rs index 9055d24d..8469d0c1 100644 --- a/src/bin/ws_user_events.rs +++ b/src/bin/ws_user_events.rs @@ -1,5 +1,5 @@ use alloy::primitives::address; -use hyperliquid_rust_sdk::{BaseUrl, InfoClient, Message, Subscription}; +use hyperliquid_rust_sdk::{HyperliquidChain, InfoClient, Message, Subscription}; use log::info; use tokio::{ spawn, @@ -10,7 +10,9 @@ use tokio::{ #[tokio::main] async fn main() { env_logger::init(); - let mut info_client = InfoClient::new(None, Some(BaseUrl::Testnet)).await.unwrap(); + let mut info_client = InfoClient::new(None, Some(HyperliquidChain::Testnet)) + .await + .unwrap(); let user = address!("0xc64cc00b46101bd40aa1c3121195e85c0b0918d8"); let (sender, mut receiver) = unbounded_channel(); diff --git a/src/bin/ws_user_fundings.rs b/src/bin/ws_user_fundings.rs index 43f57e21..aae55061 100644 --- a/src/bin/ws_user_fundings.rs +++ b/src/bin/ws_user_fundings.rs @@ -1,5 +1,5 @@ use alloy::primitives::address; -use hyperliquid_rust_sdk::{BaseUrl, InfoClient, Message, Subscription}; +use hyperliquid_rust_sdk::{HyperliquidChain, InfoClient, Message, Subscription}; use log::info; use tokio::{ spawn, @@ -10,7 +10,9 @@ use tokio::{ #[tokio::main] async fn main() { env_logger::init(); - let mut info_client = InfoClient::new(None, Some(BaseUrl::Testnet)).await.unwrap(); + let mut info_client = InfoClient::new(None, Some(HyperliquidChain::Testnet)) + .await + .unwrap(); let user = address!("0xc64cc00b46101bd40aa1c3121195e85c0b0918d8"); let (sender, mut receiver) = unbounded_channel(); diff --git a/src/bin/ws_user_non_funding_ledger_updates.rs b/src/bin/ws_user_non_funding_ledger_updates.rs index 11881b8c..2ee632a4 100644 --- a/src/bin/ws_user_non_funding_ledger_updates.rs +++ b/src/bin/ws_user_non_funding_ledger_updates.rs @@ -1,5 +1,5 @@ use alloy::primitives::address; -use hyperliquid_rust_sdk::{BaseUrl, InfoClient, Message, Subscription}; +use hyperliquid_rust_sdk::{HyperliquidChain, InfoClient, Message, Subscription}; use log::info; use tokio::{ spawn, @@ -10,7 +10,9 @@ use tokio::{ #[tokio::main] async fn main() { env_logger::init(); - let mut info_client = InfoClient::new(None, Some(BaseUrl::Testnet)).await.unwrap(); + let mut info_client = InfoClient::new(None, Some(HyperliquidChain::Testnet)) + .await + .unwrap(); let user = address!("0xc64cc00b46101bd40aa1c3121195e85c0b0918d8"); let (sender, mut receiver) = unbounded_channel(); diff --git a/src/bin/ws_web_data2.rs b/src/bin/ws_web_data2.rs index c48441ba..bb0d024c 100644 --- a/src/bin/ws_web_data2.rs +++ b/src/bin/ws_web_data2.rs @@ -1,5 +1,5 @@ use alloy::primitives::address; -use hyperliquid_rust_sdk::{BaseUrl, InfoClient, Message, Subscription}; +use hyperliquid_rust_sdk::{HyperliquidChain, InfoClient, Message, Subscription}; use log::info; use tokio::{ spawn, @@ -10,7 +10,9 @@ use tokio::{ #[tokio::main] async fn main() { env_logger::init(); - let mut info_client = InfoClient::new(None, Some(BaseUrl::Testnet)).await.unwrap(); + let mut info_client = InfoClient::new(None, Some(HyperliquidChain::Testnet)) + .await + .unwrap(); let user = address!("0xc64cc00b46101bd40aa1c3121195e85c0b0918d8"); let (sender, mut receiver) = unbounded_channel(); diff --git a/src/exchange/actions.rs b/src/exchange/actions.rs index 71e9f4f1..e521af78 100644 --- a/src/exchange/actions.rs +++ b/src/exchange/actions.rs @@ -3,12 +3,14 @@ use alloy::{ primitives::{keccak256, Address, B256}, sol_types::{eip712_domain, SolValue}, }; -use serde::{Deserialize, Serialize, Serializer}; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; use super::{cancel::CancelRequestCloid, BuilderInfo}; +use crate::helpers::next_nonce; use crate::{ eip712::Eip712, exchange::{cancel::CancelRequest, modify::ModifyRequest, order::OrderRequest}, + HyperliquidChain, }; fn eip_712_domain(chain_id: u64) -> Eip712Domain { @@ -27,10 +29,41 @@ where s.serialize_str(&format!("0x{val:x}")) } -#[derive(Serialize, Deserialize, Debug, Clone)] +fn deserialize_hex<'de, D>(d: D) -> Result +where + D: Deserializer<'de>, +{ + let s = String::deserialize(d)?; + let s = s.strip_prefix("0x").unwrap_or(&s); + u64::from_str_radix(s, 16).map_err(serde::de::Error::custom) +} + +fn default_signature_chain_id( + hyperliquid_chain: &HyperliquidChain, + signature_chain_id: Option, +) -> u64 { + match signature_chain_id { + Some(signature_chain_id) => signature_chain_id, + None => { + if hyperliquid_chain.is_mainnet() { + 42161 // Arbitrum One + } else { + 421614 // Arbitrum Sepolia + } + } + } +} + +#[derive(Debug, Clone, Deserialize, PartialEq)] +pub struct MultiSigExtension { + pub payload_multi_sig_user: String, + pub outer_signer: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] #[serde(rename_all = "camelCase")] pub struct UsdSend { - #[serde(serialize_with = "serialize_hex")] + #[serde(serialize_with = "serialize_hex", deserialize_with = "deserialize_hex")] pub signature_chain_id: u64, pub hyperliquid_chain: String, pub destination: String, @@ -38,6 +71,24 @@ pub struct UsdSend { pub time: u64, } +impl UsdSend { + pub fn new( + hyperliquid_chain: HyperliquidChain, + destination: String, + amount: String, + signature_chain_id: Option, + ) -> Self { + let signature_chain_id = default_signature_chain_id(&hyperliquid_chain, signature_chain_id); + Self { + signature_chain_id, + hyperliquid_chain: hyperliquid_chain.action_chain_name(), + destination, + amount, + time: next_nonce(), + } + } +} + impl Eip712 for UsdSend { fn domain(&self) -> Eip712Domain { eip_712_domain(self.signature_chain_id) @@ -101,7 +152,7 @@ pub struct BulkCancelCloid { #[derive(Serialize, Deserialize, Debug, Clone)] #[serde(rename_all = "camelCase")] pub struct ApproveAgent { - #[serde(serialize_with = "serialize_hex")] + #[serde(serialize_with = "serialize_hex", deserialize_with = "deserialize_hex")] pub signature_chain_id: u64, pub hyperliquid_chain: String, pub agent_address: Address, @@ -109,6 +160,24 @@ pub struct ApproveAgent { pub nonce: u64, } +impl ApproveAgent { + pub fn new( + hyperliquid_chain: HyperliquidChain, + agent_address: Address, + agent_name: Option, + signature_chain_id: Option, + ) -> Self { + let signature_chain_id = default_signature_chain_id(&hyperliquid_chain, signature_chain_id); + Self { + signature_chain_id, + hyperliquid_chain: hyperliquid_chain.action_chain_name(), + agent_address, + agent_name, + nonce: next_nonce(), + } + } +} + impl Eip712 for ApproveAgent { fn domain(&self) -> Eip712Domain { eip_712_domain(self.signature_chain_id) @@ -126,10 +195,10 @@ impl Eip712 for ApproveAgent { } } -#[derive(Serialize, Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] #[serde(rename_all = "camelCase")] pub struct Withdraw3 { - #[serde(serialize_with = "serialize_hex")] + #[serde(serialize_with = "serialize_hex", deserialize_with = "deserialize_hex")] pub signature_chain_id: u64, pub hyperliquid_chain: String, pub destination: String, @@ -137,6 +206,24 @@ pub struct Withdraw3 { pub time: u64, } +impl Withdraw3 { + pub fn new( + hyperliquid_chain: HyperliquidChain, + destination: String, + amount: String, + signature_chain_id: Option, + ) -> Self { + let signature_chain_id = default_signature_chain_id(&hyperliquid_chain, signature_chain_id); + Self { + signature_chain_id, + hyperliquid_chain: hyperliquid_chain.action_chain_name(), + destination, + amount, + time: next_nonce(), + } + } +} + impl Eip712 for Withdraw3 { fn domain(&self) -> Eip712Domain { eip_712_domain(self.signature_chain_id) @@ -157,7 +244,7 @@ impl Eip712 for Withdraw3 { #[derive(Serialize, Deserialize, Debug, Clone)] #[serde(rename_all = "camelCase")] pub struct SpotSend { - #[serde(serialize_with = "serialize_hex")] + #[serde(serialize_with = "serialize_hex", deserialize_with = "deserialize_hex")] pub signature_chain_id: u64, pub hyperliquid_chain: String, pub destination: String, @@ -166,6 +253,26 @@ pub struct SpotSend { pub time: u64, } +impl SpotSend { + pub fn new( + hyperliquid_chain: HyperliquidChain, + destination: String, + token: String, + amount: String, + signature_chain_id: Option, + ) -> Self { + let signature_chain_id = default_signature_chain_id(&hyperliquid_chain, signature_chain_id); + Self { + signature_chain_id, + hyperliquid_chain: hyperliquid_chain.action_chain_name(), + destination, + token, + amount, + time: next_nonce(), + } + } +} + impl Eip712 for SpotSend { fn domain(&self) -> Eip712Domain { eip_712_domain(self.signature_chain_id) @@ -186,21 +293,49 @@ impl Eip712 for SpotSend { #[derive(Serialize, Deserialize, Debug, Clone)] #[serde(rename_all = "camelCase")] -pub struct SpotUser { - pub class_transfer: ClassTransfer, +pub struct UsdClassTransfer { + #[serde(serialize_with = "serialize_hex", deserialize_with = "deserialize_hex")] + pub signature_chain_id: u64, + pub hyperliquid_chain: String, + pub amount: String, + pub to_perp: bool, + pub nonce: u64, } -#[derive(Serialize, Deserialize, Debug, Clone)] -#[serde(rename_all = "camelCase")] -pub struct ClassTransfer { - pub usdc: u64, - pub to_perp: bool, +impl UsdClassTransfer { + pub fn new(hyperliquid_chain: HyperliquidChain, amount: String, to_perp: bool) -> Self { + let signature_chain_id = default_signature_chain_id(&hyperliquid_chain, None); + Self { + signature_chain_id, + hyperliquid_chain: hyperliquid_chain.action_chain_name(), + amount, + to_perp, + nonce: next_nonce(), + } + } } -#[derive(Serialize, Deserialize, Debug, Clone)] +impl Eip712 for UsdClassTransfer { + fn domain(&self) -> Eip712Domain { + eip_712_domain(self.signature_chain_id) + } + + fn struct_hash(&self) -> B256 { + let items = ( + keccak256("HyperliquidTransaction:UsdClassTransfer(string hyperliquidChain,string amount,bool toPerp,uint64 nonce)"), + keccak256(&self.hyperliquid_chain), + keccak256(&self.amount), + self.to_perp, + self.nonce, + ); + keccak256(items.abi_encode()) + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] #[serde(rename_all = "camelCase")] pub struct SendAsset { - #[serde(serialize_with = "serialize_hex")] + #[serde(serialize_with = "serialize_hex", deserialize_with = "deserialize_hex")] pub signature_chain_id: u64, pub hyperliquid_chain: String, pub destination: String, @@ -210,6 +345,37 @@ pub struct SendAsset { pub amount: String, pub from_sub_account: String, pub nonce: u64, + #[serde(skip)] + pub multi_sig_ext: Option, +} + +impl SendAsset { + #[allow(clippy::too_many_arguments)] + pub fn new( + hyperliquid_chain: HyperliquidChain, + destination: String, + source_dex: String, + destination_dex: String, + token: String, + amount: String, + from_sub_account: String, + multi_sig_ext: Option, + signature_chain_id: Option, + ) -> Self { + let signature_chain_id = default_signature_chain_id(&hyperliquid_chain, signature_chain_id); + Self { + signature_chain_id, + hyperliquid_chain: hyperliquid_chain.action_chain_name(), + destination, + source_dex, + destination_dex, + token, + amount, + from_sub_account, + nonce: next_nonce(), + multi_sig_ext, + } + } } impl Eip712 for SendAsset { @@ -218,18 +384,38 @@ impl Eip712 for SendAsset { } fn struct_hash(&self) -> B256 { - let items = ( - keccak256("HyperliquidTransaction:SendAsset(string hyperliquidChain,string destination,string sourceDex,string destinationDex,string token,string amount,string fromSubAccount,uint64 nonce)"), - keccak256(&self.hyperliquid_chain), - keccak256(&self.destination), - keccak256(&self.source_dex), - keccak256(&self.destination_dex), - keccak256(&self.token), - keccak256(&self.amount), - keccak256(&self.from_sub_account), - &self.nonce, - ); - keccak256(items.abi_encode()) + if let Some(multi_sig_ext) = &self.multi_sig_ext { + let multi_sig_user: Address = multi_sig_ext.payload_multi_sig_user.parse().unwrap(); + let outer_signer: Address = multi_sig_ext.outer_signer.parse().unwrap(); + + let items = ( + keccak256("HyperliquidTransaction:SendAsset(string hyperliquidChain,address payloadMultiSigUser,address outerSigner,string destination,string sourceDex,string destinationDex,string token,string amount,string fromSubAccount,uint64 nonce)"), + keccak256(&self.hyperliquid_chain), + multi_sig_user, + outer_signer, + keccak256(&self.destination), + keccak256(&self.source_dex), + keccak256(&self.destination_dex), + keccak256(&self.token), + keccak256(&self.amount), + keccak256(&self.from_sub_account), + &self.nonce, + ); + keccak256(items.abi_encode()) + } else { + let items = ( + keccak256("HyperliquidTransaction:SendAsset(string hyperliquidChain,string destination,string sourceDex,string destinationDex,string token,string amount,string fromSubAccount,uint64 nonce)"), + keccak256(&self.hyperliquid_chain), + keccak256(&self.destination), + keccak256(&self.source_dex), + keccak256(&self.destination_dex), + keccak256(&self.token), + keccak256(&self.amount), + keccak256(&self.from_sub_account), + &self.nonce, + ); + keccak256(items.abi_encode()) + } } } @@ -253,10 +439,10 @@ pub struct EvmUserModify { pub using_big_blocks: bool, } -#[derive(Serialize, Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] #[serde(rename_all = "camelCase")] pub struct ApproveBuilderFee { - #[serde(serialize_with = "serialize_hex")] + #[serde(serialize_with = "serialize_hex", deserialize_with = "deserialize_hex")] pub signature_chain_id: u64, pub hyperliquid_chain: String, pub builder: Address, @@ -264,6 +450,24 @@ pub struct ApproveBuilderFee { pub nonce: u64, } +impl ApproveBuilderFee { + pub fn new( + hyperliquid_chain: HyperliquidChain, + builder: Address, + max_fee_rate: String, + signature_chain_id: Option, + ) -> Self { + let signature_chain_id = default_signature_chain_id(&hyperliquid_chain, signature_chain_id); + Self { + signature_chain_id, + hyperliquid_chain: hyperliquid_chain.action_chain_name(), + builder, + max_fee_rate, + nonce: next_nonce(), + } + } +} + #[derive(Serialize, Deserialize, Debug, Clone)] #[serde(rename_all = "camelCase")] pub struct ScheduleCancel { @@ -291,3 +495,283 @@ impl Eip712 for ApproveBuilderFee { keccak256(items.abi_encode()) } } + +// Multi-sig related structs + +/// Convert a regular user account to a multi-sig account +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct ConvertToMultiSig { + #[serde(serialize_with = "serialize_hex", deserialize_with = "deserialize_hex")] + pub signature_chain_id: u64, + pub hyperliquid_chain: String, + pub multi_sig_threshold: u64, + pub time: u64, +} + +impl ConvertToMultiSig { + pub fn new( + hyperliquid_chain: HyperliquidChain, + multi_sig_threshold: u64, + signature_chain_id: Option, + ) -> Self { + let signature_chain_id = default_signature_chain_id(&hyperliquid_chain, signature_chain_id); + Self { + signature_chain_id, + hyperliquid_chain: hyperliquid_chain.action_chain_name(), + multi_sig_threshold, + time: next_nonce(), + } + } +} + +impl Eip712 for ConvertToMultiSig { + fn domain(&self) -> Eip712Domain { + eip_712_domain(self.signature_chain_id) + } + + fn struct_hash(&self) -> B256 { + let items = ( + keccak256("HyperliquidTransaction:ConvertToMultiSig(string hyperliquidChain,uint64 multiSigThreshold,uint64 time)"), + keccak256(&self.hyperliquid_chain), + &self.multi_sig_threshold, + &self.time, + ); + keccak256(items.abi_encode()) + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct UpdateMultiSigAddresses { + #[serde(serialize_with = "serialize_hex", deserialize_with = "deserialize_hex")] + pub signature_chain_id: u64, + pub hyperliquid_chain: String, + pub to_add: Vec
, + pub to_remove: Vec
, + pub time: u64, +} + +impl UpdateMultiSigAddresses { + pub fn new( + hyperliquid_chain: HyperliquidChain, + to_add: Vec
, + to_remove: Vec
, + signature_chain_id: Option, + ) -> Self { + let signature_chain_id = default_signature_chain_id(&hyperliquid_chain, signature_chain_id); + Self { + signature_chain_id, + hyperliquid_chain: hyperliquid_chain.action_chain_name(), + to_add, + to_remove, + time: next_nonce(), + } + } +} + +impl Eip712 for UpdateMultiSigAddresses { + fn domain(&self) -> Eip712Domain { + eip_712_domain(self.signature_chain_id) + } + + fn struct_hash(&self) -> B256 { + let to_add_encoded = self.to_add.iter().fold(B256::ZERO, |acc, addr| { + keccak256([acc.as_slice(), addr.as_slice()].concat()) + }); + let to_remove_encoded = self.to_remove.iter().fold(B256::ZERO, |acc, addr| { + keccak256([acc.as_slice(), addr.as_slice()].concat()) + }); + + let items = ( + keccak256("HyperliquidTransaction:UpdateMultiSigAddresses(string hyperliquidChain,address[] toAdd,address[] toRemove,uint64 time)"), + keccak256(&self.hyperliquid_chain), + to_add_encoded, + to_remove_encoded, + &self.time, + ); + keccak256(items.abi_encode()) + } +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct MultiSigEnvelope { + #[serde(serialize_with = "serialize_hex", deserialize_with = "deserialize_hex")] + pub signature_chain_id: u64, + pub hyperliquid_chain: String, + pub multi_sig_action_hash: B256, + pub nonce: u64, +} + +impl Eip712 for MultiSigEnvelope { + fn domain(&self) -> Eip712Domain { + eip_712_domain(self.signature_chain_id) + } + + fn struct_hash(&self) -> B256 { + let items = ( + keccak256("HyperliquidTransaction:SendMultiSig(string hyperliquidChain,bytes32 multiSigActionHash,uint64 nonce)"), + keccak256(&self.hyperliquid_chain), + &self.multi_sig_action_hash, + &self.nonce, + ); + keccak256(items.abi_encode()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::prelude::Result; + use crate::HyperliquidChain; + use alloy::primitives::address; + + #[test] + fn test_usd_send_new_helper() -> Result<()> { + let with_helper = UsdSend::new( + HyperliquidChain::Testnet, + "0x0D1d9635D0640821d15e323ac8AdADfA9c111414".to_string(), + "1".to_string(), + None, + ); + let time = with_helper.time; + + let manual = UsdSend { + signature_chain_id: 421614, + hyperliquid_chain: "Testnet".to_string(), + destination: "0x0D1d9635D0640821d15e323ac8AdADfA9c111414".to_string(), + amount: "1".to_string(), + time, + }; + + assert_eq!(manual, with_helper); + + Ok(()) + } + + #[test] + fn test_withdraw3_new_helper() -> Result<()> { + let with_helper = Withdraw3::new( + HyperliquidChain::Testnet, + "0x0D1d9635D0640821d15e323ac8AdADfA9c111414".to_string(), + "1".to_string(), + None, + ); + let time = with_helper.time; + + let manual = Withdraw3 { + signature_chain_id: 421614, + hyperliquid_chain: "Testnet".to_string(), + destination: "0x0D1d9635D0640821d15e323ac8AdADfA9c111414".to_string(), + amount: "1".to_string(), + time, + }; + + assert_eq!(manual, with_helper); + + Ok(()) + } + + #[test] + fn test_approve_builder_fee_new_helper() -> Result<()> { + let with_helper = ApproveBuilderFee::new( + HyperliquidChain::Testnet, + address!("0x1234567890123456789012345678901234567890"), + "0.001%".to_string(), + None, + ); + let nonce = with_helper.nonce; + + let manual = ApproveBuilderFee { + signature_chain_id: 421614, + hyperliquid_chain: "Testnet".to_string(), + builder: address!("0x1234567890123456789012345678901234567890"), + max_fee_rate: "0.001%".to_string(), + nonce, + }; + + assert_eq!(manual, with_helper); + + Ok(()) + } + + #[test] + fn test_send_asset_new_helper() -> Result<()> { + let with_helper = SendAsset::new( + HyperliquidChain::Testnet, + "0x1234567890123456789012345678901234567890".to_string(), + "spot".to_string(), + "".to_string(), + "USDC".to_string(), + "50".to_string(), + "".to_string(), + None, + None, + ); + let nonce = with_helper.nonce; + + let manual = SendAsset { + signature_chain_id: 421614, + hyperliquid_chain: "Testnet".to_string(), + destination: "0x1234567890123456789012345678901234567890".to_string(), + source_dex: "spot".to_string(), + destination_dex: "".to_string(), + token: "USDC".to_string(), + amount: "50".to_string(), + from_sub_account: "".to_string(), + nonce, + multi_sig_ext: None, + }; + + assert_eq!(manual, with_helper); + + Ok(()) + } + + #[test] + fn test_convert_to_multi_sig_new_helper() -> Result<()> { + let with_helper = ConvertToMultiSig::new(HyperliquidChain::Testnet, 1, None); + let time = with_helper.time; + + let manual = ConvertToMultiSig { + signature_chain_id: 421614, + hyperliquid_chain: "Testnet".to_string(), + multi_sig_threshold: 1, + time, + }; + + assert_eq!(manual, with_helper); + + Ok(()) + } + + #[test] + fn test_update_multi_sig_addresses_new_helper() -> Result<()> { + let with_helper = UpdateMultiSigAddresses::new( + HyperliquidChain::Testnet, + vec![ + address!("0x0D1d9635D0640821d15e323ac8AdADfA9c111414"), + address!("0x1234567890123456789012345678901234567890"), + ], + vec![address!("0xabcdabcdabcdabcdabcdabcdabcdabcdabcdabcd")], + None, + ); + let time = with_helper.time; + + let manual = UpdateMultiSigAddresses { + signature_chain_id: 421614, + hyperliquid_chain: "Testnet".to_string(), + to_add: vec![ + address!("0x0D1d9635D0640821d15e323ac8AdADfA9c111414"), + address!("0x1234567890123456789012345678901234567890"), + ], + to_remove: vec![address!("0xabcdabcdabcdabcdabcdabcdabcdabcdabcdabcd")], + time, + }; + + assert_eq!(manual, with_helper); + + Ok(()) + } +} diff --git a/src/exchange/exchange_client.rs b/src/exchange/exchange_client.rs index 70b686b8..39b73d96 100644 --- a/src/exchange/exchange_client.rs +++ b/src/exchange/exchange_client.rs @@ -1,19 +1,9 @@ -use std::collections::HashMap; - -use alloy::{ - primitives::{keccak256, Address, Signature, B256}, - signers::local::PrivateKeySigner, -}; -use log::debug; -use reqwest::Client; -use serde::{ser::SerializeStruct, Deserialize, Serialize, Serializer}; - use crate::{ exchange::{ actions::{ ApproveAgent, ApproveBuilderFee, BulkCancel, BulkModify, BulkOrder, ClaimRewards, - EvmUserModify, ScheduleCancel, SendAsset, SetReferrer, UpdateIsolatedMargin, - UpdateLeverage, UsdSend, + ConvertToMultiSig, EvmUserModify, ScheduleCancel, SendAsset, SetReferrer, + UpdateIsolatedMargin, UpdateLeverage, UpdateMultiSigAddresses, UsdSend, }, cancel::{CancelRequest, CancelRequestCloid, ClientCancelRequestCloid}, modify::{ClientModifyRequest, ModifyRequest}, @@ -25,10 +15,22 @@ use crate::{ meta::Meta, prelude::*, req::HttpClient, - signature::{sign_l1_action, sign_typed_data}, - BaseUrl, BulkCancelCloid, ClassTransfer, Error, ExchangeResponseStatus, SpotSend, SpotUser, - VaultTransfer, Withdraw3, + signature::{ + sign_l1_action, sign_multi_sig_action, sign_multi_sig_l1_action_payload, sign_typed_data, + sign_typed_data_multi_sig, + }, + BulkCancelCloid, Error, ExchangeResponseStatus, HyperliquidChain, MultiSigExtension, SpotSend, + UsdClassTransfer, VaultTransfer, Withdraw3, +}; +use alloy::{ + hex, + primitives::{keccak256, Address, Signature, B256}, + signers::local::PrivateKeySigner, }; +use log::debug; +use reqwest::Client; +use serde::{ser::SerializeStruct, Deserialize, Serialize, Serializer}; +use std::collections::HashMap; #[derive(Debug)] pub struct ExchangeClient { @@ -37,6 +39,7 @@ pub struct ExchangeClient { pub meta: Meta, pub vault_address: Option
, pub coin_to_asset: HashMap, + pub expires_after: Option, } fn serialize_sig(sig: &Signature, s: S) -> std::result::Result @@ -50,14 +53,61 @@ where state.end() } -#[derive(Serialize, Deserialize)] +#[derive(Serialize)] #[serde(rename_all = "camelCase")] struct ExchangePayload { - action: serde_json::Value, + action: PostAction, #[serde(serialize_with = "serialize_sig")] signature: Signature, nonce: u64, - vault_address: Option
, + #[serde(skip_serializing_if = "Option::is_none")] + vault_address: Option, + #[serde(skip_serializing_if = "Option::is_none")] + expires_after: Option, +} + +// Multi-sig wrapper structures +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct MultiSigPayload { + multi_sig_user: String, + outer_signer: String, + action: Actions, +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MultiSigAction { + #[serde(rename = "type", skip_serializing_if = "Option::is_none")] + pub r#type: Option, + pub signature_chain_id: String, + #[serde(serialize_with = "serialize_sigs")] + signatures: Vec, + payload: MultiSigPayload, +} + +fn serialize_sigs(sigs: &[Signature], s: S) -> std::result::Result +where + S: Serializer, +{ + use serde::ser::SerializeSeq; + let mut seq = s.serialize_seq(Some(sigs.len()))?; + for sig in sigs { + let sig_obj = SignatureObj { + r: format!("0x{}", hex::encode::<[u8; 32]>(sig.r().to_be_bytes())), + s: format!("0x{}", hex::encode::<[u8; 32]>(sig.s().to_be_bytes())), + v: 27 + sig.v() as u64, + }; + seq.serialize_element(&sig_obj)?; + } + seq.end() +} + +#[derive(Serialize)] +struct SignatureObj { + r: String, + s: String, + v: u64, } #[derive(Serialize, Deserialize, Debug, Clone)] @@ -73,7 +123,7 @@ pub enum Actions { BatchModify(BulkModify), ApproveAgent(ApproveAgent), Withdraw3(Withdraw3), - SpotUser(SpotUser), + UsdClassTransfer(UsdClassTransfer), SendAsset(SendAsset), VaultTransfer(VaultTransfer), SpotSend(SpotSend), @@ -82,10 +132,36 @@ pub enum Actions { EvmUserModify(EvmUserModify), ScheduleCancel(ScheduleCancel), ClaimRewards(ClaimRewards), + ConvertToMultiSig(ConvertToMultiSig), + UpdateMultiSigAddresses(UpdateMultiSigAddresses), +} + +#[derive(Serialize)] +#[serde(untagged)] +pub enum PostAction { + Std(Actions), + MultiSig(MultiSigAction), +} + +impl From for PostAction { + fn from(action: Actions) -> Self { + PostAction::Std(action) + } +} + +impl From for PostAction { + fn from(action: MultiSigAction) -> Self { + PostAction::MultiSig(action) + } } impl Actions { - fn hash(&self, timestamp: u64, vault_address: Option
) -> Result { + pub fn hash( + &self, + timestamp: u64, + vault_address: Option
, + expires_after: Option, + ) -> Result { let mut bytes = rmp_serde::to_vec_named(self).map_err(|e| Error::RmpParse(e.to_string()))?; bytes.extend(timestamp.to_be_bytes()); @@ -95,6 +171,10 @@ impl Actions { } else { bytes.push(0); } + if let Some(expires_after) = expires_after { + bytes.push(0); + bytes.extend(expires_after.to_be_bytes()); + } Ok(keccak256(bytes)) } } @@ -103,12 +183,12 @@ impl ExchangeClient { pub async fn new( client: Option, wallet: PrivateKeySigner, - base_url: Option, + base_url: Option, meta: Option, vault_address: Option
, ) -> Result { let client = client.unwrap_or_default(); - let base_url = base_url.unwrap_or(BaseUrl::Mainnet); + let base_url = base_url.unwrap_or(HyperliquidChain::Mainnet); let info = InfoClient::new(None, Some(base_url)).await?; let meta = if let Some(meta) = meta { @@ -133,29 +213,62 @@ impl ExchangeClient { vault_address, http_client: HttpClient { client, - base_url: base_url.get_url(), + chain: base_url, }, coin_to_asset, + expires_after: None, }) } - async fn post( + /// Set the expires_after timestamp for actions. + /// Actions will be rejected after this timestamp in milliseconds. + /// Note: expires_after is not supported on user-signed actions (e.g., usd_transfer) + /// and must be None for those actions to work. + pub fn set_expires_after(&mut self, expires_after: Option) { + self.expires_after = expires_after; + } + + /// Check if an action type should exclude vault_address and expires_after from the payload + /// Based on Python SDK's _post_action logic + fn should_exclude_vault_and_expires(action: &Actions) -> bool { + matches!( + action, + Actions::UsdSend(_) + | Actions::Withdraw3(_) + | Actions::SpotSend(_) + | Actions::SendAsset(_) + | Actions::UsdClassTransfer(_) + ) + } + + pub async fn post>( &self, - action: serde_json::Value, + action: PA, signature: Signature, nonce: u64, ) -> Result { - // let signature = ExchangeSignature { - // r: signature.r(), - // s: signature.s(), - // v: 27 + signature.v() as u64, - // }; + // Determine if we should exclude vault_address and expires_after + let post_action = action.into(); + let should_exclude = match &post_action { + PostAction::Std(action) => Self::should_exclude_vault_and_expires(action), + PostAction::MultiSig(_) => false, + }; let exchange_payload = ExchangePayload { - action, + action: post_action, signature, nonce, - vault_address: self.vault_address, + vault_address: if should_exclude { + None + } else { + self.vault_address + .map(|addr| addr.to_string().to_lowercase()) + }, + expires_after: if should_exclude { + None + } else { + self.expires_after + }, }; let res = serde_json::to_string(&exchange_payload) .map_err(|e| Error::JsonParse(e.to_string()))?; @@ -180,8 +293,7 @@ impl ExchangeClient { let timestamp = next_nonce(); let action = Actions::EvmUserModify(EvmUserModify { using_big_blocks }); - let connection_id = action.hash(timestamp, self.vault_address)?; - let action = serde_json::to_value(&action).map_err(|e| Error::JsonParse(e.to_string()))?; + let connection_id = action.hash(timestamp, self.vault_address, self.expires_after)?; let is_mainnet = self.http_client.is_mainnet(); let signature = sign_l1_action(wallet, connection_id, is_mainnet)?; @@ -195,46 +307,32 @@ impl ExchangeClient { wallet: Option<&PrivateKeySigner>, ) -> Result { let wallet = wallet.unwrap_or(&self.wallet); - let hyperliquid_chain = if self.http_client.is_mainnet() { - "Mainnet".to_string() - } else { - "Testnet".to_string() - }; - - let timestamp = next_nonce(); - let usd_send = UsdSend { - signature_chain_id: 421614, - hyperliquid_chain, - destination: destination.to_string(), - amount: amount.to_string(), - time: timestamp, - }; + let usd_send = UsdSend::new( + self.http_client.chain, + destination.to_lowercase(), + amount.to_string(), + None, + ); + let timestamp = usd_send.time; let signature = sign_typed_data(&usd_send, wallet)?; - let action = serde_json::to_value(Actions::UsdSend(usd_send)) - .map_err(|e| Error::JsonParse(e.to_string()))?; + let action = Actions::UsdSend(usd_send); self.post(action, signature, timestamp).await } - pub async fn class_transfer( + pub async fn usd_class_transfer( &self, - usdc: f64, + amount: f64, to_perp: bool, wallet: Option<&PrivateKeySigner>, ) -> Result { - // payload expects usdc without decimals - let usdc = (usdc * 1e6).round() as u64; let wallet = wallet.unwrap_or(&self.wallet); - let timestamp = next_nonce(); - - let action = Actions::SpotUser(SpotUser { - class_transfer: ClassTransfer { usdc, to_perp }, - }); - let connection_id = action.hash(timestamp, self.vault_address)?; - let action = serde_json::to_value(&action).map_err(|e| Error::JsonParse(e.to_string()))?; - let is_mainnet = self.http_client.is_mainnet(); - let signature = sign_l1_action(wallet, connection_id, is_mainnet)?; + let usd_class_transfer = + UsdClassTransfer::new(self.http_client.chain, amount.to_string(), to_perp); + let timestamp = usd_class_transfer.nonce; + let signature = sign_typed_data(&usd_class_transfer, wallet)?; + let action = Actions::UsdClassTransfer(usd_class_transfer); self.post(action, signature, timestamp).await } @@ -250,36 +348,27 @@ impl ExchangeClient { ) -> Result { let wallet = wallet.unwrap_or(&self.wallet); - let hyperliquid_chain = if self.http_client.is_mainnet() { - "Mainnet".to_string() - } else { - "Testnet".to_string() - }; - - let timestamp = next_nonce(); - // Build fromSubAccount string (similar to Python SDK) let from_sub_account = self .vault_address .map_or_else(String::new, |vault_addr| format!("{vault_addr:?}")); - let send_asset = SendAsset { - signature_chain_id: 421614, - hyperliquid_chain, - destination: destination.to_string(), - source_dex: source_dex.to_string(), - destination_dex: destination_dex.to_string(), - token: token.to_string(), - amount: amount.to_string(), + let send_asset = SendAsset::new( + self.http_client.chain, + destination.to_lowercase(), + source_dex.to_string(), + destination_dex.to_string(), + token.to_string(), + amount.to_string(), from_sub_account, - nonce: timestamp, - }; - + None, + None, + ); + let timestamp = send_asset.nonce; let signature = sign_typed_data(&send_asset, wallet)?; - let action = serde_json::to_value(Actions::SendAsset(send_asset)) - .map_err(|e| Error::JsonParse(e.to_string()))?; - self.post(action, signature, timestamp).await + self.post(Actions::SendAsset(send_asset), signature, timestamp) + .await } pub async fn vault_transfer( @@ -302,8 +391,7 @@ impl ExchangeClient { is_deposit, usd, }); - let connection_id = action.hash(timestamp, self.vault_address)?; - let action = serde_json::to_value(&action).map_err(|e| Error::JsonParse(e.to_string()))?; + let connection_id = action.hash(timestamp, self.vault_address, self.expires_after)?; let is_mainnet = self.http_client.is_mainnet(); let signature = sign_l1_action(wallet, connection_id, is_mainnet)?; @@ -366,12 +454,7 @@ impl ExchangeClient { let slippage = params.slippage.unwrap_or(0.05); // Default 5% slippage let wallet = params.wallet.unwrap_or(&self.wallet); - let base_url = match self.http_client.base_url.as_str() { - "https://api.hyperliquid.xyz" => BaseUrl::Mainnet, - "https://api.hyperliquid-testnet.xyz" => BaseUrl::Testnet, - _ => return Err(Error::GenericRequest("Invalid base URL".to_string())), - }; - let info_client = InfoClient::new(None, Some(base_url)).await?; + let info_client = InfoClient::new(None, Some(self.http_client.chain)).await?; let user_state = info_client.user_state(wallet.address()).await?; let position = user_state @@ -414,12 +497,7 @@ impl ExchangeClient { slippage: f64, px: Option, ) -> Result<(f64, u32)> { - let base_url = match self.http_client.base_url.as_str() { - "https://api.hyperliquid.xyz" => BaseUrl::Mainnet, - "https://api.hyperliquid-testnet.xyz" => BaseUrl::Testnet, - _ => return Err(Error::GenericRequest("Invalid base URL".to_string())), - }; - let info_client = InfoClient::new(None, Some(base_url)).await?; + let info_client = InfoClient::new(None, Some(self.http_client.chain)).await?; let meta = info_client.meta().await?; let asset_meta = meta @@ -499,8 +577,7 @@ impl ExchangeClient { grouping: "na".to_string(), builder: None, }); - let connection_id = action.hash(timestamp, self.vault_address)?; - let action = serde_json::to_value(&action).map_err(|e| Error::JsonParse(e.to_string()))?; + let connection_id = action.hash(timestamp, self.vault_address, self.expires_after)?; let is_mainnet = self.http_client.is_mainnet(); let signature = sign_l1_action(wallet, connection_id, is_mainnet)?; @@ -529,8 +606,7 @@ impl ExchangeClient { grouping: "na".to_string(), builder: Some(builder), }); - let connection_id = action.hash(timestamp, self.vault_address)?; - let action = serde_json::to_value(&action).map_err(|e| Error::JsonParse(e.to_string()))?; + let connection_id = action.hash(timestamp, self.vault_address, self.expires_after)?; let is_mainnet = self.http_client.is_mainnet(); let signature = sign_l1_action(wallet, connection_id, is_mainnet)?; @@ -568,9 +644,8 @@ impl ExchangeClient { let action = Actions::Cancel(BulkCancel { cancels: transformed_cancels, }); - let connection_id = action.hash(timestamp, self.vault_address)?; + let connection_id = action.hash(timestamp, self.vault_address, self.expires_after)?; - let action = serde_json::to_value(&action).map_err(|e| Error::JsonParse(e.to_string()))?; let is_mainnet = self.http_client.is_mainnet(); let signature = sign_l1_action(wallet, connection_id, is_mainnet)?; @@ -604,9 +679,8 @@ impl ExchangeClient { let action = Actions::BatchModify(BulkModify { modifies: transformed_modifies, }); - let connection_id = action.hash(timestamp, self.vault_address)?; + let connection_id = action.hash(timestamp, self.vault_address, self.expires_after)?; - let action = serde_json::to_value(&action).map_err(|e| Error::JsonParse(e.to_string()))?; let is_mainnet = self.http_client.is_mainnet(); let signature = sign_l1_action(wallet, connection_id, is_mainnet)?; @@ -645,8 +719,7 @@ impl ExchangeClient { cancels: transformed_cancels, }); - let connection_id = action.hash(timestamp, self.vault_address)?; - let action = serde_json::to_value(&action).map_err(|e| Error::JsonParse(e.to_string()))?; + let connection_id = action.hash(timestamp, self.vault_address, self.expires_after)?; let is_mainnet = self.http_client.is_mainnet(); let signature = sign_l1_action(wallet, connection_id, is_mainnet)?; @@ -670,8 +743,8 @@ impl ExchangeClient { is_cross, leverage, }); - let connection_id = action.hash(timestamp, self.vault_address)?; - let action = serde_json::to_value(&action).map_err(|e| Error::JsonParse(e.to_string()))?; + let connection_id = action.hash(timestamp, self.vault_address, self.expires_after)?; + let is_mainnet = self.http_client.is_mainnet(); let signature = sign_l1_action(wallet, connection_id, is_mainnet)?; @@ -695,8 +768,8 @@ impl ExchangeClient { is_buy: true, ntli: amount, }); - let connection_id = action.hash(timestamp, self.vault_address)?; - let action = serde_json::to_value(&action).map_err(|e| Error::JsonParse(e.to_string()))?; + let connection_id = action.hash(timestamp, self.vault_address, self.expires_after)?; + let is_mainnet = self.http_client.is_mainnet(); let signature = sign_l1_action(wallet, connection_id, is_mainnet)?; @@ -709,24 +782,10 @@ impl ExchangeClient { ) -> Result<(B256, ExchangeResponseStatus)> { let wallet = wallet.unwrap_or(&self.wallet); let agent = PrivateKeySigner::random(); - - let hyperliquid_chain = if self.http_client.is_mainnet() { - "Mainnet".to_string() - } else { - "Testnet".to_string() - }; - - let nonce = next_nonce(); - let approve_agent = ApproveAgent { - signature_chain_id: 421614, - hyperliquid_chain, - agent_address: agent.address(), - agent_name: None, - nonce, - }; + let approve_agent = ApproveAgent::new(self.http_client.chain, agent.address(), None, None); + let nonce = approve_agent.nonce; let signature = sign_typed_data(&approve_agent, wallet)?; - let action = serde_json::to_value(Actions::ApproveAgent(approve_agent)) - .map_err(|e| Error::JsonParse(e.to_string()))?; + let action = Actions::ApproveAgent(approve_agent); Ok((agent.to_bytes(), self.post(action, signature, nonce).await?)) } @@ -737,23 +796,15 @@ impl ExchangeClient { wallet: Option<&PrivateKeySigner>, ) -> Result { let wallet = wallet.unwrap_or(&self.wallet); - let hyperliquid_chain = if self.http_client.is_mainnet() { - "Mainnet".to_string() - } else { - "Testnet".to_string() - }; - - let timestamp = next_nonce(); - let withdraw = Withdraw3 { - signature_chain_id: 421614, - hyperliquid_chain, - destination: destination.to_string(), - amount: amount.to_string(), - time: timestamp, - }; + let withdraw = Withdraw3::new( + self.http_client.chain, + destination.to_lowercase(), + amount.to_string(), + None, + ); + let timestamp = withdraw.time; let signature = sign_typed_data(&withdraw, wallet)?; - let action = serde_json::to_value(Actions::Withdraw3(withdraw)) - .map_err(|e| Error::JsonParse(e.to_string()))?; + let action = Actions::Withdraw3(withdraw); self.post(action, signature, timestamp).await } @@ -766,24 +817,16 @@ impl ExchangeClient { wallet: Option<&PrivateKeySigner>, ) -> Result { let wallet = wallet.unwrap_or(&self.wallet); - let hyperliquid_chain = if self.http_client.is_mainnet() { - "Mainnet".to_string() - } else { - "Testnet".to_string() - }; - - let timestamp = next_nonce(); - let spot_send = SpotSend { - signature_chain_id: 421614, - hyperliquid_chain, - destination: destination.to_string(), - amount: amount.to_string(), - time: timestamp, - token: token.to_string(), - }; + let spot_send = SpotSend::new( + self.http_client.chain, + destination.to_lowercase(), + token.to_string(), + amount.to_string(), + None, + ); + let timestamp = spot_send.time; let signature = sign_typed_data(&spot_send, wallet)?; - let action = serde_json::to_value(Actions::SpotSend(spot_send)) - .map_err(|e| Error::JsonParse(e.to_string()))?; + let action = Actions::SpotSend(spot_send); self.post(action, signature, timestamp).await } @@ -798,8 +841,7 @@ impl ExchangeClient { let action = Actions::SetReferrer(SetReferrer { code }); - let connection_id = action.hash(timestamp, self.vault_address)?; - let action = serde_json::to_value(&action).map_err(|e| Error::JsonParse(e.to_string()))?; + let connection_id = action.hash(timestamp, self.vault_address, self.expires_after)?; let is_mainnet = self.http_client.is_mainnet(); let signature = sign_l1_action(wallet, connection_id, is_mainnet)?; @@ -813,24 +855,11 @@ impl ExchangeClient { wallet: Option<&PrivateKeySigner>, ) -> Result { let wallet = wallet.unwrap_or(&self.wallet); - let timestamp = next_nonce(); - - let hyperliquid_chain = if self.http_client.is_mainnet() { - "Mainnet".to_string() - } else { - "Testnet".to_string() - }; - - let approve_builder_fee = ApproveBuilderFee { - signature_chain_id: 421614, - hyperliquid_chain, - builder, - max_fee_rate, - nonce: timestamp, - }; + let approve_builder_fee = + ApproveBuilderFee::new(self.http_client.chain, builder, max_fee_rate, None); + let timestamp = approve_builder_fee.nonce; let signature = sign_typed_data(&approve_builder_fee, wallet)?; - let action = serde_json::to_value(Actions::ApproveBuilderFee(approve_builder_fee)) - .map_err(|e| Error::JsonParse(e.to_string()))?; + let action = Actions::ApproveBuilderFee(approve_builder_fee); self.post(action, signature, timestamp).await } @@ -844,8 +873,7 @@ impl ExchangeClient { let timestamp = next_nonce(); let action = Actions::ScheduleCancel(ScheduleCancel { time }); - let connection_id = action.hash(timestamp, self.vault_address)?; - let action = serde_json::to_value(&action).map_err(|e| Error::JsonParse(e.to_string()))?; + let connection_id = action.hash(timestamp, self.vault_address, self.expires_after)?; let is_mainnet = self.http_client.is_mainnet(); let signature = sign_l1_action(wallet, connection_id, is_mainnet)?; @@ -860,13 +888,393 @@ impl ExchangeClient { let timestamp = next_nonce(); let action = Actions::ClaimRewards(ClaimRewards {}); - let connection_id = action.hash(timestamp, self.vault_address)?; - let action = serde_json::to_value(&action).map_err(|e| Error::JsonParse(e.to_string()))?; + let connection_id = action.hash(timestamp, self.vault_address, self.expires_after)?; let is_mainnet = self.http_client.is_mainnet(); let signature = sign_l1_action(wallet, connection_id, is_mainnet)?; self.post(action, signature, timestamp).await } + + // Multi-sig methods + + async fn post_multi_sig( + &self, + multi_sig_user: Address, + inner_action: Actions, + inner_signatures: Vec, + nonce: u64, + ) -> Result { + let multi_sig_action = MultiSigAction { + r#type: Some("multiSig".to_string()), + signature_chain_id: "0x1".to_string(), + signatures: inner_signatures, + payload: MultiSigPayload { + multi_sig_user: multi_sig_user.to_string().to_lowercase(), + outer_signer: self.wallet.address().to_string().to_lowercase(), + action: inner_action, + }, + }; + + let is_mainnet = self.http_client.is_mainnet(); + let signature = sign_multi_sig_action( + &self.wallet, + &multi_sig_action, + self.vault_address, + nonce, + self.expires_after, + is_mainnet, + )?; + + self.post(multi_sig_action, signature, nonce).await + } + + /// Convert a regular user account to a multi-sig account + pub async fn convert_to_multi_sig( + &self, + multi_sig_threshold: u64, + wallet: Option<&PrivateKeySigner>, + ) -> Result { + let wallet = wallet.unwrap_or(&self.wallet); + let convert_to_multi_sig = + ConvertToMultiSig::new(self.http_client.chain, multi_sig_threshold, None); + let timestamp = convert_to_multi_sig.time; + let signature = sign_typed_data(&convert_to_multi_sig, wallet)?; + let action = Actions::ConvertToMultiSig(convert_to_multi_sig); + + self.post(action, signature, timestamp).await + } + + /// Update authorized addresses for a multi-sig user + pub async fn update_multi_sig_addresses( + &self, + to_add: Vec
, + to_remove: Vec
, + wallet: Option<&PrivateKeySigner>, + ) -> Result { + let wallet = wallet.unwrap_or(&self.wallet); + let update_multi_sig_addresses = + UpdateMultiSigAddresses::new(self.http_client.chain, to_add, to_remove, None); + let timestamp = update_multi_sig_addresses.time; + let signature = sign_typed_data(&update_multi_sig_addresses, wallet)?; + let action = Actions::UpdateMultiSigAddresses(update_multi_sig_addresses); + + self.post(action, signature, timestamp).await + } + + /// Place an order on behalf of a multi-sig user with multiple signatures + pub async fn multi_sig_order( + &self, + multi_sig_user: Address, + order: ClientOrderRequest, + wallets: &[PrivateKeySigner], + ) -> Result { + let timestamp = next_nonce(); + let transformed_order = order.convert(&self.coin_to_asset)?; + + let action = Actions::Order(BulkOrder { + orders: vec![transformed_order], + grouping: "na".to_string(), + builder: None, + }); + + let is_mainnet = self.http_client.is_mainnet(); + let outer_signer = self.wallet.address(); + let signatures = sign_multi_sig_l1_action_payload( + wallets, + &action, + multi_sig_user, + outer_signer, + self.vault_address, + timestamp, + self.expires_after, + is_mainnet, + )?; + + self.post_multi_sig(multi_sig_user, action, signatures, timestamp) + .await + } + + /// Place an order on behalf of a multi-sig user with pre-collected signatures + /// + /// This is the recommended method for multi-sig orders where signatures + /// are collected from different parties independently. + /// + /// # Arguments + /// * `multi_sig_user` - The multi-sig user address + /// * `order` - The order to place + /// * `signatures` - Pre-collected signatures from multi-sig participants + /// + /// # Example + /// ```ignore + /// use hyperliquid_rust_sdk::*; + /// use alloy::signers::{local::PrivateKeySigner, Signature}; + /// + /// let sdk: ExchangeClient = todo!(); + /// let multi_sig_user: alloy::primitives::Address = todo!(); + /// let outer_signer: alloy::primitives::Address = todo!(); + /// + /// // Each participant signs the action independently + /// let wallet1: PrivateKeySigner = "0x...".parse()?; + /// let wallet2: PrivateKeySigner = "0x...".parse()?; + /// + /// let action = serde_json::json!({ + /// "type": "order", + /// "orders": [{"a": 0, "b": true, "p": "30000", "s": "0.1", "r": false, "t": {"limit": {"tif": "Gtc"}}}], + /// "grouping": "na" + /// }); + /// + /// let nonce = 123456789u64; + /// let sig1 = sign_multi_sig_l1_action_single( + /// &wallet1, + /// &action, + /// multi_sig_user, + /// outer_signer, + /// None, // vault_address + /// nonce, + /// None, // expires_after + /// true, // is_mainnet + /// )?; + /// let sig2 = sign_multi_sig_l1_action_single( + /// &wallet2, + /// &action, + /// multi_sig_user, + /// outer_signer, + /// None, + /// nonce, + /// None, + /// true, + /// )?; + /// + /// let order = ClientOrderRequest { + /// asset: "BTC".to_string(), + /// is_buy: true, + /// reduce_only: false, + /// limit_px: 30000.0, + /// sz: 0.1, + /// order_type: ClientOrderType::Limit(ClientLimit { + /// tif: "Gtc".to_string(), + /// }), + /// cloid: None, + /// }; + /// + /// let signatures = vec![sig1, sig2]; + /// sdk.multi_sig_order_with_signatures(multi_sig_user, order, signatures).await?; + /// ``` + pub async fn multi_sig_order_with_signatures( + &self, + multi_sig_user: Address, + order: ClientOrderRequest, + signatures: Vec, + ) -> Result { + let timestamp = next_nonce(); + let transformed_order = order.convert(&self.coin_to_asset)?; + + let action = Actions::Order(BulkOrder { + orders: vec![transformed_order], + grouping: "na".to_string(), + builder: None, + }); + + self.post_multi_sig(multi_sig_user, action, signatures, timestamp) + .await + } + + /// Send USDC from a multi-sig user with multiple signatures + pub async fn multi_sig_usdc_transfer( + &self, + multi_sig_user: Address, + amount: &str, + destination: &str, + wallets: &[PrivateKeySigner], + ) -> Result { + let send_asset = SendAsset::new( + self.http_client.chain, + destination.to_lowercase(), + "".to_string(), + "".to_string(), + "USDC".to_string(), + amount.to_string(), + "".to_string(), + Some(MultiSigExtension { + payload_multi_sig_user: format!("{:#x}", multi_sig_user).to_lowercase(), + outer_signer: format!("{:#x}", self.wallet.address()).to_lowercase(), + }), + None, + ); + let timestamp = send_asset.nonce; + let signatures = sign_typed_data_multi_sig(&send_asset, wallets)?; + + self.post_multi_sig( + multi_sig_user, + Actions::SendAsset(send_asset), + signatures, + timestamp, + ) + .await + } + + /// Send spot tokens from a multi-sig user with multiple signatures + pub async fn multi_sig_spot_transfer( + &self, + multi_sig_user: Address, + amount: &str, + destination: &str, + token: &str, + wallets: &[PrivateKeySigner], + ) -> Result { + let send_asset = SendAsset::new( + self.http_client.chain, + destination.to_lowercase(), + "".to_string(), + "".to_string(), + token.to_string(), + amount.to_string(), + "".to_string(), + Some(MultiSigExtension { + payload_multi_sig_user: format!("{:#x}", multi_sig_user).to_lowercase(), + outer_signer: format!("{:#x}", self.wallet.address()).to_lowercase(), + }), + None, + ); + let timestamp = send_asset.nonce; + let signatures = sign_typed_data_multi_sig(&send_asset, wallets)?; + + self.post_multi_sig( + multi_sig_user, + Actions::SendAsset(send_asset), + signatures, + timestamp, + ) + .await + } + + /// Send USDC from a multi-sig user with pre-collected signatures + /// + /// This is the recommended method for multi-sig transfers where signatures + /// are collected from different parties independently. + /// + /// # Arguments + /// * `multi_sig_user` - The multi-sig user address + /// * `amount` - The amount to transfer + /// * `destination` - The destination address + /// * `signatures` - Pre-collected signatures from multi-sig participants + /// + /// # Example + /// ```ignore + /// use hyperliquid_rust_sdk::*; + /// use alloy::signers::{local::PrivateKeySigner, Signature}; + /// + /// let sdk: ExchangeClient = todo!(); + /// let multi_sig_user: alloy::primitives::Address = todo!(); + /// + /// // Collect signatures from each participant + /// let wallet1: PrivateKeySigner = "0x...".parse()?; + /// let wallet2: PrivateKeySigner = "0x...".parse()?; + /// + /// // Each participant signs independently + /// let send_asset = SendAsset { + /// signature_chain_id: 421614, + /// hyperliquid_chain: "Mainnet".to_string(), + /// destination: "0x...".to_string(), + /// source_dex: "".to_string(), + /// destination_dex: "".to_string(), + /// token: "USDC".to_string(), + /// amount: "100".to_string(), + /// from_sub_account: "".to_string(), + /// nonce: 123456789, + /// multi_sig_ext: Some(MultiSigExtension { + /// payload_multi_sig_user: Some(format!("{:#x}", multi_sig_user).to_lowercase()), + /// outer_signer: Some("0x...".to_lowercase()), + /// }), + /// }; + /// + /// let sig1 = sign_multi_sig_user_signed_action_single(&wallet1, &send_asset)?; + /// let sig2 = sign_multi_sig_user_signed_action_single(&wallet2, &send_asset)?; + /// + /// // Submit with collected signatures + /// let signatures = vec![sig1, sig2]; + /// sdk.multi_sig_usdc_transfer_with_signatures( + /// multi_sig_user, + /// "100", + /// "0x...", + /// signatures, + /// ).await?; + /// ``` + pub async fn multi_sig_usdc_transfer_with_signatures( + &self, + multi_sig_user: Address, + amount: &str, + destination: &str, + signatures: Vec, + ) -> Result { + let send_asset = SendAsset::new( + self.http_client.chain, + destination.to_lowercase(), + "".to_string(), + "".to_string(), + "USDC".to_string(), + amount.to_string(), + "".to_string(), + Some(MultiSigExtension { + payload_multi_sig_user: format!("{:#x}", multi_sig_user).to_lowercase(), + outer_signer: format!("{:#x}", self.wallet.address()).to_lowercase(), + }), + None, + ); + let timestamp = send_asset.nonce; + + self.post_multi_sig( + multi_sig_user, + Actions::SendAsset(send_asset), + signatures, + timestamp, + ) + .await + } + + /// Send spot tokens from a multi-sig user with pre-collected signatures + /// + /// This is the recommended method for multi-sig transfers where signatures + /// are collected from different parties independently. + /// + /// # Arguments + /// * `multi_sig_user` - The multi-sig user address + /// * `amount` - The amount to transfer + /// * `destination` - The destination address + /// * `token` - The token to transfer (e.g., "PURR") + /// * `signatures` - Pre-collected signatures from multi-sig participants + pub async fn multi_sig_spot_transfer_with_signatures( + &self, + multi_sig_user: Address, + amount: &str, + destination: &str, + token: &str, + signatures: Vec, + ) -> Result { + let send_asset = SendAsset::new( + self.http_client.chain, + destination.to_lowercase(), + "".to_string(), + "".to_string(), + token.to_string(), + amount.to_string(), + "".to_string(), + Some(MultiSigExtension { + payload_multi_sig_user: format!("{:#x}", multi_sig_user).to_lowercase(), + outer_signer: format!("{:#x}", self.wallet.address()).to_lowercase(), + }), + None, + ); + let timestamp = send_asset.nonce; + + self.post_multi_sig( + multi_sig_user, + Actions::SendAsset(send_asset), + signatures, + timestamp, + ) + .await + } } fn round_to_decimals(value: f64, decimals: u32) -> f64 { @@ -919,7 +1327,7 @@ mod tests { grouping: "na".to_string(), builder: None, }); - let connection_id = action.hash(1583838, None)?; + let connection_id = action.hash(1583838, None, None)?; let signature = sign_l1_action(&wallet, connection_id, true)?; assert_eq!(signature.to_string(), "0x77957e58e70f43b6b68581f2dc42011fc384538a2e5b7bf42d5b936f19fbb67360721a8598727230f67080efee48c812a6a4442013fd3b0eed509171bef9f23f1c"); @@ -950,7 +1358,7 @@ mod tests { grouping: "na".to_string(), builder: None, }); - let connection_id = action.hash(1583838, None)?; + let connection_id = action.hash(1583838, None, None)?; let signature = sign_l1_action(&wallet, connection_id, true)?; assert_eq!(signature.to_string(), "0xd3e894092eb27098077145714630a77bbe3836120ee29df7d935d8510b03a08f456de5ec1be82aa65fc6ecda9ef928b0445e212517a98858cfaa251c4cd7552b1c"); @@ -995,7 +1403,7 @@ mod tests { grouping: "na".to_string(), builder: None, }); - let connection_id = action.hash(1583838, None)?; + let connection_id = action.hash(1583838, None, None)?; let signature = sign_l1_action(&wallet, connection_id, true)?; assert_eq!(signature.to_string(), mainnet_signature); @@ -1015,7 +1423,7 @@ mod tests { oid: 82382, }], }); - let connection_id = action.hash(1583838, None)?; + let connection_id = action.hash(1583838, None, None)?; let signature = sign_l1_action(&wallet, connection_id, true)?; assert_eq!(signature.to_string(), "0x02f76cc5b16e0810152fa0e14e7b219f49c361e3325f771544c6f54e157bf9fa17ed0afc11a98596be85d5cd9f86600aad515337318f7ab346e5ccc1b03425d51b"); @@ -1076,7 +1484,7 @@ mod tests { nonce: 1583838, }); - let connection_id = action.hash(1583838, None)?; + let connection_id = action.hash(1583838, None, None)?; assert_eq!( connection_id.to_string(), "0xbe889a23135fce39a37315424cc4ae910edea7b42a075457b15bf4a9f0a8cfa4" @@ -1089,7 +1497,7 @@ mod tests { fn test_claim_rewards_action_hashing() -> Result<()> { let wallet = get_wallet()?; let action = Actions::ClaimRewards(ClaimRewards {}); - let connection_id = action.hash(1583838, None)?; + let connection_id = action.hash(1583838, None, None)?; // Test mainnet signature let signature = sign_l1_action(&wallet, connection_id, true)?; @@ -1112,7 +1520,6 @@ mod tests { fn test_send_asset_signing() -> Result<()> { let wallet = get_wallet()?; - // Test mainnet - send asset to another address let mainnet_send = SendAsset { signature_chain_id: 421614, hyperliquid_chain: "Mainnet".to_string(), @@ -1123,12 +1530,11 @@ mod tests { amount: "100".to_string(), from_sub_account: "".to_string(), nonce: 1583838, + multi_sig_ext: None, }; let mainnet_signature = sign_typed_data(&mainnet_send, &wallet)?; - // Signature generated successfully - just verify it's a valid signature object - // Test testnet - send different token let testnet_send = SendAsset { signature_chain_id: 421614, hyperliquid_chain: "Testnet".to_string(), @@ -1139,13 +1545,12 @@ mod tests { amount: "50".to_string(), from_sub_account: "".to_string(), nonce: 1583838, + multi_sig_ext: None, }; let testnet_signature = sign_typed_data(&testnet_send, &wallet)?; - // Verify signatures are different for mainnet vs testnet assert_ne!(mainnet_signature, testnet_signature); - // Test with vault/subaccount let vault_send = SendAsset { signature_chain_id: 421614, hyperliquid_chain: "Mainnet".to_string(), @@ -1156,12 +1561,152 @@ mod tests { amount: "100".to_string(), from_sub_account: "0xabcdabcdabcdabcdabcdabcdabcdabcdabcdabcd".to_string(), nonce: 1583838, + multi_sig_ext: None, }; let vault_signature = sign_typed_data(&vault_send, &wallet)?; - // Verify vault signature is different from non-vault signature assert_ne!(mainnet_signature, vault_signature); Ok(()) } + + #[test] + fn test_convert_to_multi_sig_action_hashing() -> Result<()> { + use crate::ConvertToMultiSig; + + let wallet = get_wallet()?; + let action = Actions::ConvertToMultiSig(ConvertToMultiSig { + signature_chain_id: 421614, + hyperliquid_chain: "Testnet".to_string(), + multi_sig_threshold: 1, + time: 1690393044548, + }); + + let connection_id = action.hash(1583838, None, None)?; + + // Test mainnet signature + let mainnet_sig = sign_l1_action(&wallet, connection_id, true)?; + // Signature will be deterministic for this action + assert!(!mainnet_sig.r().is_zero()); + assert!(!mainnet_sig.s().is_zero()); + + // Test testnet signature + let testnet_sig = sign_l1_action(&wallet, connection_id, false)?; + assert_ne!(mainnet_sig, testnet_sig); + + Ok(()) + } + + #[test] + fn test_update_multi_sig_addresses_action_hashing() -> Result<()> { + use crate::UpdateMultiSigAddresses; + + let wallet = get_wallet()?; + let action = Actions::UpdateMultiSigAddresses(UpdateMultiSigAddresses { + signature_chain_id: 421614, + hyperliquid_chain: "Testnet".to_string(), + to_add: vec![ + address!("0x0D1d9635D0640821d15e323ac8AdADfA9c111414"), + address!("0x1234567890123456789012345678901234567890"), + ], + to_remove: vec![address!("0xabcdabcdabcdabcdabcdabcdabcdabcdabcdabcd")], + time: 1690393044548, + }); + + let connection_id = action.hash(1583838, None, None)?; + + // Test mainnet signature + let mainnet_sig = sign_l1_action(&wallet, connection_id, true)?; + assert!(!mainnet_sig.r().is_zero()); + assert!(!mainnet_sig.s().is_zero()); + + // Test testnet signature + let testnet_sig = sign_l1_action(&wallet, connection_id, false)?; + assert_ne!(mainnet_sig, testnet_sig); + + Ok(()) + } + + #[test] + fn test_multi_sig_payload_serialization() -> Result<()> { + // Test the new multi-sig wrapper structure + let wallet = get_wallet()?; + let multi_sig_user: Address = "0x0000000000000000000000000000000000000005" + .parse() + .map_err(|e| Error::GenericParse(format!("{:?}", e)))?; + + // Create inner action + let inner_action: Actions = serde_json::from_value(serde_json::json!({ + "type": "order", + "orders": [{ + "a": 0, + "b": true, + "p": "1100", + "s": "0.2", + "r": false, + "t": {"limit": {"tif": "Gtc"}}, + "c": null + }], + "grouping": "na" + })) + .unwrap(); + + // Create dummy signature + let connection_id = + B256::from_str("0xde6c4037798a4434ca03cd05f00e3b803126221375cd1e7eaaaf041768be06eb") + .map_err(|e| Error::GenericParse(e.to_string()))?; + let sig = sign_l1_action(&wallet, connection_id, true)?; + + // Create multi-sig action wrapper + let multi_sig_action = MultiSigAction { + r#type: Some("multiSig".to_string()), + signature_chain_id: "0x66eee".to_string(), + signatures: vec![sig], + payload: MultiSigPayload { + multi_sig_user: multi_sig_user.to_string().to_lowercase(), + outer_signer: wallet.address().to_string().to_lowercase(), + action: inner_action, + }, + }; + + let json = serde_json::to_string(&multi_sig_action).unwrap(); + assert!(json.contains("\"multiSig\"")); + assert!(json.contains("\"signatureChainId\"")); + assert!(json.contains("\"signatures\"")); + assert!(json.contains("\"payload\"")); + assert!(json.contains("\"multiSigUser\"")); + assert!(json.contains("\"outerSigner\"")); + + Ok(()) + } + + #[test] + fn test_multi_sig_threshold_validation() -> Result<()> { + use crate::ConvertToMultiSig; + + // Test that we can create actions with valid thresholds + let action = Actions::ConvertToMultiSig(ConvertToMultiSig { + signature_chain_id: 421614, + hyperliquid_chain: "Testnet".to_string(), + multi_sig_threshold: 1, + time: 1690393044548, + }); + + let connection_id = action.hash(1583838, None, None)?; + assert!(!connection_id.is_empty()); + + // Test with different threshold + let action2 = Actions::ConvertToMultiSig(ConvertToMultiSig { + signature_chain_id: 421614, + hyperliquid_chain: "Testnet".to_string(), + multi_sig_threshold: 2, + time: 1690393044548, + }); + + let connection_id2 = action2.hash(1583838, None, None)?; + assert!(!connection_id2.is_empty()); + assert_ne!(connection_id, connection_id2); + + Ok(()) + } } diff --git a/src/exchange/mod.rs b/src/exchange/mod.rs index 7b9e9042..8b1bb2c8 100644 --- a/src/exchange/mod.rs +++ b/src/exchange/mod.rs @@ -1,4 +1,4 @@ -mod actions; +pub mod actions; mod builder; mod cancel; mod exchange_client; diff --git a/src/helpers.rs b/src/helpers.rs index c642af7e..4dfd69d8 100644 --- a/src/helpers.rs +++ b/src/helpers.rs @@ -1,3 +1,4 @@ +use std::fmt::Display; use std::sync::atomic::{AtomicU64, Ordering}; use chrono::prelude::Utc; @@ -70,19 +71,37 @@ pub fn bps_diff(x: f64, y: f64) -> u16 { } } -#[derive(Copy, Clone)] -pub enum BaseUrl { +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub enum HyperliquidChain { Localhost, Testnet, Mainnet, } -impl BaseUrl { - pub(crate) fn get_url(&self) -> String { +impl HyperliquidChain { + pub(crate) fn url(&self) -> &'static str { match self { - BaseUrl::Localhost => LOCAL_API_URL.to_string(), - BaseUrl::Mainnet => MAINNET_API_URL.to_string(), - BaseUrl::Testnet => TESTNET_API_URL.to_string(), + HyperliquidChain::Localhost => LOCAL_API_URL, + HyperliquidChain::Mainnet => MAINNET_API_URL, + HyperliquidChain::Testnet => TESTNET_API_URL, + } + } + + pub(crate) fn action_chain_name(&self) -> String { + self.to_string() + } + + pub fn is_mainnet(&self) -> bool { + *self == HyperliquidChain::Mainnet + } +} + +impl Display for HyperliquidChain { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + HyperliquidChain::Localhost => write!(f, "Localhost"), + HyperliquidChain::Mainnet => write!(f, "Mainnet"), + HyperliquidChain::Testnet => write!(f, "Testnet"), } } } diff --git a/src/info/info_client.rs b/src/info/info_client.rs index b3d8ba2a..822337a9 100644 --- a/src/info/info_client.rs +++ b/src/info/info_client.rs @@ -15,7 +15,7 @@ use crate::{ prelude::*, req::HttpClient, ws::{Subscription, WsManager}, - BaseUrl, Error, Message, OrderStatusResponse, ReferralResponse, UserFeesResponse, + Error, HyperliquidChain, Message, OrderStatusResponse, ReferralResponse, UserFeesResponse, UserFundingResponse, UserTokenBalanceResponse, }; @@ -104,27 +104,33 @@ pub struct InfoClient { } impl InfoClient { - pub async fn new(client: Option, base_url: Option) -> Result { + pub async fn new( + client: Option, + base_url: Option, + ) -> Result { Self::new_internal(client, base_url, false).await } pub async fn with_reconnect( client: Option, - base_url: Option, + base_url: Option, ) -> Result { Self::new_internal(client, base_url, true).await } async fn new_internal( client: Option, - base_url: Option, + base_url: Option, reconnect: bool, ) -> Result { let client = client.unwrap_or_default(); - let base_url = base_url.unwrap_or(BaseUrl::Mainnet).get_url(); + let base_url = base_url.unwrap_or(HyperliquidChain::Mainnet); Ok(InfoClient { - http_client: HttpClient { client, base_url }, + http_client: HttpClient { + client, + chain: base_url, + }, ws_manager: None, reconnect, }) @@ -137,7 +143,7 @@ impl InfoClient { ) -> Result { if self.ws_manager.is_none() { let ws_manager = WsManager::new( - format!("ws{}/ws", &self.http_client.base_url[4..]), + format!("ws{}/ws", &self.http_client.chain.url()[4..]), self.reconnect, ) .await?; @@ -157,7 +163,7 @@ impl InfoClient { pub async fn unsubscribe(&mut self, subscription_id: u32) -> Result<()> { if self.ws_manager.is_none() { let ws_manager = WsManager::new( - format!("ws{}/ws", &self.http_client.base_url[4..]), + format!("ws{}/ws", &self.http_client.chain.url()[4..]), self.reconnect, ) .await?; diff --git a/src/lib.rs b/src/lib.rs index 86f20e2a..bb89a745 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -15,8 +15,9 @@ pub use consts::{EPSILON, LOCAL_API_URL, MAINNET_API_URL, TESTNET_API_URL}; pub use eip712::Eip712; pub use errors::Error; pub use exchange::*; -pub use helpers::{bps_diff, truncate_float, BaseUrl}; +pub use helpers::{bps_diff, truncate_float, HyperliquidChain}; pub use info::{info_client::*, *}; pub use market_maker::{MarketMaker, MarketMakerInput, MarketMakerRestingOrder}; pub use meta::{AssetContext, AssetMeta, Meta, MetaAndAssetCtxs, SpotAssetMeta, SpotMeta}; +pub use signature::*; pub use ws::*; diff --git a/src/market_maker.rs b/src/market_maker.rs index f5ae2297..531daa99 100644 --- a/src/market_maker.rs +++ b/src/market_maker.rs @@ -3,8 +3,8 @@ use log::{error, info}; use tokio::sync::mpsc::unbounded_channel; use crate::{ - bps_diff, truncate_float, BaseUrl, ClientCancelRequest, ClientLimit, ClientOrder, - ClientOrderRequest, ExchangeClient, ExchangeDataStatus, ExchangeResponseStatus, InfoClient, + bps_diff, truncate_float, ClientCancelRequest, ClientLimit, ClientOrder, ClientOrderRequest, + ExchangeClient, ExchangeDataStatus, ExchangeResponseStatus, HyperliquidChain, InfoClient, Message, Subscription, UserData, EPSILON, }; #[derive(Debug)] @@ -46,11 +46,18 @@ impl MarketMaker { pub async fn new(input: MarketMakerInput) -> MarketMaker { let user_address = input.wallet.address(); - let info_client = InfoClient::new(None, Some(BaseUrl::Testnet)).await.unwrap(); - let exchange_client = - ExchangeClient::new(None, input.wallet, Some(BaseUrl::Testnet), None, None) - .await - .unwrap(); + let info_client = InfoClient::new(None, Some(HyperliquidChain::Testnet)) + .await + .unwrap(); + let exchange_client = ExchangeClient::new( + None, + input.wallet, + Some(HyperliquidChain::Testnet), + None, + None, + ) + .await + .unwrap(); MarketMaker { asset: input.asset, diff --git a/src/req.rs b/src/req.rs index f7d5e430..c898464c 100644 --- a/src/req.rs +++ b/src/req.rs @@ -1,7 +1,7 @@ use reqwest::{Client, Response}; use serde::Deserialize; -use crate::{prelude::*, BaseUrl, Error}; +use crate::{prelude::*, Error, HyperliquidChain}; #[derive(Deserialize, Debug)] struct ErrorData { @@ -13,7 +13,7 @@ struct ErrorData { #[derive(Debug)] pub struct HttpClient { pub client: Client, - pub base_url: String, + pub chain: HyperliquidChain, } async fn parse_response(response: Response) -> Result { @@ -53,7 +53,7 @@ async fn parse_response(response: Response) -> Result { impl HttpClient { pub async fn post(&self, url_path: &'static str, data: String) -> Result { - let full_url = format!("{}{url_path}", self.base_url); + let full_url = format!("{}{url_path}", self.chain.url()); let request = self .client .post(full_url) @@ -70,6 +70,6 @@ impl HttpClient { } pub fn is_mainnet(&self) -> bool { - self.base_url == BaseUrl::Mainnet.get_url() + self.chain.is_mainnet() } } diff --git a/src/signature/agent.rs b/src/signature/agent.rs index 9ee8381d..c995a384 100644 --- a/src/signature/agent.rs +++ b/src/signature/agent.rs @@ -1,4 +1,4 @@ -pub(crate) mod l1 { +pub mod l1 { use alloy::{ dyn_abi::Eip712Domain, primitives::{Address, B256}, @@ -16,6 +16,16 @@ pub(crate) mod l1 { } } + impl Agent { + pub fn new(is_mainnet: bool, connection_id: B256) -> Agent { + let source = if is_mainnet { "a" } else { "b" }.to_string(); + Agent { + source, + connectionId: connection_id, + } + } + } + impl Eip712 for Agent { fn domain(&self) -> Eip712Domain { eip712_domain! { diff --git a/src/signature/create_signature.rs b/src/signature/create_signature.rs index 8e6e659c..cb2f617c 100644 --- a/src/signature/create_signature.rs +++ b/src/signature/create_signature.rs @@ -1,20 +1,16 @@ +use crate::exchange::MultiSigAction; +use crate::{eip712::Eip712, prelude::*, signature::agent::l1, Actions, Error}; use alloy::{ primitives::B256, signers::{local::PrivateKeySigner, Signature, SignerSync}, }; -use crate::{eip712::Eip712, prelude::*, signature::agent::l1, Error}; - -pub(crate) fn sign_l1_action( +pub fn sign_l1_action( wallet: &PrivateKeySigner, connection_id: B256, is_mainnet: bool, ) -> Result { - let source = if is_mainnet { "a" } else { "b" }.to_string(); - let payload = l1::Agent { - source, - connectionId: connection_id, - }; + let payload = l1::Agent::new(is_mainnet, connection_id); sign_typed_data(&payload, wallet) } @@ -27,6 +23,206 @@ pub(crate) fn sign_typed_data( .map_err(|e| Error::SignatureFailure(e.to_string())) } +/// Sign an L1 action with multiple wallets for multi-sig +pub(crate) fn sign_l1_action_multi_sig( + wallets: &[PrivateKeySigner], + connection_id: B256, + is_mainnet: bool, +) -> Result> { + let mut signatures = Vec::with_capacity(wallets.len()); + for wallet in wallets { + signatures.push(sign_l1_action(wallet, connection_id, is_mainnet)?); + } + Ok(signatures) +} + +/// Sign typed data with multiple wallets for multi-sig +pub(crate) fn sign_typed_data_multi_sig( + payload: &T, + wallets: &[PrivateKeySigner], +) -> Result> { + let mut signatures = Vec::with_capacity(wallets.len()); + for wallet in wallets { + signatures.push(sign_typed_data(payload, wallet)?); + } + Ok(signatures) +} + +#[allow(clippy::too_many_arguments)] +pub fn sign_multi_sig_l1_action_payload( + wallets: &[PrivateKeySigner], + action: &Actions, + multi_sig_user: alloy::primitives::Address, + outer_signer: alloy::primitives::Address, + vault_address: Option, + nonce: u64, + expires_after: Option, + is_mainnet: bool, +) -> Result> { + let multi_sig_user_str = format!("{:?}", multi_sig_user).to_lowercase(); + let outer_signer_str = format!("{:?}", outer_signer).to_lowercase(); + + let envelope = serde_json::json!([multi_sig_user_str, outer_signer_str, action]); + + let mut bytes = + rmp_serde::to_vec_named(&envelope).map_err(|e| Error::RmpParse(e.to_string()))?; + + bytes.extend(nonce.to_be_bytes()); + + if let Some(vault_address) = vault_address { + bytes.push(1); + bytes.extend(vault_address.as_slice()); + } else { + bytes.push(0); + } + + if let Some(expires_after) = expires_after { + bytes.push(0); + bytes.extend(expires_after.to_be_bytes()); + } + + let connection_id = alloy::primitives::keccak256(bytes); + + sign_l1_action_multi_sig(wallets, connection_id, is_mainnet) +} + +/// Sign a multi-sig action with the outer signer's wallet +/// This is called after the inner signatures have been collected +/// The function: +/// 1. Removes the "type" field from the multi_sig_action +/// 2. Computes the action hash using msgpack + nonce + vault_address + expires_after +/// 3. Creates and signs the MultiSigEnvelope +pub fn sign_multi_sig_action( + wallet: &PrivateKeySigner, + multi_sig_action: &MultiSigAction, + vault_address: Option, + nonce: u64, + expires_after: Option, + is_mainnet: bool, +) -> Result { + use crate::exchange::actions::MultiSigEnvelope; + use alloy::primitives::keccak256; + + // Remove the "type" field before hashing (as per Python SDK) + let mut action_without_type: MultiSigAction = multi_sig_action.clone(); + action_without_type.r#type = None; + + let mut bytes = rmp_serde::to_vec_named(&action_without_type) + .map_err(|e| Error::RmpParse(e.to_string()))?; + + bytes.extend(nonce.to_be_bytes()); + + if let Some(vault_address) = vault_address { + bytes.push(1); + bytes.extend(vault_address.as_slice()); + } else { + bytes.push(0); + } + + if let Some(expires_after) = expires_after { + bytes.push(0); + bytes.extend(expires_after.to_be_bytes()); + } + + let multi_sig_action_hash = keccak256(bytes); + + // Create the envelope to sign + let hyperliquid_chain = if is_mainnet { + "Mainnet".to_string() + } else { + "Testnet".to_string() + }; + + let signature_chain_id = u64::from_str_radix( + multi_sig_action.signature_chain_id.trim_start_matches("0x"), + 16, + ) + .map_err(|_| { + Error::GenericParse(format!( + "Invalid signature chain ID: {}", + multi_sig_action.signature_chain_id + )) + })?; + + let envelope = MultiSigEnvelope { + signature_chain_id, + hyperliquid_chain, + multi_sig_action_hash, + nonce, + }; + + sign_typed_data(&envelope, wallet) +} + +/// Sign a multi-sig user-signed action payload with a single wallet +/// This is used to collect individual signatures from multi-sig participants +/// +/// # Arguments +/// * `wallet` - The wallet of the multi-sig participant +/// * `action` - The SendAsset or other user-signed action to sign +/// +/// # Returns +/// A single signature that can be collected and combined with others +pub fn sign_multi_sig_user_signed_action_single( + wallet: &PrivateKeySigner, + action: &T, +) -> Result { + sign_typed_data(action, wallet) +} + +/// Sign a multi-sig L1 action payload with a single wallet +/// This is used to collect individual signatures from multi-sig participants for L1 actions +/// +/// # Arguments +/// * `wallet` - The wallet of the multi-sig participant +/// * `action` - The action to sign (e.g., order, cancel, etc.) +/// * `multi_sig_user` - The address of the multi-sig user +/// * `outer_signer` - The address of the wallet that will submit the transaction +/// * `vault_address` - Optional vault address +/// * `nonce` - The nonce for this action +/// * `expires_after` - Optional expiration timestamp +/// * `is_mainnet` - Whether this is for mainnet or testnet +/// +/// # Returns +/// A single signature that can be collected and combined with others +#[allow(clippy::too_many_arguments)] +pub fn sign_multi_sig_l1_action_single( + wallet: &PrivateKeySigner, + action: &Actions, + multi_sig_user: alloy::primitives::Address, + outer_signer: alloy::primitives::Address, + vault_address: Option, + nonce: u64, + expires_after: Option, + is_mainnet: bool, +) -> Result { + let multi_sig_user_str = format!("{:?}", multi_sig_user).to_lowercase(); + let outer_signer_str = format!("{:?}", outer_signer).to_lowercase(); + + let envelope = serde_json::json!([multi_sig_user_str, outer_signer_str, action]); + + let mut bytes = + rmp_serde::to_vec_named(&envelope).map_err(|e| Error::RmpParse(e.to_string()))?; + + bytes.extend(nonce.to_be_bytes()); + + if let Some(vault_address) = vault_address { + bytes.push(1); + bytes.extend(vault_address.as_slice()); + } else { + bytes.push(0); + } + + if let Some(expires_after) = expires_after { + bytes.push(0); + bytes.extend(expires_after.to_be_bytes()); + } + + let connection_id = alloy::primitives::keccak256(bytes); + + sign_l1_action(wallet, connection_id, is_mainnet) +} + #[cfg(test)] mod tests { use std::str::FromStr; @@ -100,4 +296,146 @@ mod tests { ); Ok(()) } + + #[test] + fn test_sign_l1_action_multi_sig() -> Result<()> { + // Create two test wallets + let wallet1 = "e908f86dbb4d55ac876378565aafeabc187f6690f046459397b17d9b9a19688e" + .parse::() + .map_err(|e| Error::Wallet(e.to_string()))?; + let wallet2 = "0000000000000000000000000000000000000000000000000000000000000001" + .parse::() + .map_err(|e| Error::Wallet(e.to_string()))?; + + let wallets = vec![wallet1, wallet2]; + let connection_id = + B256::from_str("0xde6c4037798a4434ca03cd05f00e3b803126221375cd1e7eaaaf041768be06eb") + .map_err(|e| Error::GenericParse(e.to_string()))?; + + // Test mainnet + let mainnet_sigs = sign_l1_action_multi_sig(&wallets, connection_id, true)?; + assert_eq!(mainnet_sigs.len(), 2); + assert_eq!( + mainnet_sigs[0].to_string(), + "0xfa8a41f6a3fa728206df80801a83bcbfbab08649cd34d9c0bfba7c7b2f99340f53a00226604567b98a1492803190d65a201d6805e5831b7044f17fd530aec7841c" + ); + + // Test testnet + let testnet_sigs = sign_l1_action_multi_sig(&wallets, connection_id, false)?; + assert_eq!(testnet_sigs.len(), 2); + assert_eq!( + testnet_sigs[0].to_string(), + "0x1713c0fc661b792a50e8ffdd59b637b1ed172d9a3aa4d801d9d88646710fb74b33959f4d075a7ccbec9f2374a6da21ffa4448d58d0413a0d335775f680a881431c" + ); + + Ok(()) + } + + #[test] + fn test_sign_typed_data_multi_sig() -> Result<()> { + // Create two test wallets + let wallet1 = "e908f86dbb4d55ac876378565aafeabc187f6690f046459397b17d9b9a19688e" + .parse::() + .map_err(|e| Error::Wallet(e.to_string()))?; + let wallet2 = "0000000000000000000000000000000000000000000000000000000000000001" + .parse::() + .map_err(|e| Error::Wallet(e.to_string()))?; + + let wallets = vec![wallet1, wallet2]; + + let usd_send = UsdSend { + signature_chain_id: 421614, + hyperliquid_chain: "Testnet".to_string(), + destination: "0x0D1d9635D0640821d15e323ac8AdADfA9c111414".to_string(), + amount: "1".to_string(), + time: 1690393044548, + }; + + let signatures = sign_typed_data_multi_sig(&usd_send, &wallets)?; + assert_eq!(signatures.len(), 2); + + // First signature should match the single-sig test + assert_eq!( + signatures[0].to_string(), + "0x214d507bbdaebba52fa60928f904a8b2df73673e3baba6133d66fe846c7ef70451e82453a6d8db124e7ed6e60fa00d4b7c46e4d96cb2bd61fd81b6e8953cc9d21b" + ); + + Ok(()) + } + + #[test] + fn test_multi_sig_with_single_wallet() -> Result<()> { + // Test that multi-sig works with a single wallet + let wallet = get_wallet()?; + let wallets = vec![wallet]; + let connection_id = + B256::from_str("0xde6c4037798a4434ca03cd05f00e3b803126221375cd1e7eaaaf041768be06eb") + .map_err(|e| Error::GenericParse(e.to_string()))?; + + let sigs = sign_l1_action_multi_sig(&wallets, connection_id, true)?; + assert_eq!(sigs.len(), 1); + + // Should match the single-sig result + assert_eq!( + sigs[0].to_string(), + "0xfa8a41f6a3fa728206df80801a83bcbfbab08649cd34d9c0bfba7c7b2f99340f53a00226604567b98a1492803190d65a201d6805e5831b7044f17fd530aec7841c" + ); + + Ok(()) + } + + #[test] + fn test_sign_multi_sig_l1_action_payload() -> Result<()> { + let wallet1 = "0x0123456789012345678901234567890123456789012345678901234567890123" + .parse::() + .map_err(|e| Error::Wallet(e.to_string()))?; + let wallet2 = "0x0000000000000000000000000000000000000000000000000000000000000001" + .parse::() + .map_err(|e| Error::Wallet(e.to_string()))?; + + let wallets = vec![wallet1, wallet2]; + + let multi_sig_user = + alloy::primitives::Address::from_str("0x0000000000000000000000000000000000000005") + .map_err(|e| Error::GenericParse(e.to_string()))?; + let outer_signer = + alloy::primitives::Address::from_str("0x0d1d9635d0640821d15e323ac8adadfa9c111414") + .map_err(|e| Error::GenericParse(e.to_string()))?; + + let action = serde_json::from_value(serde_json::json!({ + "type": "order", + "orders": [{"a": 4, "b": true, "p": "1100", "s": "0.2", "r": false, "t": {"limit": {"tif": "Gtc"}}}], + "grouping": "na" + })).unwrap(); + + let nonce = 0u64; + + let signatures_mainnet = sign_multi_sig_l1_action_payload( + &wallets, + &action, + multi_sig_user, + outer_signer, + None, + nonce, + None, + true, + )?; + + assert_eq!(signatures_mainnet.len(), 2); + + let signatures_testnet = sign_multi_sig_l1_action_payload( + &wallets, + &action, + multi_sig_user, + outer_signer, + None, + nonce, + None, + false, + )?; + + assert_eq!(signatures_testnet.len(), 2); + + Ok(()) + } } diff --git a/src/signature/mod.rs b/src/signature/mod.rs index 9f967148..64cc6d3e 100644 --- a/src/signature/mod.rs +++ b/src/signature/mod.rs @@ -1,4 +1,11 @@ -pub(crate) mod agent; +mod agent; mod create_signature; -pub(crate) use create_signature::{sign_l1_action, sign_typed_data}; +pub(crate) use create_signature::{sign_typed_data, sign_typed_data_multi_sig}; + +// Public API for signature collection +pub use agent::*; +pub use create_signature::{ + sign_l1_action, sign_multi_sig_action, sign_multi_sig_l1_action_payload, + sign_multi_sig_l1_action_single, sign_multi_sig_user_signed_action_single, +}; diff --git a/src/ws/ws_manager.rs b/src/ws/ws_manager.rs index 4035baf3..da686301 100755 --- a/src/ws/ws_manager.rs +++ b/src/ws/ws_manager.rs @@ -197,7 +197,9 @@ impl WsManager { match serde_json::to_string(&Ping { method: "ping" }) { Ok(payload) => { let mut writer = writer.lock().await; - if let Err(err) = writer.send(protocol::Message::Text(payload)).await { + if let Err(err) = + writer.send(protocol::Message::Text(payload.into())).await + { error!("Error pinging server: {err}") } } @@ -384,7 +386,7 @@ impl WsManager { .map_err(|e| Error::JsonParse(e.to_string()))?; writer - .send(protocol::Message::Text(payload)) + .send(protocol::Message::Text(payload.into())) .await .map_err(|e| Error::Websocket(e.to_string()))?; Ok(())