Files

467 lines
16 KiB
Python

import asyncio
import binascii
import hashlib
import time
import uuid
from typing import Dict, Any, Optional, Tuple, List, Union
import json
import aiohttp
from Crypto.Hash import keccak
from .signing_adapter import SigningAdapter
# Import field prime for modular arithmetic
try:
from ..crypto.constants import FIELD_PRIME
except ImportError:
# Fallback if crypto module is not available
FIELD_PRIME = 0x800000000000011000000000000000000000000000000000000000000000001
# Constants
LIMIT_ORDER_WITH_FEE_TYPE = 3
class L2Signature:
"""Represents a signature for L2 operations."""
def __init__(self, r: str, s: str, v: str = ""):
self.r = r
self.s = s
self.v = v
class AsyncClient:
"""Async base client with common functionality."""
def __init__(self, base_url: str, account_id: int, stark_pri_key: str,
signing_adapter: Optional[SigningAdapter] = None,
timeout: float = 30.0, connector_limit: int = 100):
"""
Initialize the async internal client.
Args:
base_url: Base URL for API endpoints
account_id: Account ID for authentication
stark_pri_key: Stark private key for signing
signing_adapter: Optional signing adapter to use for cryptographic operations
timeout: Request timeout in seconds
connector_limit: Maximum number of connections in the pool
"""
self.base_url = base_url
self.account_id = account_id
self.stark_pri_key = stark_pri_key
# Use the provided signing adapter (required)
if signing_adapter is None:
raise ValueError("signing_adapter is required")
self.signing_adapter = signing_adapter
# Store configuration for later session creation
self._session = None
self._timeout = timeout
self._connector_limit = connector_limit
self._closed = False
async def __aenter__(self):
"""Async context manager entry."""
await self._ensure_session()
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
"""Async context manager exit."""
await self.close()
async def _ensure_session(self):
"""Ensure the aiohttp session is created."""
if self._session is None or self._session.closed:
# Create connector and session when needed (inside event loop)
timeout_config = aiohttp.ClientTimeout(total=self._timeout)
connector = aiohttp.TCPConnector(
limit=self._connector_limit,
limit_per_host=30,
keepalive_timeout=30,
enable_cleanup_closed=True
)
self._session = aiohttp.ClientSession(
timeout=timeout_config,
connector=connector,
headers={
"Content-Type": "application/json",
"Accept": "application/json"
}
)
async def close(self):
"""Close the HTTP session and cleanup resources."""
if self._session and not self._session.closed:
await self._session.close()
self._closed = True
@property
def session(self) -> aiohttp.ClientSession:
"""Get the HTTP session, ensuring it's created."""
if self._session is None or self._session.closed:
raise RuntimeError("Session not initialized. Use 'async with client:' or call '_ensure_session()'")
return self._session
def get_account_id(self) -> int:
"""Get the account ID."""
return self.account_id
def get_stark_pri_key(self) -> str:
"""Get the stark private key."""
return self.stark_pri_key
def sign(self, message_hash: bytes) -> L2Signature:
"""
Sign a message hash using the client's Stark private key.
Args:
message_hash: The hash of the message to sign
Returns:
L2Signature: The signature components
Raises:
ValueError: If the stark private key is not set or invalid
"""
private_key = self.get_stark_pri_key()
if not private_key:
raise ValueError("stark private key not set")
# Sign the message using the signing adapter
try:
r, s = self.signing_adapter.sign(message_hash, private_key)
return L2Signature(r=r, s=s, v="")
except Exception as e:
raise ValueError(f"failed to sign message: {str(e)}")
def generate_uuid(self) -> str:
"""Generate a UUID for client order IDs."""
return str(uuid.uuid4())
def calc_nonce(self, client_order_id: str) -> int:
"""
Calculate a nonce from a client order ID.
Args:
client_order_id: The client order ID
Returns:
int: The calculated nonce
"""
# Use SHA256 like the Go SDK (not Keccak256)
h = hashlib.sha256()
h.update(client_order_id.encode())
hash_hex = h.hexdigest()
return int(hash_hex[:8], 16)
async def make_authenticated_request(
self,
method: str,
path: str,
data: Optional[Dict[str, Any]] = None,
params: Optional[Dict[str, Any]] = None
) -> Dict[str, Any]:
"""
Make an authenticated HTTP request.
Args:
method: HTTP method (GET, POST, etc.)
path: API path (e.g., '/api/v1/private/order/createOrder')
data: JSON data for POST requests
params: Query parameters for GET requests
Returns:
Dict[str, Any]: Response JSON data
Raises:
ValueError: If the request fails
"""
await self._ensure_session()
# Generate timestamp
timestamp = int(time.time() * 1000)
# Build full URL
url = f"{self.base_url}{path}"
# Generate signature content
sign_content = self._build_signature_content(timestamp, method, path, data, params)
# Sign the content
keccak_hash = keccak.new(digest_bits=256)
keccak_hash.update(sign_content.encode())
content_hash = keccak_hash.digest()
sig = self.sign(content_hash)
# Prepare headers
headers = {
"X-edgeX-Api-Timestamp": str(timestamp),
"X-edgeX-Api-Signature": f"{sig.r}{sig.s}"
}
# Make the request
try:
async with self.session.request(
method=method,
url=url,
json=data,
params=params,
headers=headers
) as response:
if response.status != 200:
try:
error_detail = await response.json()
raise ValueError(f"request failed with status code: {response.status}, response: {error_detail}")
except (aiohttp.ContentTypeError, json.JSONDecodeError):
text = await response.text()
raise ValueError(f"request failed with status code: {response.status}, response: {text}")
resp_data = await response.json()
# Check response code
if resp_data.get("code") != "SUCCESS":
error_param = resp_data.get("errorParam")
if error_param:
raise ValueError(f"request failed with error params: {error_param}")
raise ValueError(f"request failed with code: {resp_data.get('code')}")
return resp_data
except aiohttp.ClientError as e:
raise ValueError(f"HTTP request failed: {str(e)}")
def _build_signature_content(
self,
timestamp: int,
method: str,
path: str,
data: Optional[Dict[str, Any]],
params: Optional[Dict[str, Any]]
) -> str:
"""Build the content string for signature generation."""
if data:
# Convert body to sorted string format
body_str = self.get_value(data)
sign_content = f"{timestamp}{method}{path}{body_str}"
else:
# For requests without body, use query parameters if present
if params:
# Sort query parameters as strings (matching Go SDK exactly)
param_pairs = []
for key, value in sorted(params.items()):
param_pairs.append(f"{key}={value}")
query_string = "&".join(param_pairs)
sign_content = f"{timestamp}{method}{path}{query_string}"
else:
sign_content = f"{timestamp}{method}{path}"
return sign_content
def get_value(self, data: Union[Dict[str, Any], List[Any], str, int, float, None]) -> str:
"""
Convert a value to a string representation for signing.
This function recursively processes dictionaries, lists, and primitive types.
Args:
data: The value to convert
Returns:
str: The string representation
"""
if data is None:
return ""
if isinstance(data, str):
return data
if isinstance(data, bool):
# Convert boolean to lowercase string to match Go SDK
return str(data).lower()
if isinstance(data, (int, float)):
return str(data)
if isinstance(data, list):
if len(data) == 0:
return ""
values = [self.get_value(item) for item in data]
return "&".join(values)
if isinstance(data, dict):
# Convert all values to strings and sort by keys
sorted_map = {}
for key, val in data.items():
sorted_map[key] = self.get_value(val)
# Get sorted keys
keys = sorted(sorted_map.keys())
# Build key=value pairs
pairs = [f"{key}={sorted_map[key]}" for key in keys]
return "&".join(pairs)
# Handle other types by converting to string
return str(data)
def calc_limit_order_hash(
self,
synthetic_asset_id: str,
collateral_asset_id: str,
fee_asset_id: str,
is_buy: bool,
amount_synthetic: int,
amount_collateral: int,
amount_fee: int,
nonce: int,
account_id: int,
expire_time: int
) -> bytes:
"""
Calculate the hash for a limit order using StarkEx protocol.
Args:
synthetic_asset_id: The synthetic asset ID (hex string)
collateral_asset_id: The collateral asset ID (hex string)
fee_asset_id: The fee asset ID (hex string)
is_buy: Whether the order is a buy order
amount_synthetic: The synthetic amount
amount_collateral: The collateral amount
amount_fee: The fee amount
nonce: The nonce
account_id: The account ID (position ID)
expire_time: The expiration time
Returns:
bytes: The calculated hash
"""
# Remove 0x prefix if present
if synthetic_asset_id.startswith('0x'):
synthetic_asset_id = synthetic_asset_id[2:]
if collateral_asset_id.startswith('0x'):
collateral_asset_id = collateral_asset_id[2:]
if fee_asset_id.startswith('0x'):
fee_asset_id = fee_asset_id[2:]
# Convert hex strings to integers and ensure they're within the field
asset_id_synthetic = int(synthetic_asset_id, 16) % FIELD_PRIME
asset_id_collateral = int(collateral_asset_id, 16) % FIELD_PRIME
asset_id_fee = int(fee_asset_id, 16) % FIELD_PRIME
# Determine buy/sell assets based on order direction
if is_buy:
asset_id_sell = asset_id_collateral
asset_id_buy = asset_id_synthetic
amount_sell = amount_collateral
amount_buy = amount_synthetic
else:
asset_id_sell = asset_id_synthetic
asset_id_buy = asset_id_collateral
amount_sell = amount_synthetic
amount_buy = amount_collateral
# Use the signing adapter to calculate the Pedersen hash
# First hash: hash(asset_id_sell, asset_id_buy)
msg = self.signing_adapter.pedersen_hash([asset_id_sell, asset_id_buy])
msg_int = int.from_bytes(msg, byteorder='big')
# Second hash: hash(msg, asset_id_fee)
msg = self.signing_adapter.pedersen_hash([msg_int, asset_id_fee])
msg_int = int.from_bytes(msg, byteorder='big')
# Pack message 0
# packed_message0 = amount_sell * 2^64 + amount_buy * 2^64 + max_amount_fee * 2^32 + nonce
packed_message0 = amount_sell
packed_message0 = (packed_message0 << 64) + amount_buy
packed_message0 = (packed_message0 << 64) + amount_fee
packed_message0 = (packed_message0 << 32) + nonce
packed_message0 = packed_message0 % FIELD_PRIME # Ensure within field
# Third hash: hash(msg, packed_message0)
msg = self.signing_adapter.pedersen_hash([msg_int, packed_message0])
msg_int = int.from_bytes(msg, byteorder='big')
# Pack message 1
# packed_message1 = LIMIT_ORDER_WITH_FEES * 2^64 + position_id * 2^64 + position_id * 2^64 + position_id * 2^32 + expiration_timestamp * 2^17
packed_message1 = LIMIT_ORDER_WITH_FEE_TYPE
packed_message1 = (packed_message1 << 64) + account_id
packed_message1 = (packed_message1 << 64) + account_id
packed_message1 = (packed_message1 << 64) + account_id
packed_message1 = (packed_message1 << 32) + expire_time
packed_message1 = packed_message1 << 17 # Padding
packed_message1 = packed_message1 % FIELD_PRIME # Ensure within field
# Final hash: hash(msg, packed_message1)
msg = self.signing_adapter.pedersen_hash([msg_int, packed_message1])
return msg
def calc_transfer_hash(
self,
asset_id: int,
asset_id_fee: int,
receiver_public_key: int,
sender_position_id: int,
receiver_position_id: int,
fee_position_id: int,
nonce: int,
amount: int,
max_amount_fee: int,
expiration_timestamp: int
) -> bytes:
"""
Calculate the hash for a transfer using StarkEx protocol.
Args:
asset_id: The asset ID
asset_id_fee: The fee asset ID
receiver_public_key: The receiver's public key
sender_position_id: The sender's position ID
receiver_position_id: The receiver's position ID
fee_position_id: The fee position ID
nonce: The nonce
amount: The transfer amount
max_amount_fee: The maximum fee amount
expiration_timestamp: The expiration timestamp
Returns:
bytes: The calculated hash
"""
# First hash: hash(asset_id, asset_id_fee)
msg = self.signing_adapter.pedersen_hash([asset_id, asset_id_fee])
msg_int = int.from_bytes(msg, byteorder='big')
# Second hash: hash(msg, receiver_public_key)
msg = self.signing_adapter.pedersen_hash([msg_int, receiver_public_key])
msg_int = int.from_bytes(msg, byteorder='big')
# Pack message 0
# packed_msg0 = sender_position_id * 2^64 + receiver_position_id * 2^64 + fee_position_id * 2^32 + nonce
packed_msg0 = sender_position_id
packed_msg0 = (packed_msg0 << 64) + receiver_position_id
packed_msg0 = (packed_msg0 << 64) + fee_position_id
packed_msg0 = (packed_msg0 << 32) + nonce
packed_msg0 = packed_msg0 % FIELD_PRIME # Ensure within field
# Third hash: hash(msg, packed_msg0)
msg = self.signing_adapter.pedersen_hash([msg_int, packed_msg0])
msg_int = int.from_bytes(msg, byteorder='big')
# Pack message 1
# packed_msg1 = 4 * 2^64 + amount * 2^64 + max_amount_fee * 2^32 + expiration_timestamp * 2^81
packed_msg1 = 4 # Transfer type
packed_msg1 = (packed_msg1 << 64) + amount
packed_msg1 = (packed_msg1 << 64) + max_amount_fee
packed_msg1 = (packed_msg1 << 32) + expiration_timestamp
packed_msg1 = packed_msg1 << 81 # Padding
packed_msg1 = packed_msg1 % FIELD_PRIME # Ensure within field
# Final hash: hash(msg, packed_msg1)
msg = self.signing_adapter.pedersen_hash([msg_int, packed_msg1])
return msg