Loading source
Pulling the file list, source metadata, and syntax-aware rendering for this listing.
Source from repo
Trade perpetual futures and spot tokens on Hyperliquid DEX with 18 tools for orders, positions, funding, and USDC transfers.
Files
Skill
Size
Entrypoint
Format
Open file
Syntax-highlighted preview of this file as included in the skill package.
signing.py
1"""2Hyperliquid L1 Action Signing — phantom agent EIP-712 pattern.34Hyperliquid trading actions (orders, cancels, leverage, modify) require an EIP-7125"Agent" signature over a keccak256 hash of the msgpack-serialized action.67Since we don't have direct private key access (Privy server wallet), we delegate8EIP-712 signing to the wallet service's /agent/sign-typed-data endpoint.910Two signing schemes:111. L1 Action (orders, cancels, leverage) — msgpack → keccak → Agent struct122. User-Signed (USDC transfers, withdrawals) — direct EIP-712 over action fields1314The connectionId field (bytes32) encoding must match what the Privy wallet service15expects. We try multiple encodings and verify each with local ecrecover.16"""1718import logging19from typing import Optional2021import msgpack22from eth_utils import keccak2324from core.wallet_runtime import wallet_request as _wallet_request2526logger = logging.getLogger(__name__)2728# Hyperliquid chain constants29MAINNET_SOURCE = "a"3031# EIP-712 domains32L1_DOMAIN = {33"name": "Exchange",34"version": "1",35"chainId": 1337,36"verifyingContract": "0x0000000000000000000000000000000000000000",37}3839USER_SIGNED_DOMAIN = {40"name": "HyperliquidSignTransaction",41"version": "1",42"chainId": 42161, # Arbitrum mainnet43"verifyingContract": "0x0000000000000000000000000000000000000000",44}4546# EIP-712 types for Agent struct47AGENT_TYPES = {48"Agent": [49{"name": "source", "type": "string"},50{"name": "connectionId", "type": "bytes32"},51]52}535455# ── Wallet address cache ─────────────────────────────────────────────────5657_cached_wallet_address: Optional[str] = None585960async def _get_wallet_address() -> Optional[str]:61"""Get the Privy wallet address for signature verification (cached)."""62global _cached_wallet_address63if _cached_wallet_address:64return _cached_wallet_address6566try:67from core.wallet_runtime import is_fly_machine as _is_fly_machine68if not _is_fly_machine():69return None7071data = await _wallet_request("GET", "/agent/wallet")72wallets = data if isinstance(data, list) else data.get("wallets", [])73for w in wallets:74if w.get("chain_type") == "ethereum":75_cached_wallet_address = w["wallet_address"].lower()76return _cached_wallet_address77except Exception as e:78logger.debug(f"Could not fetch wallet address for verification: {e}")7980return None818283# ── EIP-712 hash computation (manual, no encode_typed_data dependency) ────8485def _eip712_domain_separator(domain: dict) -> bytes:86"""Compute EIP-712 domain separator hash."""87type_hash = keccak(88b"EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"89)90encoded = type_hash91encoded += keccak(domain["name"].encode())92encoded += keccak(domain["version"].encode())93encoded += domain["chainId"].to_bytes(32, "big")94addr = domain["verifyingContract"].replace("0x", "")95encoded += bytes.fromhex(addr).rjust(32, b"\x00")96return keccak(encoded)979899def _eip712_encode_field(field_type: str, value) -> bytes:100"""ABI-encode a single EIP-712 field value to 32 bytes."""101if field_type == "string":102return keccak(value.encode() if isinstance(value, str) else value)103elif field_type == "bytes32":104if isinstance(value, bytes):105return value.ljust(32, b"\x00")106elif isinstance(value, str):107return bytes.fromhex(value.replace("0x", "")).ljust(32, b"\x00")108elif isinstance(value, list):109return bytes(value).ljust(32, b"\x00")110elif field_type in ("uint256", "uint64"):111return int(value).to_bytes(32, "big")112elif field_type == "bool":113return int(bool(value)).to_bytes(32, "big")114elif field_type == "address":115return bytes.fromhex(value.replace("0x", "")).rjust(32, b"\x00")116raise ValueError(f"Unsupported EIP-712 type: {field_type}")117118119def _eip712_hash_struct(primary_type: str, types: dict, message: dict) -> bytes:120"""Compute hashStruct for an EIP-712 message."""121fields = types[primary_type]122type_string = (123primary_type124+ "("125+ ",".join(f"{f['type']} {f['name']}" for f in fields)126+ ")"127)128encoded = keccak(type_string.encode())129for field in fields:130encoded += _eip712_encode_field(field["type"], message[field["name"]])131return keccak(encoded)132133134def _eip712_digest(domain: dict, types: dict, primary_type: str, message: dict) -> bytes:135"""Compute the full EIP-712 digest: keccak256(0x1901 || domainSep || structHash)."""136domain_sep = _eip712_domain_separator(domain)137struct_hash = _eip712_hash_struct(primary_type, types, message)138return keccak(b"\x19\x01" + domain_sep + struct_hash)139140141# ── Local ecrecover verification ──────────────────────────────────────────142143def _verify_signature_locally(144domain: dict,145types: dict,146primary_type: str,147message: dict,148signature_hex: str,149expected_address: Optional[str],150) -> bool:151"""152Verify an EIP-712 signature locally using ecrecover.153154Computes the EIP-712 hash manually (no encode_typed_data dependency)155and recovers the signer address from the signature.156157Returns True if the recovered address matches expected_address.158"""159if not expected_address:160return True # Can't verify without address, assume OK161162try:163from eth_keys import keys164165digest = _eip712_digest(domain, types, primary_type, message)166167sig_bytes = bytes.fromhex(signature_hex.replace("0x", ""))168r = int.from_bytes(sig_bytes[:32], "big")169s = int.from_bytes(sig_bytes[32:64], "big")170v = sig_bytes[64]171if v >= 27:172v -= 27173174sig = keys.Signature(vrs=(v, r, s))175public_key = sig.recover_public_key_from_msg_hash(digest)176recovered = public_key.to_checksum_address()177recovered_lower = recovered.lower()178expected_lower = expected_address.lower()179180if recovered_lower == expected_lower:181logger.info(f"EIP-712 ecrecover OK: {recovered}")182return True183else:184logger.warning(185f"EIP-712 ecrecover MISMATCH: recovered={recovered}, expected={expected_address}"186)187return False188189except Exception as e:190logger.warning(f"Local ecrecover verification failed: {e}")191return False192193194def _signature_to_hex(sig: dict) -> str:195"""Convert {r, s, v} dict to flat hex signature string."""196r = sig["r"].replace("0x", "").zfill(64)197s = sig["s"].replace("0x", "").zfill(64)198v = sig["v"]199return "0x" + r + s + format(v, "02x")200201202# ── Core hashing ──────────────────────────────────────────────────────────203204def action_hash(action: dict, vault_address: Optional[str], nonce: int) -> bytes:205"""206Compute keccak256 hash of a Hyperliquid L1 action.207208Steps:2091. msgpack serialize the action dict2102. Append nonce as 8-byte big-endian2113. Append vault flag: 0x01 if vault_address else 0x002124. If vault, append 20 bytes of vault address2135. keccak256 the whole blob214"""215data = msgpack.packb(action)216data += nonce.to_bytes(8, "big")217218if vault_address:219data += b"\x01"220# Remove 0x prefix if present, decode hex to bytes221addr_bytes = bytes.fromhex(vault_address.replace("0x", ""))222data += addr_bytes223else:224data += b"\x00"225226return keccak(data)227228229# ── L1 Action Signing (orders, cancels, leverage) ────────────────────────230231async def sign_l1_action(232action: dict,233nonce: int,234vault_address: Optional[str] = None,235) -> dict:236"""237Sign an L1 action (order, cancel, leverage, modify) via wallet service.238239Tries multiple connectionId encodings and verifies each with local ecrecover.240Uses the first encoding that produces a signature matching the wallet address.241242Returns: {"r": "0x...", "s": "0x...", "v": 27|28}243"""244hash_bytes = action_hash(action, vault_address, nonce)245source = MAINNET_SOURCE246expected_address = await _get_wallet_address()247248# Multiple connectionId encodings to try249encodings = [250("hex_with_0x", "0x" + hash_bytes.hex()), # current: hex string with prefix251("byte_array", list(hash_bytes)), # byte array as list of ints252("hex_no_prefix", hash_bytes.hex()), # hex string without 0x prefix253]254255last_error = None256for encoding_name, connection_id_value in encodings:257try:258payload = {259"domain": L1_DOMAIN,260"types": AGENT_TYPES,261"primaryType": "Agent",262"message": {263"source": source,264"connectionId": connection_id_value,265},266}267268logger.info(269f"sign_l1_action: trying encoding={encoding_name}, "270f"connectionId type={type(connection_id_value).__name__}"271)272273result = await _wallet_request("POST", "/agent/sign-typed-data", payload)274sig = _parse_signature(result)275276# Verify locally with ecrecover277# For verification, connectionId must be the raw bytes32278verify_message = {279"source": source,280"connectionId": hash_bytes,281}282283sig_hex = _signature_to_hex(sig)284match = _verify_signature_locally(285domain=L1_DOMAIN,286types=AGENT_TYPES,287primary_type="Agent",288message=verify_message,289signature_hex=sig_hex,290expected_address=expected_address,291)292293if match:294logger.info(f"sign_l1_action: encoding={encoding_name} VERIFIED")295return sig296else:297logger.warning(298f"sign_l1_action: encoding={encoding_name} signature mismatch, trying next"299)300301except Exception as e:302logger.warning(f"sign_l1_action: encoding={encoding_name} failed: {e}")303last_error = e304305raise RuntimeError(306f"sign_l1_action: No encoding produced a verified signature. "307f"Expected address: {expected_address}. Last error: {last_error}"308)309310311# ── User-Signed Action (transfers, withdrawals, abstraction) ──────────────────────────312313async def sign_user_set_abstraction(314user: str,315abstraction: str,316nonce: int,317) -> dict:318"""Sign a user set abstraction action.319320Uses HyperliquidSignTransaction domain for user-signed actions.321Matches SDK's sign_user_set_abstraction_action exactly.322323Args:324user: User address (lowercase)325abstraction: Abstraction mode ("unifiedAccount", "portfolioMargin", "disabled")326nonce: Timestamp in milliseconds327328Returns: {"r": "0x...", "s": "0x...", "v": 27|28}329"""330types = {331"HyperliquidTransaction:UserSetAbstraction": [332{"name": "hyperliquidChain", "type": "string"},333{"name": "user", "type": "address"}, # ADDRESS, not string!334{"name": "abstraction", "type": "string"},335{"name": "nonce", "type": "uint64"},336]337}338339action = {340"hyperliquidChain": "Mainnet",341"user": user.lower(),342"abstraction": abstraction,343"nonce": nonce,344}345346return await sign_user_action(347action=action,348types=types,349primary_type="HyperliquidTransaction:UserSetAbstraction",350)351352353async def sign_user_action(354action: dict,355types: dict,356primary_type: str,357) -> dict:358"""359Sign a user-level action (USDC transfer, withdrawal) via wallet service.360Uses the HyperliquidSignTransaction domain (no msgpack/keccak).361362Verifies the signature locally with ecrecover before returning.363364Returns: {"r": "0x...", "s": "0x...", "v": 27|28}365"""366expected_address = await _get_wallet_address()367368payload = {369"domain": USER_SIGNED_DOMAIN,370"types": types,371"primaryType": primary_type,372"message": action,373}374375result = await _wallet_request("POST", "/agent/sign-typed-data", payload)376sig = _parse_signature(result)377378# Verify locally379sig_hex = _signature_to_hex(sig)380match = _verify_signature_locally(381domain=USER_SIGNED_DOMAIN,382types=types,383primary_type=primary_type,384message=action,385signature_hex=sig_hex,386expected_address=expected_address,387)388389if not match:390logger.error(391f"sign_user_action: signature mismatch! "392f"Expected address: {expected_address}, action type: {primary_type}"393)394395return sig396397398# ── Signature parsing ─────────────────────────────────────────────────────399400def _parse_signature(result: dict) -> dict:401"""402Parse signature from wallet service into {r, s, v} format.403404The wallet service may return either:405- A flat hex signature string in result["signature"]406- Already-split {r, s, v} components407"""408sig = result.get("signature", result)409410if isinstance(sig, str):411# Flat hex signature — split into r, s, v412sig_hex = sig.replace("0x", "")413if len(sig_hex) == 130:414r = "0x" + sig_hex[:64]415s = "0x" + sig_hex[64:128]416v = int(sig_hex[128:130], 16)417# Normalize v to 27/28418if v < 27:419v += 27420return {"r": r, "s": s, "v": v}421raise ValueError(f"Unexpected signature length: {len(sig_hex)}")422423if isinstance(sig, dict):424# Already has components425r = sig.get("r", "")426s = sig.get("s", "")427v = sig.get("v", 0)428if isinstance(v, str):429v = int(v, 16) if v.startswith("0x") else int(v)430if v < 27:431v += 27432return {"r": r, "s": s, "v": v}433434raise ValueError(f"Cannot parse signature from wallet response: {result}")435