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

This commit is contained in:
discountry
2025-10-04 20:15:41 +08:00
parent ccc3a2bf89
commit 627ed36de4
52 changed files with 15892 additions and 2 deletions
@@ -0,0 +1,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))