feat: 添加 EdgeX 交易所适配器及相关客户端实现,支持订单、深度和K线数据处理

This commit is contained in:
discountry
2025-10-04 20:15:41 +08:00
parent ccc3a2bf89
commit 627ed36de4
52 changed files with 15892 additions and 2 deletions
@@ -0,0 +1,72 @@
"""
EdgeX Python SDK - A Python SDK for interacting with the EdgeX Exchange API.
"""
from .client import Client
from .internal.signing_adapter import SigningAdapter
from .internal.starkex_signing_adapter import StarkExSigningAdapter
from .order.types import (
OrderType,
OrderSide,
TimeInForce,
CreateOrderParams,
CancelOrderParams,
GetActiveOrderParams,
OrderFillTransactionParams
)
from .account.client import (
GetPositionTransactionPageParams,
GetCollateralTransactionPageParams,
GetPositionTermPageParams,
GetAccountAssetSnapshotPageParams
)
from .quote.client import (
GetKLineParams,
GetOrderBookDepthParams,
GetMultiContractKLineParams
)
from .transfer.client import (
GetTransferOutByIdParams,
GetTransferInByIdParams,
GetWithdrawAvailableAmountParams,
CreateTransferOutParams,
GetTransferOutPageParams,
GetTransferInPageParams
)
from .asset.client import (
GetAssetOrdersParams,
CreateWithdrawalParams,
GetWithdrawalRecordsParams
)
from .ws.manager import Manager as WebSocketManager
__version__ = "0.2.0"
__all__ = [
"Client",
"OrderType",
"OrderSide",
"TimeInForce",
"CreateOrderParams",
"CancelOrderParams",
"GetActiveOrderParams",
"OrderFillTransactionParams",
"GetPositionTransactionPageParams",
"GetCollateralTransactionPageParams",
"GetPositionTermPageParams",
"GetAccountAssetSnapshotPageParams",
"GetKLineParams",
"GetOrderBookDepthParams",
"GetMultiContractKLineParams",
"GetTransferOutByIdParams",
"GetTransferInByIdParams",
"GetWithdrawAvailableAmountParams",
"CreateTransferOutParams",
"GetTransferOutPageParams",
"GetTransferInPageParams",
"GetAssetOrdersParams",
"CreateWithdrawalParams",
"GetWithdrawalRecordsParams",
"WebSocketManager",
"SigningAdapter",
"StarkExSigningAdapter"
]
@@ -0,0 +1,437 @@
from typing import Dict, Any, List, Optional
from ..internal.async_client import AsyncClient
class GetPositionTransactionPageParams:
"""Parameters for getting position transactions with pagination."""
def __init__(
self,
size: str = "",
offset_data: str = "",
filter_contract_id_list: List[str] = None,
filter_start_created_time_inclusive: int = 0,
filter_end_created_time_exclusive: int = 0
):
self.size = size
self.offset_data = offset_data
self.filter_contract_id_list = filter_contract_id_list or []
self.filter_start_created_time_inclusive = filter_start_created_time_inclusive
self.filter_end_created_time_exclusive = filter_end_created_time_exclusive
class GetCollateralTransactionPageParams:
"""Parameters for getting collateral transactions with pagination."""
def __init__(
self,
size: str = "",
offset_data: str = "",
filter_start_created_time_inclusive: int = 0,
filter_end_created_time_exclusive: int = 0
):
self.size = size
self.offset_data = offset_data
self.filter_start_created_time_inclusive = filter_start_created_time_inclusive
self.filter_end_created_time_exclusive = filter_end_created_time_exclusive
class GetPositionTermPageParams:
"""Parameters for getting position terms with pagination."""
def __init__(
self,
size: str = "",
offset_data: str = "",
filter_contract_id_list: List[str] = None,
filter_start_created_time_inclusive: int = 0,
filter_end_created_time_exclusive: int = 0
):
self.size = size
self.offset_data = offset_data
self.filter_contract_id_list = filter_contract_id_list or []
self.filter_start_created_time_inclusive = filter_start_created_time_inclusive
self.filter_end_created_time_exclusive = filter_end_created_time_exclusive
class GetAccountAssetSnapshotPageParams:
"""Parameters for getting account asset snapshots with pagination."""
def __init__(
self,
size: str = "",
offset_data: str = "",
filter_start_created_time_inclusive: int = 0,
filter_end_created_time_exclusive: int = 0
):
self.size = size
self.offset_data = offset_data
self.filter_start_created_time_inclusive = filter_start_created_time_inclusive
self.filter_end_created_time_exclusive = filter_end_created_time_exclusive
class Client:
"""Client for account-related API endpoints."""
def __init__(self, async_client: AsyncClient):
"""
Initialize the account client.
Args:
async_client: The async client for common functionality
"""
self.async_client = async_client
async def get_account_asset(self) -> Dict[str, Any]:
"""
Get the account asset information.
Returns:
Dict[str, Any]: The account asset information
Raises:
ValueError: If the request fails
"""
params = {
"accountId": str(self.async_client.get_account_id())
}
return await self.async_client.make_authenticated_request(
method="GET",
path="/api/v1/private/account/getAccountAsset",
params=params
)
async def get_account_positions(self) -> Dict[str, Any]:
"""
Get the account positions.
Note: This calls the same endpoint as get_account_asset, which returns both
collateral and position data. The position data is in the 'positionAssetList' field.
Returns:
Dict[str, Any]: The account positions (same as account asset response)
Raises:
ValueError: If the request fails
"""
# Use the same endpoint as get_account_asset (matching Go SDK behavior)
return await self.get_account_asset()
async def get_position_transaction_page(self, params: GetPositionTransactionPageParams) -> Dict[str, Any]:
"""
Get the position transactions with pagination.
Args:
params: Position transaction query parameters
Returns:
Dict[str, Any]: The position transactions
Raises:
ValueError: If the request fails
"""
query_params = {
"accountId": str(self.async_client.get_account_id())
}
# Add pagination parameters
if params.size:
query_params["size"] = params.size
if params.offset_data:
query_params["offsetData"] = params.offset_data
# Add filter parameters
if params.filter_contract_id_list:
query_params["filterContractIdList"] = ",".join(params.filter_contract_id_list)
# Add time filters
if params.filter_start_created_time_inclusive > 0:
query_params["filterStartCreatedTimeInclusive"] = str(params.filter_start_created_time_inclusive)
if params.filter_end_created_time_exclusive > 0:
query_params["filterEndCreatedTimeExclusive"] = str(params.filter_end_created_time_exclusive)
return await self.async_client.make_authenticated_request(
method="GET",
path="/api/v1/private/account/getPositionTransactionPage",
params=query_params
)
async def get_collateral_transaction_page(self, params: GetCollateralTransactionPageParams) -> Dict[str, Any]:
"""
Get the collateral transactions with pagination.
Args:
params: Collateral transaction query parameters
Returns:
Dict[str, Any]: The collateral transactions
Raises:
ValueError: If the request fails
"""
query_params = {
"accountId": str(self.async_client.get_account_id())
}
# Add pagination parameters
if params.size:
query_params["size"] = params.size
if params.offset_data:
query_params["offsetData"] = params.offset_data
# Add time filters
if params.filter_start_created_time_inclusive > 0:
query_params["filterStartCreatedTimeInclusive"] = str(params.filter_start_created_time_inclusive)
if params.filter_end_created_time_exclusive > 0:
query_params["filterEndCreatedTimeExclusive"] = str(params.filter_end_created_time_exclusive)
return await self.async_client.make_authenticated_request(
method="GET",
path="/api/v1/private/account/getCollateralTransactionPage",
params=query_params
)
async def get_position_term_page(self, params: GetPositionTermPageParams) -> Dict[str, Any]:
"""
Get the position terms with pagination.
Args:
params: Position term query parameters
Returns:
Dict[str, Any]: The position terms
Raises:
ValueError: If the request fails
"""
url = f"{self.base_url}/api/v1/private/account/getPositionTermPage"
query_params = {
"accountId": str(self.internal_client.get_account_id())
}
# Add pagination parameters
if params.size:
query_params["size"] = params.size
if params.offset_data:
query_params["offsetData"] = params.offset_data
# Add filter parameters
if params.filter_contract_id_list:
query_params["filterContractIdList"] = ",".join(params.filter_contract_id_list)
# Add time filters
if params.filter_start_created_time_inclusive > 0:
query_params["filterStartCreatedTimeInclusive"] = str(params.filter_start_created_time_inclusive)
if params.filter_end_created_time_exclusive > 0:
query_params["filterEndCreatedTimeExclusive"] = str(params.filter_end_created_time_exclusive)
response = self.session.get(url, params=query_params)
if response.status_code != 200:
raise ValueError(f"request failed with status code: {response.status_code}")
resp_data = response.json()
if resp_data.get("code") != ResponseCode.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
async def get_account_by_id(self) -> Dict[str, Any]:
"""
Get account information by ID.
Returns:
Dict[str, Any]: The account information
Raises:
ValueError: If the request fails
"""
params = {
"accountId": str(self.async_client.get_account_id())
}
return await self.async_client.make_authenticated_request(
method="GET",
path="/api/v1/private/account/getAccountById",
params=params
)
async def get_account_deleverage_light(self) -> Dict[str, Any]:
"""
Get account deleverage light information.
Returns:
Dict[str, Any]: The account deleverage light information
Raises:
ValueError: If the request fails
"""
url = f"{self.base_url}/api/v1/private/account/getAccountDeleverageLight"
params = {
"accountId": str(self.internal_client.get_account_id())
}
response = self.session.get(url, params=params)
if response.status_code != 200:
raise ValueError(f"request failed with status code: {response.status_code}")
resp_data = response.json()
if resp_data.get("code") != ResponseCode.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
async def get_account_asset_snapshot_page(self, params: GetAccountAssetSnapshotPageParams) -> Dict[str, Any]:
"""
Get account asset snapshots with pagination.
Args:
params: Account asset snapshot query parameters
Returns:
Dict[str, Any]: The account asset snapshots
Raises:
ValueError: If the request fails
"""
url = f"{self.base_url}/api/v1/private/account/getAccountAssetSnapshotPage"
query_params = {
"accountId": str(self.internal_client.get_account_id())
}
# Add pagination parameters
if params.size:
query_params["size"] = params.size
if params.offset_data:
query_params["offsetData"] = params.offset_data
# Add time filters
if params.filter_start_created_time_inclusive > 0:
query_params["filterStartCreatedTimeInclusive"] = str(params.filter_start_created_time_inclusive)
if params.filter_end_created_time_exclusive > 0:
query_params["filterEndCreatedTimeExclusive"] = str(params.filter_end_created_time_exclusive)
response = self.session.get(url, params=query_params)
if response.status_code != 200:
raise ValueError(f"request failed with status code: {response.status_code}")
resp_data = response.json()
if resp_data.get("code") != ResponseCode.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
async def get_position_transaction_by_id(self, transaction_ids: List[str]) -> Dict[str, Any]:
"""
Get position transactions by IDs.
Args:
transaction_ids: List of transaction IDs
Returns:
Dict[str, Any]: The position transactions
Raises:
ValueError: If the request fails
"""
url = f"{self.base_url}/api/v1/private/account/getPositionTransactionById"
query_params = {
"accountId": str(self.internal_client.get_account_id()),
"transactionIdList": ",".join(transaction_ids)
}
response = self.session.get(url, params=query_params)
if response.status_code != 200:
raise ValueError(f"request failed with status code: {response.status_code}")
resp_data = response.json()
if resp_data.get("code") != ResponseCode.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
async def get_collateral_transaction_by_id(self, transaction_ids: List[str]) -> Dict[str, Any]:
"""
Get collateral transactions by IDs.
Args:
transaction_ids: List of transaction IDs
Returns:
Dict[str, Any]: The collateral transactions
Raises:
ValueError: If the request fails
"""
url = f"{self.base_url}/api/v1/private/account/getCollateralTransactionById"
query_params = {
"accountId": str(self.internal_client.get_account_id()),
"transactionIdList": ",".join(transaction_ids)
}
response = self.session.get(url, params=query_params)
if response.status_code != 200:
raise ValueError(f"request failed with status code: {response.status_code}")
resp_data = response.json()
if resp_data.get("code") != ResponseCode.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
async def update_leverage_setting(self, contract_id: str, leverage: str) -> None:
"""
Update the account leverage settings.
Args:
contract_id: The contract ID
leverage: The leverage value
Raises:
ValueError: If the request fails
"""
url = f"{self.base_url}/api/v1/private/account/updateLeverageSetting"
data = {
"accountId": str(self.internal_client.get_account_id()),
"contractId": contract_id,
"leverage": leverage
}
response = self.session.post(url, json=data)
if response.status_code != 200:
raise ValueError(f"request failed with status code: {response.status_code}")
resp_data = response.json()
if resp_data.get("code") != ResponseCode.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')}")
@@ -0,0 +1,300 @@
from typing import Dict, Any, List
from ..internal.async_client import AsyncClient
class GetAssetOrdersParams:
"""Parameters for getting asset orders."""
def __init__(self, size: str = "10", offset_data: str = "", filter_coin_id_list: List[str] = None,
filter_start_created_time_inclusive: int = 0, filter_end_created_time_exclusive: int = 0):
self.size = size
self.offset_data = offset_data
self.filter_coin_id_list = filter_coin_id_list or []
self.filter_start_created_time_inclusive = filter_start_created_time_inclusive
self.filter_end_created_time_exclusive = filter_end_created_time_exclusive
class CreateWithdrawalParams:
"""Parameters for creating a withdrawal."""
def __init__(self, coin_id: str, amount: str, address: str, tag: str = ""):
self.coin_id = coin_id
self.amount = amount
self.address = address
self.tag = tag
class GetWithdrawalRecordsParams:
"""Parameters for getting withdrawal records."""
def __init__(self, size: str = "10", offset_data: str = "", filter_coin_id_list: List[str] = None,
filter_status_list: List[str] = None, filter_start_created_time_inclusive: int = 0,
filter_end_created_time_exclusive: int = 0):
self.size = size
self.offset_data = offset_data
self.filter_coin_id_list = filter_coin_id_list or []
self.filter_status_list = filter_status_list or []
self.filter_start_created_time_inclusive = filter_start_created_time_inclusive
self.filter_end_created_time_exclusive = filter_end_created_time_exclusive
class Client:
"""Client for asset-related API endpoints."""
def __init__(self, async_client: AsyncClient):
"""
Initialize the asset client.
Args:
async_client: The async client for common functionality
"""
self.async_client = async_client
async def get_account_asset(self) -> Dict[str, Any]:
"""
Get the account asset information.
Note: This method delegates to the account client since it's an account endpoint.
Returns:
Dict[str, Any]: The account asset information
Raises:
ValueError: If the request fails
"""
# This is actually an account endpoint, not an asset endpoint
# We should delegate to the account client
raise NotImplementedError("This method should be called from the account client: client.account.get_account_asset()")
async def get_asset_orders(
self,
params: GetAssetOrdersParams
) -> Dict[str, Any]:
"""
Get asset orders with pagination.
Args:
params: Parameters for the request
Returns:
Dict[str, Any]: The asset orders
Raises:
ValueError: If the request fails
"""
query_params = {
"accountId": str(self.async_client.get_account_id())
}
# Add pagination parameters
if params.size:
query_params["size"] = params.size
if params.offset_data:
query_params["offsetData"] = params.offset_data
# Add filter parameters
if params.filter_coin_id_list:
query_params["filterCoinIdList"] = ",".join(params.filter_coin_id_list)
# Add time filters
if params.filter_start_created_time_inclusive > 0:
query_params["filterStartCreatedTimeInclusive"] = str(params.filter_start_created_time_inclusive)
if params.filter_end_created_time_exclusive > 0:
query_params["filterEndCreatedTimeExclusive"] = str(params.filter_end_created_time_exclusive)
return await self.async_client.make_authenticated_request(
method="GET",
path="/api/v1/private/assets/getAllOrdersPage",
params=query_params
)
async def get_coin_rates(self, chain_id: str = "1", coin: str = "0xdac17f958d2ee523a2206206994597c13d831ec7") -> Dict[str, Any]:
"""
Get coin rates.
Args:
chain_id: Chain ID (default: "1" for Ethereum mainnet)
coin: Coin contract address (default: USDT)
Returns:
Dict[str, Any]: The coin rates
Raises:
ValueError: If the request fails
"""
params = {
"chainId": chain_id,
"coin": coin
}
return await self.async_client.make_authenticated_request(
method="GET",
path="/api/v1/private/assets/getCoinRate",
params=params
)
async def create_withdrawal(
self,
coin_id: str,
amount: str,
address: str,
network: str,
memo: str = "",
client_order_id: str = None
) -> Dict[str, Any]:
"""
Create a withdrawal request.
Args:
coin_id: The coin ID
amount: The withdrawal amount
address: The withdrawal address
network: The network
memo: Optional memo
client_order_id: Optional client order ID
Returns:
Dict[str, Any]: The withdrawal result
Raises:
ValueError: If the request fails
"""
data = {
"accountId": str(self.async_client.get_account_id()),
"coinId": coin_id,
"amount": amount,
"address": address,
"network": network
}
if memo:
data["memo"] = memo
if client_order_id:
data["clientOrderId"] = client_order_id
else:
data["clientOrderId"] = self.async_client.generate_uuid()
return await self.async_client.make_authenticated_request(
method="POST",
path="/api/v1/private/assets/createNormalWithdraw",
data=data
)
async def get_withdrawal_records(
self,
size: str = "",
offset_data: str = "",
filter_coin_id_list: List[str] = None,
filter_status_list: List[str] = None,
filter_start_created_time_inclusive: int = 0,
filter_end_created_time_exclusive: int = 0
) -> Dict[str, Any]:
"""
Get withdrawal records with pagination.
Args:
size: Size of the page
offset_data: Offset data for pagination
filter_coin_id_list: Filter by coin IDs
filter_status_list: Filter by status
filter_start_created_time_inclusive: Filter start time (inclusive)
filter_end_created_time_exclusive: Filter end time (exclusive)
Returns:
Dict[str, Any]: The withdrawal records
Raises:
ValueError: If the request fails
"""
query_params = {
"accountId": str(self.async_client.get_account_id())
}
# Add pagination parameters
if size:
query_params["size"] = size
if offset_data:
query_params["offsetData"] = offset_data
# Add filter parameters
if filter_coin_id_list:
query_params["filterCoinIdList"] = ",".join(filter_coin_id_list)
if filter_status_list:
query_params["filterStatusList"] = ",".join(filter_status_list)
# Add time filters
if filter_start_created_time_inclusive > 0:
query_params["filterStartCreatedTimeInclusive"] = str(filter_start_created_time_inclusive)
if filter_end_created_time_exclusive > 0:
query_params["filterEndCreatedTimeExclusive"] = str(filter_end_created_time_exclusive)
return await self.async_client.make_authenticated_request(
method="GET",
path="/api/v1/private/assets/getNormalWithdrawById",
params=query_params
)
async def get_withdrawable_amount(self, address: str) -> Dict[str, Any]:
"""
Get the withdrawable amount for a coin.
Args:
address: The coin contract address
Returns:
Dict[str, Any]: The withdrawable amount information
Raises:
ValueError: If the request fails
"""
query_params = {
"address": address
}
return await self.async_client.make_authenticated_request(
method="GET",
path="/api/v1/private/assets/getNormalWithdrawableAmount",
params=query_params
)
async def get_withdrawal_records(self, params: GetWithdrawalRecordsParams) -> Dict[str, Any]:
"""
Get withdrawal records with pagination.
Args:
params: Parameters for the request
Returns:
Dict[str, Any]: The withdrawal records
Raises:
ValueError: If the request fails
"""
query_params = {
"accountId": str(self.async_client.get_account_id())
}
# Add pagination parameters
if params.size:
query_params["size"] = params.size
if params.offset_data:
query_params["offsetData"] = params.offset_data
# Add filter parameters
if params.filter_coin_id_list:
query_params["filterCoinIdList"] = ",".join(params.filter_coin_id_list)
if params.filter_status_list:
query_params["filterStatusList"] = ",".join(params.filter_status_list)
# Add time filters
if params.filter_start_created_time_inclusive > 0:
query_params["filterStartCreatedTimeInclusive"] = str(params.filter_start_created_time_inclusive)
if params.filter_end_created_time_exclusive > 0:
query_params["filterEndCreatedTimeExclusive"] = str(params.filter_end_created_time_exclusive)
return await self.async_client.make_authenticated_request(
method="GET",
path="/api/v1/private/assets/getNormalWithdrawById",
params=query_params
)
@@ -0,0 +1,264 @@
import json
import time
from typing import Dict, Any, Optional, List, Union
from decimal import Decimal
from .internal.async_client import AsyncClient
from .internal.signing_adapter import SigningAdapter
from .internal.starkex_signing_adapter import StarkExSigningAdapter
from .account.client import Client as AccountClient
from .asset.client import Client as AssetClient
from .funding.client import Client as FundingClient
from .metadata.client import Client as MetadataClient
from .order.client import Client as OrderClient
from .quote.client import Client as QuoteClient
from .transfer.client import Client as TransferClient
from .order.types import CreateOrderParams, CancelOrderParams, GetActiveOrderParams, OrderFillTransactionParams
class Client:
"""Main EdgeX SDK client."""
def __init__(self, base_url: str, account_id: int, stark_private_key: str,
signing_adapter: Optional[SigningAdapter] = None, timeout: float = 30.0):
"""
Initialize the EdgeX SDK client.
Args:
base_url: Base URL for API endpoints
account_id: Account ID for authentication
stark_private_key: Stark private key for signing
signing_adapter: Optional signing adapter (defaults to StarkExSigningAdapter)
timeout: Request timeout in seconds
"""
# Use StarkExSigningAdapter as default if none provided
if signing_adapter is None:
signing_adapter = StarkExSigningAdapter()
# Create async client
self.async_client = AsyncClient(
base_url=base_url,
account_id=account_id,
stark_pri_key=stark_private_key,
signing_adapter=signing_adapter,
timeout=timeout
)
# Initialize API clients
self.metadata = MetadataClient(self.async_client)
self.account = AccountClient(self.async_client)
self.order = OrderClient(self.async_client)
self.quote = QuoteClient(self.async_client)
self.funding = FundingClient(self.async_client)
self.transfer = TransferClient(self.async_client)
self.asset = AssetClient(self.async_client)
async def __aenter__(self):
"""Async context manager entry."""
await self.async_client._ensure_session()
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
"""Async context manager exit."""
await self.close()
async def close(self):
"""Close the client and cleanup resources."""
await self.async_client.close()
@property
def internal_client(self):
"""Backward compatibility property for accessing internal client."""
return self.async_client
async def get_metadata(self) -> Dict[str, Any]:
"""Get the exchange metadata."""
return await self.metadata.get_metadata()
async def get_server_time(self) -> Dict[str, Any]:
"""Get the current server time."""
return await self.metadata.get_server_time()
async def create_order(self, params: CreateOrderParams) -> Dict[str, Any]:
"""
Create a new order with the given parameters.
Args:
params: Order parameters
Returns:
Dict[str, Any]: The created order
"""
# Get metadata first
metadata = await self.get_metadata()
if not metadata:
raise ValueError("failed to get metadata")
return await self.order.create_order(params, metadata.get("data", {}))
async def get_max_order_size(self, contract_id: str, price: Decimal) -> Dict[str, Any]:
"""
Get the maximum order size for a given contract and price.
Args:
contract_id: The contract ID
price: The price
Returns:
Dict[str, Any]: The maximum order size information
"""
return await self.order.get_max_order_size(contract_id, float(price))
async def cancel_order(self, params: CancelOrderParams) -> Dict[str, Any]:
"""
Cancel a specific order.
Args:
params: Cancel order parameters
Returns:
Dict[str, Any]: The cancellation result
"""
return await self.order.cancel_order(params)
async def get_active_orders(self, params: GetActiveOrderParams) -> Dict[str, Any]:
"""
Get active orders with pagination and filters.
Args:
params: Active order query parameters
Returns:
Dict[str, Any]: The active orders
"""
return await self.order.get_active_orders(params)
async def get_order_fill_transactions(self, params: OrderFillTransactionParams) -> Dict[str, Any]:
"""
Get order fill transactions with pagination and filters.
Args:
params: Order fill transaction query parameters
Returns:
Dict[str, Any]: The order fill transactions
"""
return await self.order.get_order_fill_transactions(params)
async def get_account_asset(self) -> Dict[str, Any]:
"""Get the account asset information."""
return await self.account.get_account_asset()
async def get_account_positions(self) -> Dict[str, Any]:
"""Get the account positions."""
return await self.account.get_account_positions()
async def create_limit_order(
self,
contract_id: str,
size: str,
price: str,
side: str,
client_order_id: Optional[str] = None
) -> Dict[str, Any]:
"""
Create a new limit order with the given parameters.
Args:
contract_id: The contract ID
size: The order size
price: The order price
side: The order side (BUY or SELL)
client_order_id: Optional client order ID
Returns:
Dict[str, Any]: The created order
"""
from .order.types import OrderType
params = CreateOrderParams(
contract_id=contract_id,
size=size,
price=price,
side=side,
type=OrderType.LIMIT,
client_order_id=client_order_id
)
return await self.create_order(params)
async def create_market_order(
self,
contract_id: str,
size: str,
side: str,
client_order_id: Optional[str] = None
) -> Dict[str, Any]:
"""
Create a new market order with the given parameters.
Args:
contract_id: The contract ID
size: The order size
side: The order side (BUY or SELL)
client_order_id: Optional client order ID
Returns:
Dict[str, Any]: The created order
"""
# Get metadata for contract info
metadata = await self.get_metadata()
if not metadata:
raise ValueError("failed to get metadata")
# Find the contract
contract = None
contract_list = metadata.get("data", {}).get("contractList", [])
for c in contract_list:
if c.get("contractId") == contract_id:
contract = c
break
if not contract:
raise ValueError(f"contract not found: {contract_id}")
# Calculate price based on side
from .order.types import OrderSide, OrderType
if side == OrderSide.BUY:
# For buy orders: oracle_price * 10, rounded to price precision
quote = await self.get_24_hour_quote(contract_id)
if not quote:
raise ValueError("failed to get 24-hour quotes")
oracle_price = Decimal(quote.get("data", [])[0].get("oraclePrice", "0"))
multiplier = Decimal("10")
tick_size = Decimal(contract.get("tickSize", "0"))
precision = abs(tick_size.as_tuple().exponent)
price = str(round(oracle_price * multiplier, precision))
else:
# For sell orders: use tick size
price = contract.get("tickSize", "0")
params = CreateOrderParams(
contract_id=contract_id,
size=size,
price=price,
side=side,
type=OrderType.MARKET,
client_order_id=client_order_id
)
return await self.create_order(params)
async def get_24_hour_quote(self, contract_id: str) -> Dict[str, Any]:
"""
Get the 24-hour quotes for a given contract.
Args:
contract_id: The contract ID
Returns:
Dict[str, Any]: The 24-hour quotes
"""
return await self.quote.get_24_hour_quote(contract_id)
@@ -0,0 +1,13 @@
"""
Cryptographic utilities for the EdgeX Python SDK.
This module provides cryptographic functions including Pedersen hash
implementation compatible with StarkWare's specifications.
"""
from .pedersen_hash import pedersen_hash, pedersen_hash_as_point
__all__ = [
'pedersen_hash',
'pedersen_hash_as_point',
]
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,209 @@
"""
Pedersen hash implementation for StarkWare cryptography.
This module provides a full implementation of the Pedersen hash function
as specified by StarkWare, compatible with the reference implementation.
"""
from typing import List, Tuple, Union
# Handle both relative and absolute imports
try:
from .constants import (
FIELD_PRIME, ALPHA, BETA, N_ELEMENT_BITS_HASH,
SHIFT_POINT, CONSTANT_POINTS
)
except ImportError:
from constants import (
FIELD_PRIME, ALPHA, BETA, N_ELEMENT_BITS_HASH,
SHIFT_POINT, CONSTANT_POINTS
)
def _div_mod(n: int, m: int, p: int) -> int:
"""
Calculate (n / m) mod p.
Args:
n: The numerator
m: The denominator
p: The modulus
Returns:
int: The result of the division modulo p
"""
return (n * pow(m, -1, p)) % p
def _ec_add(p1: Tuple[int, int], p2: Tuple[int, int]) -> Tuple[int, int]:
"""
Add two points on the elliptic curve.
Args:
p1: The first point as (x, y) coordinates
p2: The second point as (x, y) coordinates
Returns:
Tuple[int, int]: The resulting point as (x, y) coordinates
"""
if p1[0] == p2[0]:
if (p1[1] + p2[1]) % FIELD_PRIME == 0:
# The points are negatives of each other, return the point at infinity
# We represent the point at infinity as None, but this should never happen
# in our use case, so we raise an exception instead
raise ValueError("Points are negatives of each other")
# The points are the same, so we're doubling
return _ec_double(p1)
# Calculate the slope
slope = _div_mod(p2[1] - p1[1], p2[0] - p1[0], FIELD_PRIME)
# Calculate the new point
x3 = (slope * slope - p1[0] - p2[0]) % FIELD_PRIME
y3 = (slope * (p1[0] - x3) - p1[1]) % FIELD_PRIME
return (x3, y3)
def _ec_double(p: Tuple[int, int]) -> Tuple[int, int]:
"""
Double a point on the elliptic curve.
Args:
p: The point to double as (x, y) coordinates
Returns:
Tuple[int, int]: The resulting point as (x, y) coordinates
"""
# Calculate the slope
slope = _div_mod(3 * p[0] * p[0] + ALPHA, 2 * p[1], FIELD_PRIME)
# Calculate the new point
x3 = (slope * slope - 2 * p[0]) % FIELD_PRIME
y3 = (slope * (p[0] - x3) - p[1]) % FIELD_PRIME
return (x3, y3)
def _ec_mult(m: int, p: Tuple[int, int]) -> Tuple[int, int]:
"""
Multiply a point on the elliptic curve by a scalar.
Args:
m: The scalar
p: The point as (x, y) coordinates
Returns:
Tuple[int, int]: The resulting point as (x, y) coordinates
"""
if m == 0:
raise ValueError("Cannot multiply by 0")
if m == 1:
return p
if m % 2 == 0:
return _ec_mult(m // 2, _ec_double(p))
else:
return _ec_add(p, _ec_mult(m - 1, p))
def pedersen_hash_as_point(*elements: int) -> Tuple[int, int]:
"""
Calculate the Pedersen hash of a list of integers and return the full EC point.
This is the full implementation following StarkWare's specification:
For each element, iterate through its 252 bits and add corresponding
constant points based on the bit values.
Args:
*elements: Variable number of integers to hash
Returns:
Tuple[int, int]: The resulting EC point as (x, y) coordinates
Raises:
ValueError: If any element is out of range or if there are insufficient constant points
"""
# Start with the shift point
point = tuple(SHIFT_POINT)
for i, element in enumerate(elements):
# Validate element is in valid range
if not (0 <= element < FIELD_PRIME):
raise ValueError(f"Element {element} is out of range [0, {FIELD_PRIME})")
# Calculate the starting index for this element's constant points
start_idx = 2 + i * N_ELEMENT_BITS_HASH
# Check if we have enough constant points
if start_idx + N_ELEMENT_BITS_HASH > len(CONSTANT_POINTS):
raise ValueError(f"Insufficient constant points for element {i}. Need {start_idx + N_ELEMENT_BITS_HASH}, have {len(CONSTANT_POINTS)}")
# Full implementation using all 252 bits
for j in range(N_ELEMENT_BITS_HASH):
pt = tuple(CONSTANT_POINTS[start_idx + j])
# Check for unhashable input (same x coordinate)
if point[0] == pt[0]:
raise ValueError('Unhashable input: point collision detected')
if element & 1:
point = _ec_add(point, pt)
element >>= 1
# Ensure all bits have been processed
if element != 0:
raise ValueError(f"Element too large: remaining bits {element}")
return point
def pedersen_hash(*elements: int) -> int:
"""
Calculate the Pedersen hash of a list of integers.
This function returns only the x-coordinate of the resulting EC point,
which is the standard Pedersen hash value.
Args:
*elements: Variable number of integers to hash
Returns:
int: The Pedersen hash as an integer (x-coordinate of the EC point)
Raises:
ValueError: If any element is out of range
"""
point = pedersen_hash_as_point(*elements)
return point[0]
def pedersen_hash_bytes(*elements: Union[int, bytes]) -> bytes:
"""
Calculate the Pedersen hash and return as bytes.
Args:
*elements: Variable number of integers or bytes to hash
Returns:
bytes: The hash result as 32 bytes (big-endian)
Raises:
ValueError: If any element is invalid
"""
# Convert bytes to integers if needed
int_elements = []
for element in elements:
if isinstance(element, bytes):
if len(element) > 32:
raise ValueError(f"Bytes element too long: {len(element)} > 32")
int_elements.append(int.from_bytes(element, byteorder='big'))
elif isinstance(element, int):
int_elements.append(element)
else:
raise ValueError(f"Invalid element type: {type(element)}")
hash_result = pedersen_hash(*int_elements)
return hash_result.to_bytes(32, byteorder='big')
@@ -0,0 +1,114 @@
from typing import Dict, Any, List
from ..internal.async_client import AsyncClient
class Client:
"""Client for funding-related API endpoints."""
def __init__(self, async_client: AsyncClient):
"""
Initialize the funding client.
Args:
async_client: The async client for common functionality
"""
self.async_client = async_client
async def get_funding_transactions(
self,
size: str = "",
offset_data: str = "",
filter_coin_id_list: List[str] = None,
filter_type_list: List[str] = None,
filter_start_created_time_inclusive: int = 0,
filter_end_created_time_exclusive: int = 0
) -> Dict[str, Any]:
"""
Get funding transactions with pagination.
Args:
size: Size of the page
offset_data: Offset data for pagination
filter_coin_id_list: Filter by coin IDs
filter_type_list: Filter by transaction types
filter_start_created_time_inclusive: Filter start time (inclusive)
filter_end_created_time_exclusive: Filter end time (exclusive)
Returns:
Dict[str, Any]: The funding transactions
Raises:
ValueError: If the request fails
"""
query_params = {
"accountId": str(self.async_client.get_account_id())
}
# Add pagination parameters
if size:
query_params["size"] = size
if offset_data:
query_params["offsetData"] = offset_data
# Add filter parameters
if filter_coin_id_list:
query_params["filterCoinIdList"] = ",".join(filter_coin_id_list)
if filter_type_list:
query_params["filterTypeList"] = ",".join(filter_type_list)
# Add time filters
if filter_start_created_time_inclusive > 0:
query_params["filterStartCreatedTimeInclusive"] = str(filter_start_created_time_inclusive)
if filter_end_created_time_exclusive > 0:
query_params["filterEndCreatedTimeExclusive"] = str(filter_end_created_time_exclusive)
return await self.async_client.make_authenticated_request(
method="GET",
path="/api/v1/public/funding/getFundingRatePage",
params=query_params
)
async def get_funding_account(self) -> Dict[str, Any]:
"""
Get funding account information.
Returns:
Dict[str, Any]: The funding account information
Raises:
ValueError: If the request fails
"""
params = {
"accountId": str(self.async_client.get_account_id())
}
return await self.async_client.make_authenticated_request(
method="GET",
path="/api/v1/private/account/getAccountAsset",
params=params
)
async def get_funding_transaction_by_id(self, transaction_ids: List[str]) -> Dict[str, Any]:
"""
Get funding transactions by IDs.
Args:
transaction_ids: List of transaction IDs
Returns:
Dict[str, Any]: The funding transactions
Raises:
ValueError: If the request fails
"""
query_params = {
"accountId": str(self.async_client.get_account_id()),
"transactionIdList": ",".join(transaction_ids)
}
return await self.async_client.make_authenticated_request(
method="GET",
path="/api/v1/public/funding/getLatestFundingRate",
params=query_params
)
@@ -0,0 +1,466 @@
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
@@ -0,0 +1,312 @@
import binascii
import hashlib
import time
import uuid
from typing import Dict, Any, Optional, Tuple, List, Union
import requests
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 Client:
"""Base client with common functionality."""
def __init__(self, base_url: str, account_id: int, stark_pri_key: str, signing_adapter: Optional[SigningAdapter] = None):
"""
Initialize the 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
"""
self.http_client = requests.Session()
self.http_client.headers.update({
"Content-Type": "application/json",
"Accept": "application/json"
})
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
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)
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
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)
@@ -0,0 +1,77 @@
"""
Signing adapter interface for the EdgeX Python SDK.
This module defines the interface for signing adapters that can be used with the SDK.
Different implementations can be provided for different environments (development, testing, production).
"""
from abc import ABC, abstractmethod
from typing import Tuple, List
class SigningAdapter(ABC):
"""Interface for signing adapters."""
@abstractmethod
def sign(self, message_hash: bytes, private_key: str) -> Tuple[str, str]:
"""
Sign a message hash using a private key.
Args:
message_hash: The hash of the message to sign
private_key: The private key as a hex string
Returns:
Tuple[str, str]: The signature as (r, s) hex strings
Raises:
ValueError: If the private key is invalid or the signing fails
"""
pass
@abstractmethod
def get_public_key(self, private_key: str) -> str:
"""
Get the public key from a private key.
Args:
private_key: The private key as a hex string
Returns:
str: The public key as a hex string
Raises:
ValueError: If the private key is invalid
"""
pass
@abstractmethod
def verify(self, message_hash: bytes, signature: Tuple[str, str], public_key: str) -> bool:
"""
Verify a signature using a public key.
Args:
message_hash: The hash of the message
signature: The signature as (r, s) hex strings
public_key: The public key as a hex string
Returns:
bool: Whether the signature is valid
"""
pass
@abstractmethod
def pedersen_hash(self, elements: List[int]) -> bytes:
"""
Calculate the Pedersen hash of a list of integers.
Args:
elements: List of integers to hash
Returns:
bytes: The hash result
Raises:
ValueError: If the calculation fails
"""
pass
@@ -0,0 +1,496 @@
"""
StarkEx signing adapter for the EdgeX Python SDK.
This module provides an implementation of the signing adapter interface
that uses the StarkWare cryptographic primitives for signing operations.
"""
import binascii
import math
import secrets
from typing import List, Tuple
from .signing_adapter import SigningAdapter
from ..crypto.pedersen_hash import pedersen_hash_bytes
# StarkEx curve parameters
FIELD_PRIME = 0x800000000000011000000000000000000000000000000000000000000000001
ALPHA = 1
BETA = 0x6f21413efbe40de150e596d72f7a8c5609ad26c15c915c1f4cdfcb99cee9e89
EC_ORDER = 0x800000000000010ffffffffffffffffb781126dcae7b2321e66a241adc64d2f
N_ELEMENT_BITS_ECDSA = math.floor(math.log(FIELD_PRIME, 2))
assert N_ELEMENT_BITS_ECDSA == 251
# Generator point for the Stark curve
EC_GEN = (
0x1ef15c18599971b7beced415a40f0c7deacfd9b0d1819e03d723d8bc943cfca,
0x5668060aa49730b7be4801df46ec62de53ecd11abe43a32873000c36e8dc1f
)
class StarkExSigningAdapter(SigningAdapter):
"""StarkEx implementation of the signing adapter interface."""
def sign(self, message_hash: bytes, private_key: str) -> Tuple[str, str]:
"""
Sign a message hash using a private key.
Args:
message_hash: The hash of the message to sign
private_key: The private key as a hex string
Returns:
Tuple[str, str]: The signature as (r, s) hex strings
Raises:
ValueError: If the private key is invalid or the signing fails
"""
try:
# Validate private key format
binascii.unhexlify(private_key)
except binascii.Error:
raise ValueError("Invalid private key hex string")
# Convert message hash to integer
msg_hash_int = int.from_bytes(message_hash, byteorder='big')
# Ensure the message hash is in the valid range
# Use the same modulus as the Golang SDK (EC_ORDER, which is starkcurve.N)
msg_hash_int = msg_hash_int % EC_ORDER
# Convert private key to integer
priv_key_int = int(private_key, 16)
# Ensure the private key is in the valid range
# For testing purposes, we'll just take the modulus
priv_key_int = priv_key_int % EC_ORDER
if priv_key_int == 0:
priv_key_int = 1
# Sign the message
r, s = self._sign(msg_hash_int, priv_key_int)
# Convert r and s to hex strings
r_hex = format(r, '064x')
s_hex = format(s, '064x')
return r_hex, s_hex
def get_public_key(self, private_key: str) -> str:
"""
Get the public key from a private key.
Args:
private_key: The private key as a hex string
Returns:
str: The public key as a hex string
Raises:
ValueError: If the private key is invalid
"""
try:
# Validate private key format
binascii.unhexlify(private_key)
except binascii.Error:
raise ValueError("Invalid private key hex string")
# Convert private key to integer
priv_key_int = int(private_key, 16)
# Ensure the private key is in the valid range
# For testing purposes, we'll just take the modulus
priv_key_int = priv_key_int % EC_ORDER
if priv_key_int == 0:
priv_key_int = 1
# Get the public key
public_key = self._private_to_stark_key(priv_key_int)
# Convert public key to hex string
public_key_hex = format(public_key, '064x')
return public_key_hex
def verify(self, message_hash: bytes, signature: Tuple[str, str], public_key: str) -> bool:
"""
Verify a signature using a public key.
Args:
message_hash: The hash of the message
signature: The signature as (r, s) hex strings
public_key: The public key as a hex string
Returns:
bool: Whether the signature is valid
"""
try:
# Convert message hash to integer
msg_hash_int = int.from_bytes(message_hash, byteorder='big')
# Ensure the message hash is in the valid range
# Use the same modulus as the sign method (EC_ORDER)
msg_hash_int = msg_hash_int % EC_ORDER
# Convert signature components to integers
r_int = int(signature[0], 16)
s_int = int(signature[1], 16)
# Ensure r and s are in the valid range
if not (1 <= r_int < 2**N_ELEMENT_BITS_ECDSA and 1 <= s_int < EC_ORDER):
return False
# Convert public key to integer
pub_key_int = int(public_key, 16)
# Verify the signature
return self._verify(msg_hash_int, r_int, s_int, pub_key_int)
except Exception:
return False
def pedersen_hash(self, elements: List[int]) -> bytes:
"""
Calculate the Pedersen hash of a list of integers.
This method now uses the full Pedersen hash implementation
that follows StarkWare's specification.
Args:
elements: List of integers to hash
Returns:
bytes: The hash result
Raises:
ValueError: If the calculation fails
"""
try:
# Use the full Pedersen hash implementation
return pedersen_hash_bytes(*elements)
except Exception as e:
raise ValueError(f"Failed to calculate Pedersen hash: {str(e)}")
def _sign(self, msg_hash: int, priv_key: int) -> Tuple[int, int]:
"""
Sign a message hash using a private key.
Args:
msg_hash: The hash of the message to sign as an integer
priv_key: The private key as an integer
Returns:
Tuple[int, int]: The signature as (r, s) integers
"""
# Choose a valid k. In our version of ECDSA not every k value is valid,
# and there is a negligible probability a drawn k cannot be used for signing.
# This is why we have this loop.
while True:
# Use random nonce generation like the Go SDK
k = self._generate_random_k()
# Cannot fail because 0 < k < EC_ORDER and EC_ORDER is prime.
x = self._ec_mult(k, EC_GEN)[0]
# DIFF: in classic ECDSA, we take int(x) % n.
r = int(x)
if not (1 <= r < 2**N_ELEMENT_BITS_ECDSA):
# Bad value. This fails with negligible probability.
continue
if (msg_hash + r * priv_key) % EC_ORDER == 0:
# Bad value. This fails with negligible probability.
continue
w = self._div_mod(k, msg_hash + r * priv_key, EC_ORDER)
if not (1 <= w < 2**N_ELEMENT_BITS_ECDSA):
# Bad value. This fails with negligible probability.
continue
s = self._inv_mod_curve_size(w)
return r, s
def _verify(self, msg_hash: int, r: int, s: int, public_key: int) -> bool:
"""
Verify a signature using a public key.
Args:
msg_hash: The hash of the message as an integer
r: The r component of the signature as an integer
s: The s component of the signature as an integer
public_key: The public key as an integer
Returns:
bool: Whether the signature is valid
"""
# Compute w = s^-1 (mod EC_ORDER).
if not (1 <= s < EC_ORDER):
return False
w = self._inv_mod_curve_size(s)
# Preassumptions:
# DIFF: in classic ECDSA, we assert 1 <= r, w <= EC_ORDER-1.
# Since r, w < 2**N_ELEMENT_BITS_ECDSA < EC_ORDER, we only need to verify r, w != 0.
if not (1 <= r < 2**N_ELEMENT_BITS_ECDSA and 1 <= w < 2**N_ELEMENT_BITS_ECDSA):
return False
if not (0 <= msg_hash < 2**N_ELEMENT_BITS_ECDSA):
return False
# Only the x coordinate of the point is given, check the two possibilities for the y
# coordinate.
try:
y = self._get_y_coordinate(public_key)
except ValueError:
return False
# Verify it is on the curve.
if (y**2 - (public_key**3 + ALPHA * public_key + BETA)) % FIELD_PRIME != 0:
return False
# Try both possible y coordinates.
for y_candidate in [y, (-y) % FIELD_PRIME]:
public_key_point = (public_key, y_candidate)
# Signature validation.
try:
# Calculate u1 = msg_hash * w mod n
u1 = (msg_hash * w) % EC_ORDER
# Calculate u2 = r * w mod n
u2 = (r * w) % EC_ORDER
# Calculate u1*G + u2*Q
point1 = self._ec_mult(u1, EC_GEN)
point2 = self._ec_mult(u2, public_key_point)
point = self._ec_add(point1, point2)
# The signature is valid if the x-coordinate of the resulting point equals r
if point[0] == r:
return True
except Exception:
continue
return False
def _generate_random_k(self) -> int:
"""
Generate a cryptographically secure random k value.
Returns:
int: The generated k value in range [1, EC_ORDER)
"""
# Generate a cryptographically secure random number in the range [1, EC_ORDER)
# This matches the Go implementation's approach of using random nonces
return secrets.randbelow(EC_ORDER - 1) + 1
def _private_to_stark_key(self, priv_key: int) -> int:
"""
Convert a private key to a Stark public key.
Args:
priv_key: The private key as an integer
Returns:
int: The public key as an integer
"""
return self._private_key_to_ec_point_on_stark_curve(priv_key)[0]
def _private_key_to_ec_point_on_stark_curve(self, priv_key: int) -> Tuple[int, int]:
"""
Convert a private key to an EC point on the Stark curve.
Args:
priv_key: The private key as an integer
Returns:
Tuple[int, int]: The EC point as (x, y) coordinates
"""
# Ensure the private key is in the valid range
# For testing purposes, we'll just take the modulus
priv_key = priv_key % EC_ORDER
if priv_key == 0:
priv_key = 1
return self._ec_mult(priv_key, EC_GEN)
def _inv_mod_curve_size(self, x: int) -> int:
"""
Calculate the modular inverse of x modulo the curve order.
Args:
x: The value to invert
Returns:
int: The modular inverse
"""
return self._div_mod(1, x, EC_ORDER)
def _div_mod(self, n: int, m: int, p: int) -> int:
"""
Calculate (n / m) mod p.
Args:
n: The numerator
m: The denominator
p: The modulus
Returns:
int: The result of the division modulo p
"""
return (n * pow(m, -1, p)) % p
def _is_quad_residue(self, n: int, p: int) -> bool:
"""
Check if n is a quadratic residue modulo p.
Args:
n: The number to check
p: The modulus
Returns:
bool: True if n is a quadratic residue modulo p, False otherwise
"""
return pow(n, (p - 1) // 2, p) == 1
def _sqrt_mod(self, n: int, p: int) -> int:
"""
Calculate the square root of n modulo p.
Args:
n: The number to take the square root of
p: The modulus
Returns:
int: The square root of n modulo p
"""
# Handle the case where p = 3 mod 4
if p % 4 == 3:
return pow(n, (p + 1) // 4, p)
# Handle the general case using the Tonelli-Shanks algorithm
q = p - 1
s = 0
while q % 2 == 0:
q //= 2
s += 1
# Find a non-residue
z = 2
while self._is_quad_residue(z, p):
z += 1
m = s
c = pow(z, q, p)
t = pow(n, q, p)
r = pow(n, (q + 1) // 2, p)
while t != 1:
# Find the least i, 0 < i < m, such that t^(2^i) = 1
i = 0
t_sq = t
while t_sq != 1 and i < m - 1:
t_sq = (t_sq * t_sq) % p
i += 1
# Calculate b = c^(2^(m-i-1))
b = pow(c, 2**(m - i - 1), p)
m = i
c = (b * b) % p
t = (t * b * b) % p
r = (r * b) % p
return r
def _get_y_coordinate(self, x: int) -> int:
"""
Given the x coordinate of a point, returns a possible y coordinate such that
together the point (x,y) is on the curve.
Args:
x: The x coordinate
Returns:
int: A possible y coordinate
Raises:
ValueError: If x is not a valid x coordinate on the curve
"""
y_squared = (x * x * x + ALPHA * x + BETA) % FIELD_PRIME
if not self._is_quad_residue(y_squared, FIELD_PRIME):
raise ValueError("Given x coordinate does not represent any point on the elliptic curve.")
return self._sqrt_mod(y_squared, FIELD_PRIME)
def _ec_add(self, p1: Tuple[int, int], p2: Tuple[int, int]) -> Tuple[int, int]:
"""
Add two points on the elliptic curve.
Args:
p1: The first point as (x, y) coordinates
p2: The second point as (x, y) coordinates
Returns:
Tuple[int, int]: The resulting point as (x, y) coordinates
"""
if p1[0] == p2[0]:
if (p1[1] + p2[1]) % FIELD_PRIME == 0:
# The points are negatives of each other, return the point at infinity
# We represent the point at infinity as None, but this should never happen
# in our use case, so we raise an exception instead
raise ValueError("Points are negatives of each other")
# The points are the same, so we're doubling
return self._ec_double(p1)
# Calculate the slope
slope = self._div_mod(p2[1] - p1[1], p2[0] - p1[0], FIELD_PRIME)
# Calculate the new point
x3 = (slope * slope - p1[0] - p2[0]) % FIELD_PRIME
y3 = (slope * (p1[0] - x3) - p1[1]) % FIELD_PRIME
return (x3, y3)
def _ec_double(self, p: Tuple[int, int]) -> Tuple[int, int]:
"""
Double a point on the elliptic curve.
Args:
p: The point to double as (x, y) coordinates
Returns:
Tuple[int, int]: The resulting point as (x, y) coordinates
"""
# Calculate the slope
slope = self._div_mod(3 * p[0] * p[0] + ALPHA, 2 * p[1], FIELD_PRIME)
# Calculate the new point
x3 = (slope * slope - 2 * p[0]) % FIELD_PRIME
y3 = (slope * (p[0] - x3) - p[1]) % FIELD_PRIME
return (x3, y3)
def _ec_mult(self, m: int, p: Tuple[int, int]) -> Tuple[int, int]:
"""
Multiply a point on the elliptic curve by a scalar.
Args:
m: The scalar
p: The point as (x, y) coordinates
Returns:
Tuple[int, int]: The resulting point as (x, y) coordinates
"""
if m == 0:
raise ValueError("Cannot multiply by 0")
if m == 1:
return p
if m % 2 == 0:
return self._ec_mult(m // 2, self._ec_double(p))
else:
return self._ec_add(p, self._ec_mult(m - 1, p))
@@ -0,0 +1,96 @@
from typing import Dict, Any
from ..internal.async_client import AsyncClient
class Client:
"""Client for metadata-related API endpoints."""
def __init__(self, async_client: AsyncClient):
"""
Initialize the metadata client.
Args:
async_client: The async client for common functionality
"""
self.async_client = async_client
async def get_metadata(self) -> Dict[str, Any]:
"""
Get the exchange metadata.
Returns:
Dict[str, Any]: The exchange metadata
Raises:
ValueError: If the request fails
"""
# Public endpoint - use simple GET request
await self.async_client._ensure_session()
url = f"{self.async_client.base_url}/api/v1/public/meta/getMetaData"
try:
async with self.async_client.session.get(url) 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:
text = await response.text()
raise ValueError(f"request failed with status code: {response.status}, response: {text}")
resp_data = await response.json()
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 Exception as e:
if isinstance(e, ValueError):
raise
raise ValueError(f"request failed: {str(e)}")
async def get_server_time(self) -> Dict[str, Any]:
"""
Get the current server time.
Returns:
Dict[str, Any]: The server time information
Raises:
ValueError: If the request fails
"""
# Public endpoint - use simple GET request
await self.async_client._ensure_session()
url = f"{self.async_client.base_url}/api/v1/public/meta/getServerTime"
try:
async with self.async_client.session.get(url) 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:
text = await response.text()
raise ValueError(f"request failed with status code: {response.status}, response: {text}")
resp_data = await response.json()
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 Exception as e:
if isinstance(e, ValueError):
raise
raise ValueError(f"request failed: {str(e)}")
@@ -0,0 +1,343 @@
import math
import time
from decimal import Decimal
from typing import Dict, Any, Optional, List
from ..internal.async_client import AsyncClient
from .types import (
CreateOrderParams,
CancelOrderParams,
GetActiveOrderParams,
OrderFillTransactionParams,
TimeInForce,
OrderType
)
class Client:
"""Client for order-related API endpoints."""
def __init__(self, async_client: AsyncClient):
"""
Initialize the order client.
Args:
async_client: The async client for common functionality
"""
self.async_client = async_client
async def create_order(self, params: CreateOrderParams, metadata: Dict[str, Any]) -> Dict[str, Any]:
"""
Create a new order with the given parameters.
Args:
params: Order parameters
metadata: Exchange metadata
Returns:
Dict[str, Any]: The created order
Raises:
ValueError: If required parameters are missing or invalid
"""
# Set default TimeInForce based on order type if not specified
if not params.time_in_force:
if params.type == OrderType.MARKET:
params.time_in_force = TimeInForce.IMMEDIATE_OR_CANCEL
elif params.type == OrderType.LIMIT:
params.time_in_force = TimeInForce.GOOD_TIL_CANCEL
# Find the contract from metadata
contract = None
contract_list = metadata.get("contractList", [])
for c in contract_list:
if c.get("contractId") == params.contract_id:
contract = c
break
if not contract:
raise ValueError(f"contract not found: {params.contract_id}")
# Get collateral coin from metadata
global_data = metadata.get("global", {})
collateral_coin = global_data.get("starkExCollateralCoin", {})
# Parse decimal values
try:
size = Decimal(params.size)
price = Decimal(params.price)
except (ValueError, TypeError):
raise ValueError("failed to parse size or price")
# Convert hex resolution to decimal
hex_resolution = contract.get("starkExResolution", "0x0")
# Remove "0x" prefix if present
hex_resolution = hex_resolution.replace("0x", "")
# Parse hex string to int
try:
resolution_int = int(hex_resolution, 16)
resolution = Decimal(resolution_int)
except (ValueError, TypeError):
raise ValueError("failed to parse hex resolution")
client_order_id = params.client_order_id or self.async_client.generate_uuid()
# Calculate values
value_dm = price * size
amount_synthetic = int(size * resolution)
amount_collateral = int(value_dm * Decimal("1000000")) # Shift 6 decimal places
# Calculate fee based on order type (maker/taker)
try:
fee_rate = Decimal(contract.get("defaultTakerFeeRate", "0"))
except (ValueError, TypeError):
raise ValueError("failed to parse fee rate")
# Calculate fee amount in decimal with ceiling to integer
amount_fee_dm = Decimal(str(math.ceil(float(value_dm * fee_rate))))
amount_fee_str = str(amount_fee_dm)
# Convert to the required integer format for the protocol
amount_fee = int(amount_fee_dm * Decimal("1000000")) # Shift 6 decimal places
nonce = self.async_client.calc_nonce(client_order_id)
l2_expire_time = int(time.time() * 1000) + (14 * 24 * 60 * 60 * 1000) # 14 days
# Calculate signature using asset IDs from metadata
expire_time_unix = l2_expire_time // (60 * 60 * 1000)
sig_hash = self.async_client.calc_limit_order_hash(
contract.get("starkExSyntheticAssetId", ""),
collateral_coin.get("starkExAssetId", ""),
collateral_coin.get("starkExAssetId", ""),
params.side.value == "BUY",
amount_synthetic,
amount_collateral,
amount_fee,
nonce,
self.async_client.get_account_id(),
expire_time_unix
)
# Sign the order
sig = self.async_client.sign(sig_hash)
# Convert signature to string (include v component like Go SDK, even though it's empty)
sig_str = f"{sig.r}{sig.s}{sig.v if hasattr(sig, 'v') and sig.v else ''}"
# Create order request
account_id = str(self.async_client.get_account_id())
nonce_str = str(nonce)
l2_expire_time_str = str(l2_expire_time)
expire_time_str = str(l2_expire_time - 864000000) # 10 days earlier
value_str = str(value_dm)
price_str = params.price if params.type == OrderType.LIMIT else "0"
# Prepare request data
request_data = {
"accountId": account_id,
"contractId": params.contract_id,
"price": price_str,
"size": params.size,
"type": params.type.value, # Use .value to get the string value
"timeInForce": params.time_in_force.value, # Use .value to get the string value
"side": params.side.value, # Use .value to get the string value
"l2Signature": sig_str,
"l2Nonce": nonce_str,
"l2ExpireTime": l2_expire_time_str,
"l2Value": value_str,
"l2Size": params.size,
"l2LimitFee": amount_fee_str,
"clientOrderId": client_order_id,
"expireTime": expire_time_str,
"reduceOnly": params.reduce_only
}
# Execute request using async client
return await self.async_client.make_authenticated_request(
method="POST",
path="/api/v1/private/order/createOrder",
data=request_data
)
async def cancel_order(self, params: CancelOrderParams) -> Dict[str, Any]:
"""
Cancel a specific order.
Args:
params: Cancel order parameters
Returns:
Dict[str, Any]: The cancellation result
Raises:
ValueError: If required parameters are missing or invalid
"""
account_id = str(self.async_client.get_account_id())
if params.order_id:
path = "/api/v1/private/order/cancelOrderById"
request_data = {
"accountId": account_id,
"orderIdList": [params.order_id]
}
elif params.client_id:
path = "/api/v1/private/order/cancelOrderByClientOrderId"
request_data = {
"accountId": account_id,
"clientOrderIdList": [params.client_id]
}
elif params.contract_id:
path = "/api/v1/private/order/cancelAllOrder"
request_data = {
"accountId": account_id,
"filterContractIdList": [params.contract_id]
}
else:
raise ValueError("must provide either order_id, client_id, or contract_id")
# Execute request using async client
return await self.async_client.make_authenticated_request(
method="POST",
path=path,
data=request_data
)
async def get_active_orders(self, params: GetActiveOrderParams) -> Dict[str, Any]:
"""
Get active orders with pagination and filters.
Args:
params: Active order query parameters
Returns:
Dict[str, Any]: The active orders
Raises:
ValueError: If the request fails
"""
# Build query parameters
query_params = {
"accountId": str(self.async_client.get_account_id())
}
# Add pagination parameters
if params.size:
query_params["size"] = params.size
if params.offset_data:
query_params["offsetData"] = params.offset_data
# Add filter parameters
if params.filter_coin_id_list:
query_params["filterCoinIdList"] = ",".join(params.filter_coin_id_list)
if params.filter_contract_id_list:
query_params["filterContractIdList"] = ",".join(params.filter_contract_id_list)
if params.filter_type_list:
query_params["filterTypeList"] = ",".join(params.filter_type_list)
if params.filter_status_list:
query_params["filterStatusList"] = ",".join(params.filter_status_list)
# Add boolean filters
if params.filter_is_liquidate is not None:
query_params["filterIsLiquidateList"] = str(params.filter_is_liquidate).lower()
if params.filter_is_deleverage is not None:
query_params["filterIsDeleverageList"] = str(params.filter_is_deleverage).lower()
if params.filter_is_position_tpsl is not None:
query_params["filterIsPositionTpslList"] = str(params.filter_is_position_tpsl).lower()
# Add time filters
if params.filter_start_created_time_inclusive > 0:
query_params["filterStartCreatedTimeInclusive"] = str(params.filter_start_created_time_inclusive)
if params.filter_end_created_time_exclusive > 0:
query_params["filterEndCreatedTimeExclusive"] = str(params.filter_end_created_time_exclusive)
# Execute request using async client
return await self.async_client.make_authenticated_request(
method="GET",
path="/api/v1/private/order/getActiveOrderPage",
params=query_params
)
async def get_order_fill_transactions(self, params: OrderFillTransactionParams) -> Dict[str, Any]:
"""
Get order fill transactions with pagination and filters.
Args:
params: Order fill transaction query parameters
Returns:
Dict[str, Any]: The order fill transactions
Raises:
ValueError: If the request fails
"""
# Build query parameters
query_params = {
"accountId": str(self.async_client.get_account_id())
}
# Add pagination parameters
if params.size:
query_params["size"] = params.size
if params.offset_data:
query_params["offsetData"] = params.offset_data
# Add filter parameters
if params.filter_coin_id_list:
query_params["filterCoinIdList"] = ",".join(params.filter_coin_id_list)
if params.filter_contract_id_list:
query_params["filterContractIdList"] = ",".join(params.filter_contract_id_list)
if params.filter_order_id_list:
query_params["filterOrderIdList"] = ",".join(params.filter_order_id_list)
# Add boolean filters
if params.filter_is_liquidate is not None:
query_params["filterIsLiquidateList"] = str(params.filter_is_liquidate).lower()
if params.filter_is_deleverage is not None:
query_params["filterIsDeleverageList"] = str(params.filter_is_deleverage).lower()
if params.filter_is_position_tpsl is not None:
query_params["filterIsPositionTpslList"] = str(params.filter_is_position_tpsl).lower()
# Add time filters
if params.filter_start_created_time_inclusive > 0:
query_params["filterStartCreatedTimeInclusive"] = str(params.filter_start_created_time_inclusive)
if params.filter_end_created_time_exclusive > 0:
query_params["filterEndCreatedTimeExclusive"] = str(params.filter_end_created_time_exclusive)
# Execute request using async client
return await self.async_client.make_authenticated_request(
method="GET",
path="/api/v1/private/order/getHistoryOrderFillTransactionPage",
params=query_params
)
async def get_max_order_size(self, contract_id: str, price: float) -> Dict[str, Any]:
"""
Get the maximum order size for a given contract and price.
Args:
contract_id: The contract ID
price: The price
Returns:
Dict[str, Any]: The maximum order size information
Raises:
ValueError: If the request fails
"""
# Build request body (API expects POST with JSON body)
data = {
"accountId": str(self.async_client.get_account_id()),
"contractId": contract_id,
"price": str(price)
}
# Execute request using async client
return await self.async_client.make_authenticated_request(
method="POST",
path="/api/v1/private/order/getMaxCreateOrderSize",
data=data
)
@@ -0,0 +1,165 @@
from dataclasses import dataclass
from enum import Enum
from typing import List, Optional, Dict, Any
class TimeInForce(str, Enum):
"""Time in force options for orders."""
UNKNOWN_TIME_IN_FORCE = "UNKNOWN_TIME_IN_FORCE"
GOOD_TIL_CANCEL = "GOOD_TIL_CANCEL"
FILL_OR_KILL = "FILL_OR_KILL"
IMMEDIATE_OR_CANCEL = "IMMEDIATE_OR_CANCEL"
POST_ONLY = "POST_ONLY"
class OrderSide(str, Enum):
"""Order side options."""
BUY = "BUY"
SELL = "SELL"
class ResponseCode(str, Enum):
"""API response codes."""
SUCCESS = "SUCCESS"
class OrderType(str, Enum):
"""Order type options."""
UNKNOWN = "UNKNOWN_ORDER_TYPE"
LIMIT = "LIMIT"
MARKET = "MARKET"
STOP_LIMIT = "STOP_LIMIT"
STOP_MARKET = "STOP_MARKET"
TAKE_PROFIT_LIMIT = "TAKE_PROFIT_LIMIT"
TAKE_PROFIT_MARKET = "TAKE_PROFIT_MARKET"
@dataclass
class OrderFilterParams:
"""Common filter types used across different order APIs."""
filter_coin_id_list: List[str] = None # Filter by coin IDs, empty means all coins
filter_contract_id_list: List[str] = None # Filter by contract IDs, empty means all contracts
filter_type_list: List[str] = None # Filter by order types
filter_status_list: List[str] = None # Filter by order statuses
filter_is_liquidate: Optional[bool] = None # Filter by liquidation status
filter_is_deleverage: Optional[bool] = None # Filter by deleverage status
filter_is_position_tpsl: Optional[bool] = None # Filter by position take-profit/stop-loss status
def __post_init__(self):
"""Initialize empty lists."""
if self.filter_coin_id_list is None:
self.filter_coin_id_list = []
if self.filter_contract_id_list is None:
self.filter_contract_id_list = []
if self.filter_type_list is None:
self.filter_type_list = []
if self.filter_status_list is None:
self.filter_status_list = []
@dataclass
class PaginationParams:
"""Common pagination parameters."""
size: str = "" # Size of the page, must be greater than 0 and less than or equal to 100/200
offset_data: str = "" # Offset data for pagination. Empty string gets the first page
@dataclass
class OrderFillTransactionParams(PaginationParams, OrderFilterParams):
"""Parameters for getting order fill transactions."""
filter_order_id_list: List[str] = None # Filter by order IDs, empty means all orders
filter_start_created_time_inclusive: int = 0 # Filter start time (inclusive), 0 means from earliest
filter_end_created_time_exclusive: int = 0 # Filter end time (exclusive), 0 means until latest
def __post_init__(self):
"""Initialize empty lists."""
super().__post_init__()
if self.filter_order_id_list is None:
self.filter_order_id_list = []
@dataclass
class GetActiveOrderParams(PaginationParams, OrderFilterParams):
"""Parameters for getting active orders."""
filter_start_created_time_inclusive: int = 0 # Filter start time (inclusive), 0 means from earliest
filter_end_created_time_exclusive: int = 0 # Filter end time (exclusive), 0 means until latest
@dataclass
class GetHistoryOrderParams(PaginationParams, OrderFilterParams):
"""Parameters for getting historical orders."""
filter_start_created_time_inclusive: int = 0 # Filter start time (inclusive), 0 means from earliest
filter_end_created_time_exclusive: int = 0 # Filter end time (exclusive), 0 means until latest
@dataclass
class CreateOrderParams:
"""Parameters for creating an order."""
contract_id: str
price: str
size: str
type: OrderType
side: str
client_order_id: Optional[str] = None
l2_expire_time: Optional[int] = None
time_in_force: Optional[str] = None
reduce_only: bool = False
@dataclass
class CancelOrderParams:
"""Parameters for canceling orders."""
order_id: str = "" # Order ID to cancel
client_id: str = "" # Client order ID to cancel
contract_id: str = "" # Contract ID for canceling all orders
class OrderResponse:
"""Response from creating an order."""
code: str
data: Dict[str, Any]
error_param: Optional[Dict[str, Any]]
request_time: str
response_time: str
trace_id: str
def __init__(self, response_data: Dict[str, Any]):
"""Initialize from response data."""
self.code = response_data.get("code", "")
self.data = response_data.get("data", {})
self.error_param = response_data.get("errorParam")
self.request_time = response_data.get("requestTime", "")
self.response_time = response_data.get("responseTime", "")
self.trace_id = response_data.get("traceId", "")
class MaxOrderSizeResponse(OrderResponse):
"""Response from getting max order size."""
pass
class OrderListResponse(OrderResponse):
"""Response from getting a list of orders."""
pass
class OrderPageResponse(OrderResponse):
"""Response from getting paginated orders."""
pass
class OrderFillTransactionResponse(OrderResponse):
"""Response from getting order fill transactions."""
pass
@dataclass
class OrderFillFilterParams(OrderFilterParams):
"""Parameters for filtering order fill transactions."""
filter_order_id_list: List[str] = None # Filter by order IDs, empty means all orders
def __post_init__(self):
"""Initialize empty lists."""
super().__post_init__()
if self.filter_order_id_list is None:
self.filter_order_id_list = []
@@ -0,0 +1,312 @@
from typing import Dict, Any, List
from ..internal.async_client import AsyncClient
class GetKLineParams:
"""Parameters for getting K-line data."""
def __init__(
self,
contract_id: str,
interval: str,
size: str = "",
offset_data: str = "",
filter_start_time_inclusive: int = 0,
filter_end_time_exclusive: int = 0
):
self.contract_id = contract_id
self.interval = interval
self.size = size
self.offset_data = offset_data
self.filter_start_time_inclusive = filter_start_time_inclusive
self.filter_end_time_exclusive = filter_end_time_exclusive
class GetOrderBookDepthParams:
"""Parameters for getting order book depth."""
def __init__(
self,
contract_id: str,
limit: int = 50
):
self.contract_id = contract_id
self.limit = limit
class GetMultiContractKLineParams:
"""Parameters for getting K-line data for multiple contracts."""
def __init__(
self,
contract_id_list: List[str],
interval: str,
limit: int = 1
):
self.contract_id_list = contract_id_list
self.interval = interval
self.limit = limit
class Client:
"""Client for quote-related API endpoints."""
def __init__(self, async_client: AsyncClient):
"""
Initialize the quote client.
Args:
async_client: The async client for common functionality
"""
self.async_client = async_client
async def get_quote_summary(self, contract_id: str) -> Dict[str, Any]:
"""
Get the quote summary for a given contract.
Args:
contract_id: The contract ID
Returns:
Dict[str, Any]: The quote summary
Raises:
ValueError: If the request fails
"""
# Public endpoint - use simple GET request
await self.async_client._ensure_session()
url = f"{self.async_client.base_url}/api/v1/public/quote/getTicketSummary"
params = {
"contractId": contract_id
}
try:
async with self.async_client.session.get(url, params=params) 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:
text = await response.text()
raise ValueError(f"request failed with status code: {response.status}, response: {text}")
resp_data = await response.json()
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 Exception as e:
if isinstance(e, ValueError):
raise
raise ValueError(f"request failed: {str(e)}")
async def get_24_hour_quote(self, contract_id: str) -> Dict[str, Any]:
"""
Get the 24-hour quotes for a given contract.
Args:
contract_id: The contract ID
Returns:
Dict[str, Any]: The 24-hour quotes
Raises:
ValueError: If the request fails
"""
# Public endpoint - use simple GET request
await self.async_client._ensure_session()
url = f"{self.async_client.base_url}/api/v1/public/quote/getTicker"
params = {
"contractId": contract_id
}
try:
async with self.async_client.session.get(url, params=params) 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:
text = await response.text()
raise ValueError(f"request failed with status code: {response.status}, response: {text}")
resp_data = await response.json()
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 Exception as e:
if isinstance(e, ValueError):
raise
raise ValueError(f"request failed: {str(e)}")
async def get_k_line(self, params: GetKLineParams) -> Dict[str, Any]:
"""
Get the K-line data for a contract.
Args:
params: K-line query parameters
Returns:
Dict[str, Any]: The K-line data
Raises:
ValueError: If the request fails
"""
url = f"{self.async_client.base_url}/api/v1/public/quote/getKline"
query_params = {
"contractId": params.contract_id,
"interval": params.interval
}
# Add pagination parameters
if params.size:
query_params["size"] = params.size
if params.offset_data:
query_params["offsetData"] = params.offset_data
# Add time filters
if params.filter_start_time_inclusive > 0:
query_params["filterStartTimeInclusive"] = str(params.filter_start_time_inclusive)
if params.filter_end_time_exclusive > 0:
query_params["filterEndTimeExclusive"] = str(params.filter_end_time_exclusive)
# Public endpoint - use simple GET request
await self.async_client._ensure_session()
url = f"{self.async_client.base_url}/api/v1/public/quote/getKline"
try:
async with self.async_client.session.get(url, params=query_params) 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:
text = await response.text()
raise ValueError(f"request failed with status code: {response.status}, response: {text}")
resp_data = await response.json()
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 Exception as e:
if isinstance(e, ValueError):
raise
raise ValueError(f"request failed: {str(e)}")
async def get_order_book_depth(self, params: GetOrderBookDepthParams) -> Dict[str, Any]:
"""
Get the order book depth for a contract.
Args:
params: Order book depth query parameters
Returns:
Dict[str, Any]: The order book depth
Raises:
ValueError: If the request fails
"""
url = f"{self.async_client.base_url}/api/v1/public/quote/getDepth"
query_params = {
"contractId": params.contract_id,
"level": str(params.limit) # The API expects 'level', not 'limit'
}
# Public endpoint - use simple GET request
await self.async_client._ensure_session()
url = f"{self.async_client.base_url}/api/v1/public/quote/getDepth"
try:
async with self.async_client.session.get(url, params=query_params) 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:
text = await response.text()
raise ValueError(f"request failed with status code: {response.status}, response: {text}")
resp_data = await response.json()
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 Exception as e:
if isinstance(e, ValueError):
raise
raise ValueError(f"request failed: {str(e)}")
async def get_multi_contract_k_line(self, params: GetMultiContractKLineParams) -> Dict[str, Any]:
"""
Get the K-line data for multiple contracts.
Args:
params: Multi-contract K-line query parameters
Returns:
Dict[str, Any]: The K-line data for multiple contracts
Raises:
ValueError: If the request fails
"""
# Public endpoint - use simple GET request
await self.async_client._ensure_session()
url = f"{self.async_client.base_url}/api/v1/public/quote/getMultiContractKline"
query_params = {
"contractIdList": ",".join(params.contract_id_list),
"interval": params.interval,
"limit": str(params.limit)
}
try:
async with self.async_client.session.get(url, params=query_params) 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:
text = await response.text()
raise ValueError(f"request failed with status code: {response.status}, response: {text}")
resp_data = await response.json()
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 Exception as e:
if isinstance(e, ValueError):
raise
raise ValueError(f"request failed: {str(e)}")
@@ -0,0 +1,288 @@
from typing import Dict, Any, List
from ..internal.async_client import AsyncClient
class GetTransferOutByIdParams:
"""Parameters for getting transfer out records by ID."""
def __init__(self, transfer_id_list: List[str]):
self.transfer_id_list = transfer_id_list
class GetTransferInByIdParams:
"""Parameters for getting transfer in records by ID."""
def __init__(self, transfer_id_list: List[str]):
self.transfer_id_list = transfer_id_list
class GetWithdrawAvailableAmountParams:
"""Parameters for getting available withdrawal amount."""
def __init__(self, coin_id: str):
self.coin_id = coin_id
class CreateTransferOutParams:
"""Parameters for creating a transfer out order."""
def __init__(
self,
coin_id: str,
amount: str,
address: str,
network: str,
memo: str = "",
client_order_id: str = None
):
self.coin_id = coin_id
self.amount = amount
self.address = address
self.network = network
self.memo = memo
self.client_order_id = client_order_id
class GetTransferOutPageParams:
"""Parameters for getting transfer out page."""
def __init__(self, size: str = "10", offset_data: str = "", filter_coin_id_list: List[str] = None,
filter_status_list: List[str] = None, filter_start_created_time_inclusive: int = 0,
filter_end_created_time_exclusive: int = 0):
self.size = size
self.offset_data = offset_data
self.filter_coin_id_list = filter_coin_id_list or []
self.filter_status_list = filter_status_list or []
self.filter_start_created_time_inclusive = filter_start_created_time_inclusive
self.filter_end_created_time_exclusive = filter_end_created_time_exclusive
class GetTransferInPageParams:
"""Parameters for getting transfer in page."""
def __init__(self, size: str = "10", offset_data: str = "", filter_coin_id_list: List[str] = None,
filter_status_list: List[str] = None, filter_start_created_time_inclusive: int = 0,
filter_end_created_time_exclusive: int = 0):
self.size = size
self.offset_data = offset_data
self.filter_coin_id_list = filter_coin_id_list or []
self.filter_status_list = filter_status_list or []
self.filter_start_created_time_inclusive = filter_start_created_time_inclusive
self.filter_end_created_time_exclusive = filter_end_created_time_exclusive
class Client:
"""Client for transfer-related API endpoints."""
def __init__(self, async_client: AsyncClient):
"""
Initialize the transfer client.
Args:
async_client: The async client for common functionality
"""
self.async_client = async_client
async def get_transfer_out_by_id(self, params: GetTransferOutByIdParams) -> Dict[str, Any]:
"""
Get transfer out records by ID.
Args:
params: Transfer out query parameters
Returns:
Dict[str, Any]: The transfer out records
Raises:
ValueError: If the request fails
"""
query_params = {
"accountId": str(self.async_client.get_account_id()),
"transferIdList": ",".join(params.transfer_id_list)
}
return await self.async_client.make_authenticated_request(
method="GET",
path="/api/v1/private/transfer/getTransferOutById",
params=query_params
)
async def get_transfer_in_by_id(self, params: GetTransferInByIdParams) -> Dict[str, Any]:
"""
Get transfer in records by ID.
Args:
params: Transfer in query parameters
Returns:
Dict[str, Any]: The transfer in records
Raises:
ValueError: If the request fails
"""
query_params = {
"accountId": str(self.async_client.get_account_id()),
"transferIdList": ",".join(params.transfer_id_list)
}
return await self.async_client.make_authenticated_request(
method="GET",
path="/api/v1/private/transfer/getTransferInById",
params=query_params
)
async def get_withdraw_available_amount(self, params: GetWithdrawAvailableAmountParams) -> Dict[str, Any]:
"""
Get the available withdrawal amount.
Args:
params: Withdrawal available amount query parameters
Returns:
Dict[str, Any]: The available withdrawal amount
Raises:
ValueError: If the request fails
"""
query_params = {
"accountId": str(self.async_client.get_account_id()),
"coinId": params.coin_id
}
return await self.async_client.make_authenticated_request(
method="GET",
path="/api/v1/private/transfer/getTransferOutAvailableAmount",
params=query_params
)
async def create_transfer_out(self, params: CreateTransferOutParams, metadata: Dict[str, Any] = None) -> Dict[str, Any]:
"""
Create a new transfer out order.
Args:
params: Transfer out parameters
metadata: Exchange metadata (optional, not used in current implementation)
Returns:
Dict[str, Any]: The created transfer out order
Raises:
ValueError: If the request fails
"""
client_order_id = params.client_order_id or self.async_client.generate_uuid()
data = {
"accountId": str(self.async_client.get_account_id()),
"coinId": params.coin_id,
"amount": params.amount,
"address": params.address,
"network": params.network,
"clientOrderId": client_order_id
}
if params.memo:
data["memo"] = params.memo
# TODO: Implement signature calculation for transfer out
# This would require:
# 1. Asset ID from metadata based on coin_id
# 2. Receiver public key from address
# 3. Position IDs for sender, receiver, and fee
# 4. Proper expiration time calculation
# 5. Call to calc_transfer_hash and sign the result
# For now, the API call is made without signature (may fail on actual server)
return await self.async_client.make_authenticated_request(
method="POST",
path="/api/v1/private/transfer/createTransferOut",
data=data
)
async def get_transfer_out_page(
self,
params: GetTransferOutPageParams
) -> Dict[str, Any]:
"""
Get transfer out records with pagination.
Args:
params: Parameters for the request
Returns:
Dict[str, Any]: The transfer out records
Raises:
ValueError: If the request fails
"""
query_params = {
"accountId": str(self.async_client.get_account_id())
}
# Add pagination parameters
if params.size:
query_params["size"] = params.size
if params.offset_data:
query_params["offsetData"] = params.offset_data
# Add filter parameters
if params.filter_coin_id_list:
query_params["filterCoinIdList"] = ",".join(params.filter_coin_id_list)
if params.filter_status_list:
query_params["filterStatusList"] = ",".join(params.filter_status_list)
# Add time filters
if params.filter_start_created_time_inclusive > 0:
query_params["filterStartCreatedTimeInclusive"] = str(params.filter_start_created_time_inclusive)
if params.filter_end_created_time_exclusive > 0:
query_params["filterEndCreatedTimeExclusive"] = str(params.filter_end_created_time_exclusive)
return await self.async_client.make_authenticated_request(
method="GET",
path="/api/v1/private/transfer/getActiveTransferOut",
params=query_params
)
async def get_transfer_in_page(
self,
params: GetTransferInPageParams
) -> Dict[str, Any]:
"""
Get transfer in records with pagination.
Args:
params: Parameters for the request
Returns:
Dict[str, Any]: The transfer in records
Raises:
ValueError: If the request fails
"""
query_params = {
"accountId": str(self.async_client.get_account_id())
}
# Add pagination parameters
if params.size:
query_params["size"] = params.size
if params.offset_data:
query_params["offsetData"] = params.offset_data
# Add filter parameters
if params.filter_coin_id_list:
query_params["filterCoinIdList"] = ",".join(params.filter_coin_id_list)
if params.filter_status_list:
query_params["filterStatusList"] = ",".join(params.filter_status_list)
# Add time filters
if params.filter_start_created_time_inclusive > 0:
query_params["filterStartCreatedTimeInclusive"] = str(params.filter_start_created_time_inclusive)
if params.filter_end_created_time_exclusive > 0:
query_params["filterEndCreatedTimeExclusive"] = str(params.filter_end_created_time_exclusive)
return await self.async_client.make_authenticated_request(
method="GET",
path="/api/v1/private/transfer/getActiveTransferIn",
params=query_params
)
@@ -0,0 +1,302 @@
import asyncio
import binascii
import json
import logging
import threading
import time
from typing import Dict, Any, List, Optional, Callable, Union
import websocket
from Crypto.Hash import keccak
from ..internal.signing_adapter import SigningAdapter
from ..internal.client import Client as InternalClient
class Client:
"""WebSocket client for real-time data."""
def __init__(self, url: str, is_private: bool, account_id: int, stark_pri_key: str, signing_adapter: Optional[SigningAdapter] = None):
"""
Initialize the WebSocket client.
Args:
url: WebSocket URL
is_private: Whether this is a private WebSocket connection
account_id: Account ID for authentication
stark_pri_key: Stark private key for signing
"""
self.url = url
self.is_private = is_private
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
self.conn = None
self.handlers = {}
self.done = threading.Event()
self.ping_thread = None
self.subscriptions = set()
self.on_connect_hooks = []
self.on_message_hooks = []
self.on_disconnect_hooks = []
self.logger = logging.getLogger(__name__)
def connect(self):
"""
Establish a WebSocket connection.
Raises:
ValueError: If the connection fails
"""
headers = {}
url = self.url
# Add timestamp parameter for both public and private connections
timestamp = int(time.time() * 1000)
if self.is_private:
# Add timestamp header
headers["X-edgeX-Api-Timestamp"] = str(timestamp)
# Generate signature content (no ? separator, matching Go SDK)
path = f"/api/v1/private/wsaccountId={self.account_id}"
sign_content = f"{timestamp}GET{path}"
# Hash the content
keccak_hash = keccak.new(digest_bits=256)
keccak_hash.update(sign_content.encode())
message_hash = keccak_hash.digest()
# Sign the message using the signing adapter
try:
r, s = self.signing_adapter.sign(message_hash, self.stark_pri_key)
except Exception as e:
raise ValueError(f"failed to sign message: {str(e)}")
# Set signature header
headers["X-edgeX-Api-Signature"] = f"{r}{s}"
else:
# For public connections, add timestamp as URL parameter
separator = "&" if "?" in url else "?"
url = f"{url}{separator}timestamp={timestamp}"
# Create WebSocket connection
try:
self.conn = websocket.create_connection(url, header=headers)
except Exception as e:
raise ValueError(f"failed to connect to WebSocket: {str(e)}")
# Start ping thread
self.done.clear()
self.ping_thread = threading.Thread(target=self._ping_loop)
self.ping_thread.daemon = True
self.ping_thread.start()
# Start message handling thread
self.message_thread = threading.Thread(target=self._handle_messages)
self.message_thread.daemon = True
self.message_thread.start()
# Call connect hooks
for hook in self.on_connect_hooks:
hook()
def close(self):
"""Close the WebSocket connection."""
self.done.set()
if self.conn:
self.conn.close()
self.conn = None
def _ping_loop(self):
"""Send periodic ping messages."""
while not self.done.is_set():
if self.conn:
ping_msg = {
"type": "ping",
"time": str(int(time.time() * 1000))
}
try:
self.conn.send(json.dumps(ping_msg))
except Exception as e:
self.logger.error(f"Failed to send ping: {str(e)}")
break
# Wait for 30 seconds or until done
self.done.wait(30)
def _handle_messages(self):
"""Process incoming WebSocket messages."""
while not self.done.is_set():
if not self.conn:
break
try:
message = self.conn.recv()
# Call message hooks
for hook in self.on_message_hooks:
hook(message)
# Parse message
try:
msg = json.loads(message)
except json.JSONDecodeError:
continue
# Handle ping messages
if msg.get("type") == "ping":
self._handle_pong(msg.get("time", ""))
continue
# Handle quote events
if msg.get("type") == "quote-event":
channel = msg.get("channel", "")
channel_type = channel.split(".")[0] if "." in channel else channel
if channel_type in self.handlers:
self.handlers[channel_type](message)
continue
# Call registered handlers for other message types
msg_type = msg.get("type", "")
if msg_type in self.handlers:
self.handlers[msg_type](message)
except Exception as e:
self.logger.error(f"Error handling message: {str(e)}")
# Call disconnect hooks
for hook in self.on_disconnect_hooks:
hook(e)
break
def _handle_pong(self, timestamp: str):
"""
Send pong response to server ping.
Args:
timestamp: The timestamp from the ping message
"""
pong_msg = {
"type": "pong",
"time": timestamp
}
try:
self.conn.send(json.dumps(pong_msg))
except Exception as e:
self.logger.error(f"Failed to send pong: {str(e)}")
def subscribe(self, topic: str, params: Dict[str, Any] = None) -> bool:
"""
Subscribe to a topic (for public WebSocket).
Args:
topic: The topic to subscribe to
params: Optional parameters for the subscription
Returns:
bool: Whether the subscription was successful
Raises:
ValueError: If the subscription fails
"""
if self.is_private:
raise ValueError("cannot subscribe on private WebSocket connection")
if not self.conn:
raise ValueError("WebSocket connection is not established")
sub_msg = {
"type": "subscribe",
"channel": topic
}
if params:
sub_msg.update(params)
try:
self.conn.send(json.dumps(sub_msg))
self.subscriptions.add(topic)
return True
except Exception as e:
raise ValueError(f"failed to subscribe: {str(e)}")
def unsubscribe(self, topic: str) -> bool:
"""
Unsubscribe from a topic (for public WebSocket).
Args:
topic: The topic to unsubscribe from
Returns:
bool: Whether the unsubscription was successful
Raises:
ValueError: If the unsubscription fails
"""
if self.is_private:
raise ValueError("cannot unsubscribe on private WebSocket connection")
if not self.conn:
raise ValueError("WebSocket connection is not established")
unsub_msg = {
"type": "unsubscribe",
"channel": topic
}
try:
self.conn.send(json.dumps(unsub_msg))
self.subscriptions.discard(topic)
return True
except Exception as e:
raise ValueError(f"failed to unsubscribe: {str(e)}")
def on_message(self, msg_type: str, handler: Callable[[str], None]):
"""
Register a handler for a specific message type.
Args:
msg_type: The message type to handle
handler: The handler function
"""
self.handlers[msg_type] = handler
def on_message_hook(self, hook: Callable[[str], None]):
"""
Register a hook that will be called for all messages.
Args:
hook: The hook function
"""
self.on_message_hooks.append(hook)
def on_connect(self, hook: Callable[[], None]):
"""
Register a hook that will be called when connection is established.
Args:
hook: The hook function
"""
self.on_connect_hooks.append(hook)
def on_disconnect(self, hook: Callable[[Exception], None]):
"""
Register a hook that will be called when connection is closed.
Args:
hook: The hook function
"""
self.on_disconnect_hooks.append(hook)
@@ -0,0 +1,231 @@
import logging
from typing import Dict, Any, List, Optional, Callable
from ..internal.signing_adapter import SigningAdapter
from ..internal.starkex_signing_adapter import StarkExSigningAdapter
from .client import Client
class Manager:
"""Manager for WebSocket connections."""
def __init__(self, base_url: str, account_id: int, stark_pri_key: str, signing_adapter: Optional[SigningAdapter] = None):
"""
Initialize the WebSocket manager.
Args:
base_url: Base WebSocket URL
account_id: Account ID for authentication
stark_pri_key: Stark private key for signing
signing_adapter: Optional signing adapter (defaults to StarkExSigningAdapter)
"""
self.base_url = base_url
self.account_id = account_id
self.stark_pri_key = stark_pri_key
# Use StarkExSigningAdapter as default if none provided
if signing_adapter is None:
signing_adapter = StarkExSigningAdapter()
self.signing_adapter = signing_adapter
self.public_client = None
self.private_client = None
self.logger = logging.getLogger(__name__)
def get_public_client(self) -> Client:
"""
Get the public WebSocket client.
Returns:
Client: The public WebSocket client
"""
if not self.public_client:
self.public_client = Client(
url=f"{self.base_url}/api/v1/public/ws",
is_private=False,
account_id=self.account_id,
stark_pri_key=self.stark_pri_key,
signing_adapter=self.signing_adapter
)
return self.public_client
def get_private_client(self) -> Client:
"""
Get the private WebSocket client.
Returns:
Client: The private WebSocket client
"""
if not self.private_client:
self.private_client = Client(
url=f"{self.base_url}/api/v1/private/ws?accountId={self.account_id}",
is_private=True,
account_id=self.account_id,
stark_pri_key=self.stark_pri_key,
signing_adapter=self.signing_adapter
)
return self.private_client
def connect_public(self):
"""
Connect to the public WebSocket.
Raises:
ValueError: If the connection fails
"""
client = self.get_public_client()
client.connect()
def connect_private(self):
"""
Connect to the private WebSocket.
Raises:
ValueError: If the connection fails
"""
client = self.get_private_client()
client.connect()
def disconnect_public(self):
"""Disconnect from the public WebSocket."""
if self.public_client:
self.public_client.close()
def disconnect_private(self):
"""Disconnect from the private WebSocket."""
if self.private_client:
self.private_client.close()
def disconnect_all(self):
"""Disconnect from all WebSockets."""
self.disconnect_public()
self.disconnect_private()
def subscribe_ticker(self, contract_id: str, handler: Callable[[str], None]):
"""
Subscribe to ticker updates for a contract.
Args:
contract_id: The contract ID
handler: The handler function
Raises:
ValueError: If the subscription fails
"""
client = self.get_public_client()
# Register handler
client.on_message("ticker", handler)
# Subscribe to ticker channel
channel = f"ticker.{contract_id}"
client.subscribe(channel)
def subscribe_kline(self, contract_id: str, interval: str, handler: Callable[[str], None]):
"""
Subscribe to K-line updates for a contract.
Args:
contract_id: The contract ID
interval: The K-line interval
handler: The handler function
Raises:
ValueError: If the subscription fails
"""
client = self.get_public_client()
# Register handler
client.on_message("kline", handler)
# Subscribe to kline channel
channel = f"kline.{contract_id}.{interval}"
client.subscribe(channel)
def subscribe_depth(self, contract_id: str, handler: Callable[[str], None]):
"""
Subscribe to depth updates for a contract.
Args:
contract_id: The contract ID
handler: The handler function
Raises:
ValueError: If the subscription fails
"""
client = self.get_public_client()
# Register handler
client.on_message("depth", handler)
# Subscribe to depth channel
channel = f"depth.{contract_id}"
client.subscribe(channel)
def subscribe_trade(self, contract_id: str, handler: Callable[[str], None]):
"""
Subscribe to trade updates for a contract.
Args:
contract_id: The contract ID
handler: The handler function
Raises:
ValueError: If the subscription fails
"""
client = self.get_public_client()
# Register handler
client.on_message("trade", handler)
# Subscribe to trade channel
channel = f"trade.{contract_id}"
client.subscribe(channel)
def subscribe_account_update(self, handler: Callable[[str], None]):
"""
Subscribe to account updates.
Args:
handler: The handler function
Raises:
ValueError: If the subscription fails
"""
client = self.get_private_client()
# Register handler
client.on_message("account", handler)
def subscribe_order_update(self, handler: Callable[[str], None]):
"""
Subscribe to order updates.
Args:
handler: The handler function
Raises:
ValueError: If the subscription fails
"""
client = self.get_private_client()
# Register handler
client.on_message("order", handler)
def subscribe_position_update(self, handler: Callable[[str], None]):
"""
Subscribe to position updates.
Args:
handler: The handler function
Raises:
ValueError: If the subscription fails
"""
client = self.get_private_client()
# Register handler
client.on_message("position", handler)