mirror of
https://github.com/discountry/ritmex-bot.git
synced 2026-09-10 08:48:07 +00:00
feat: 添加 EdgeX 交易所适配器及相关客户端实现,支持订单、深度和K线数据处理
This commit is contained in:
@@ -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)
|
||||
Reference in New Issue
Block a user