use alloy::{primitives::B256, signers::local::PrivateKeySigner};
use chrono::{DateTime, Utc};
use serde_json::{Value, json};
use super::{
Address, Chain,
api::{Action, MultiSigPayload},
signing::multisig_collect_signatures_with_context,
};
fn fixtures() -> Value {
serde_json::from_str(include_str!("testdata/signing.json")).unwrap()
}
fn signer() -> PrivateKeySigner {
"0000000000000000000000000000000000000000000000000000000000000001"
.parse()
.unwrap()
}
#[tokio::test]
async fn user_signed_hashes_match_python_sdk() {
let fixtures = fixtures();
let signer = signer();
for case in fixtures["userSigned"].as_array().unwrap() {
let chain: Chain = case["chain"].as_str().unwrap().parse().unwrap();
let nonce = case["nonce"].as_u64().unwrap();
let action: Action = serde_json::from_value(case["action"].clone()).unwrap();
let expected: B256 = case["prehash"].as_str().unwrap().parse().unwrap();
assert_eq!(action.prehash(nonce, None, None, chain).unwrap(), expected);
let ordinary = action
.clone()
.sign(&signer, nonce, None, None, chain)
.await
.unwrap();
assert_eq!(ordinary.recover(chain).unwrap(), signer.address());
let payload = MultiSigPayload {
multi_sig_user: fixtures["multisigUser"].as_str().unwrap().into(),
outer_signer: fixtures["leader"].as_str().unwrap().into(),
action: Box::new(action),
};
let expected: B256 = case["multisigPrehash"].as_str().unwrap().parse().unwrap();
assert_eq!(payload.prehash(nonce, chain).unwrap(), expected);
let sig = payload.sign(&signer, nonce, chain).await.unwrap();
assert_eq!(
sig.to_string(),
payload
.sign_sync(&signer, nonce, chain)
.unwrap()
.to_string()
);
assert_eq!(
payload.recover(&sig, nonce, chain).unwrap(),
signer.address()
);
}
}
#[tokio::test]
async fn multisig_l1_context_hashes_match_python_sdk() {
let fixtures = fixtures();
let signer = signer();
for case in fixtures["l1Contexts"].as_array().unwrap() {
let chain = case["chain"].as_str().unwrap().parse().unwrap();
let nonce = case["nonce"].as_u64().unwrap();
let vault = case["vault"].as_str().map(|s| s.parse().unwrap());
let expiry = case["expiresAfter"]
.as_i64()
.map(|t| DateTime::<Utc>::from_timestamp_millis(t).unwrap());
let action = serde_json::from_value(case["action"].clone()).unwrap();
let multi = multisig_collect_signatures_with_context(
signer.address(),
fixtures["multisigUser"].as_str().unwrap().parse().unwrap(),
std::iter::once(&signer),
std::iter::empty(),
action,
nonce,
vault,
expiry,
chain,
)
.await
.unwrap();
let expected: B256 = case["prehash"].as_str().unwrap().parse().unwrap();
assert_eq!(
multi
.payload
.prehash_with_context(nonce, vault, expiry, chain)
.unwrap(),
expected
);
let sig = &multi.signatures[0];
assert_eq!(
multi
.payload
.recover_with_context(sig, nonce, vault, expiry, chain)
.unwrap(),
signer.address()
);
assert_eq!(
sig.to_string(),
multi
.payload
.sign_sync_with_context(&signer, nonce, vault, expiry, chain)
.unwrap()
.to_string()
);
if vault.is_some() || expiry.is_some() {
assert_ne!(
multi.payload.recover(sig, nonce, chain).unwrap(),
signer.address()
);
}
let request =
super::signing::multisig_lead_msg(&signer, multi, nonce, vault, expiry, chain)
.await
.unwrap();
assert_eq!(request.vault_address, vault);
assert_eq!(
request.expires_after,
expiry.map(|t| t.timestamp_millis() as u64)
);
assert_eq!(request.recover(chain).unwrap(), signer.address());
}
}
#[test]
fn unnamed_agent_serializes_and_signs_as_empty_string() {
let mut case = fixtures()["userSigned"][0]["action"].clone();
case["agentName"] = Value::Null;
let action: Action = serde_json::from_value(case).unwrap();
assert_eq!(serde_json::to_value(&action).unwrap()["agentName"], "");
let expected: B256 = fixtures()["userSigned"][0]["prehash"]
.as_str()
.unwrap()
.parse()
.unwrap();
assert_eq!(
action
.prehash(1_700_000_000_000, None, None, Chain::Mainnet)
.unwrap(),
expected
);
}
#[test]
fn all_user_signed_actions_get_multisig_fields() {
let address = "0x2222222222222222222222222222222222222222";
let multisig: Address = "0x1111111111111111111111111111111111111111"
.parse()
.unwrap();
let signer = signer();
let cases = [
json!({"type":"usdSend", "destination":address,"amount":"10", "time":1}),
json!({"type":"spotSend", "destination":address,"amount":"10", "token":"USDC:0x1", "time":1}),
json!({"type":"sendAsset", "destination":address,"amount":"10", "token":"USDC:0x1", "sourceDex":"spot", "destinationDex":"", "fromSubAccount":""}),
json!({"type":"sendToEvmWithData", "token":"USDC:0x1", "amount":"10", "sourceDex":"spot", "destinationRecipient":address, "addressEncoding":"hex", "destinationChainId":1,"gasLimit":100000,"data":"0x1234"}),
json!({"type":"approveAgent", "agentAddress":address,"agentName":null}),
json!({"type":"approveBuilderFee", "builder":address,"maxFeeRate":"0.001%"}),
json!({"type":"convertToMultiSigUser", "signers":"null"}),
json!({"type":"userDexAbstraction", "user":address,"enabled":true}),
json!({"type":"userSetAbstraction", "user":address,"abstraction":"unifiedAccount"}),
json!({"type":"userPortfolioMargin", "user":address,"enabled":true}),
json!({"type":"linkStakingUser", "user":address,"isFinalize":false}),
json!({"type":"stakingLinkDisableTradingUser", "tradingUser":address}),
json!({"type":"withdraw3", "destination":address,"amount":"10", "time":1}),
json!({"type":"usdClassTransfer", "amount":"10", "toPerp":true}),
json!({"type":"tokenDelegate", "validator":address,"wei":1000,"isUndelegate":false}),
];
for mut case in cases {
case["signatureChainId"] = "0xa4b1".into();
case["hyperliquidChain"] = "Mainnet".into();
case["nonce"] = 1.into();
let action: Action = serde_json::from_value(case.clone()).unwrap();
let data = action
.typed_data_multisig(multisig, signer.address(), Chain::Mainnet)
.unwrap_or_else(|| panic!("missing multisig schema for {}", case["type"]));
let fields = serde_json::to_value(&data.resolver).unwrap();
let names: Vec<_> = fields[&data.primary_type]
.as_array()
.unwrap()
.iter()
.map(|field| field["name"].as_str().unwrap())
.collect();
assert_eq!(
&names[..3],
&["hyperliquidChain", "payloadMultiSigUser", "outerSigner"]
);
let payload = MultiSigPayload {
multi_sig_user: const_hex::encode_prefixed(multisig.as_slice()),
outer_signer: const_hex::encode_prefixed(signer.address().as_slice()),
action: Box::new(action),
};
let signature = payload.sign_sync(&signer, 1, Chain::Mainnet).unwrap();
assert_eq!(
payload.recover(&signature, 1, Chain::Mainnet).unwrap(),
signer.address()
);
}
assert!(
Action::Noop
.typed_data_multisig(multisig, signer.address(), Chain::Mainnet)
.is_none()
);
}