diff --git a/examples/create_market_order_async_client.py b/examples/create_market_order_async_client.py new file mode 100644 index 0000000..eb77b26 --- /dev/null +++ b/examples/create_market_order_async_client.py @@ -0,0 +1,51 @@ +import asyncio + +import lighter +from utils import get_api_key_config, trim_exception + + +async def create_signer_for_user(config_file="./api_key_config.json"): + # Constructing the SignerClient performs no network I/O (nonces are + # fetched lazily on first use), so it is safe inside a coroutine. + base_url, account_index, private_keys = get_api_key_config(config_file) + client = lighter.SignerClient( + url=base_url, + account_index=account_index, + api_private_keys=private_keys, + ) + + err = client.check_client() + if err is not None: + await client.close() + raise Exception(f"CheckClient error: {trim_exception(err)}") + + return client + + +async def place_market_order_for_user(config_file="./api_key_config.json"): + client = await create_signer_for_user(config_file) + + try: + # Note: change this to 2048 to trade spot ETH. Make sure you have at least 0.1 ETH to trade spot. + market_index = 0 + + tx, tx_hash, err = await client.create_market_order( + market_index=market_index, + client_order_index=0, + base_amount=100, # 0.1 ETH + avg_execution_price=4000_00, # $4000 -- worst acceptable price for the order + is_ask=False, + ) + print(f"Create Order {tx=} {tx_hash=} {err=}") + if err is not None: + raise Exception(err) + finally: + await client.close() + + +async def main(): + await place_market_order_for_user() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/lighter/nonce_manager.py b/lighter/nonce_manager.py index aedb260..901be8b 100644 --- a/lighter/nonce_manager.py +++ b/lighter/nonce_manager.py @@ -1,15 +1,18 @@ import abc +import asyncio import enum -from typing import Optional, Tuple, List +from typing import Dict, Optional, Tuple, List import requests +from lighter.api.transaction_api import TransactionApi from lighter.api_client import ApiClient from lighter.errors import ValidationError def get_nonce_from_api(client: ApiClient, account_index: int, api_key: int) -> int: - # uses request to avoid async initialization + # Blocking fallback for callers using the sync next_nonce()/refresh paths. + # The async paths never use this; they go through TransactionApi. req = requests.get( client.configuration.host + "/api/v1/nextNonce", params={"account_index": account_index, "api_key_index": api_key}, @@ -20,6 +23,15 @@ def get_nonce_from_api(client: ApiClient, account_index: int, api_key: int) -> i class NonceManager(abc.ABC): + """ + Nonces are fetched lazily on first use per api key, so constructing a + manager (and therefore a SignerClient) performs no network I/O and is safe + inside a running event loop. + + next_nonce() may perform a blocking HTTP call on the first use of each api + key; inside an event loop use async_next_nonce() instead. + """ + def __init__( self, account_index: int, @@ -33,10 +45,37 @@ def __init__( self.account_index = account_index self.api_client = api_client self.api_keys_list = api_keys_list - self.nonce = { - api_keys_list[i]: get_nonce_from_api(api_client, account_index, api_keys_list[i]) - 1 - for i in range(len(api_keys_list)) - } + self.nonce: Dict[int, int] = {} + # created lazily so they bind to the running loop on older Pythons + self._locks: Dict[int, asyncio.Lock] = {} + + def rotate_key(self) -> int: + self.current = (self.current + 1) % len(self.api_keys_list) + return self.api_keys_list[self.current] + + def lock(self, api_key: int) -> asyncio.Lock: + lock = self._locks.get(api_key) + if lock is None: + lock = self._locks[api_key] = asyncio.Lock() + return lock + + def _validate_key(self, api_key: int) -> None: + if api_key not in self.api_keys_list: + raise ValidationError(f"unknown api key index {api_key}") + + async def _fetch_nonce(self, api_key: int) -> int: + resp = await TransactionApi(self.api_client).next_nonce( + account_index=self.account_index, api_key_index=api_key + ) + return resp.nonce + + def _ensure_nonce_sync(self, api_key: int) -> None: + if api_key not in self.nonce: + self.nonce[api_key] = get_nonce_from_api(self.api_client, self.account_index, api_key) - 1 + + async def _ensure_nonce(self, api_key: int) -> None: + if api_key not in self.nonce: + self.nonce[api_key] = await self._fetch_nonce(api_key) - 1 def refresh_nonce(self, api_key: int) -> int: self.nonce[api_key] = get_nonce_from_api(self.api_client, self.account_index, api_key) @@ -45,75 +84,81 @@ def refresh_nonce(self, api_key: int) -> int: def hard_refresh_nonce(self, api_key: int): self.nonce[api_key] = get_nonce_from_api(self.api_client, self.account_index, api_key) - 1 + async def async_refresh_nonce(self, api_key: int) -> int: + self.nonce[api_key] = await self._fetch_nonce(api_key) + return self.nonce[api_key] + + async def async_hard_refresh_nonce(self, api_key: int): + self.nonce[api_key] = await self._fetch_nonce(api_key) - 1 + @abc.abstractmethod def next_nonce(self, api_key: Optional[int] = None) -> Tuple[int, int]: pass + @abc.abstractmethod + async def async_next_nonce(self, api_key: Optional[int] = None) -> Tuple[int, int]: + pass + def acknowledge_failure(self, api_key: int) -> None: pass class OptimisticNonceManager(NonceManager): - def __init__( - self, - account_index: int, - api_client: ApiClient, - api_keys_list: List[int] - ) -> None: - super().__init__(account_index, api_client, api_keys_list) - def next_nonce(self, api_key: Optional[int] = None) -> Tuple[int, int]: if api_key is None: - self.current = (self.current + 1) % len(self.api_keys_list) - api_key = self.api_keys_list[self.current] + api_key = self.rotate_key() + self._validate_key(api_key) + self._ensure_nonce_sync(api_key) + self.nonce[api_key] += 1 + return api_key, self.nonce[api_key] + async def async_next_nonce(self, api_key: Optional[int] = None) -> Tuple[int, int]: + if api_key is None: + api_key = self.rotate_key() + self._validate_key(api_key) + await self._ensure_nonce(api_key) self.nonce[api_key] += 1 return api_key, self.nonce[api_key] def acknowledge_failure(self, api_key: int) -> None: - self.nonce[api_key] -= 1 + if api_key in self.nonce: + self.nonce[api_key] -= 1 class ApiNonceManager(NonceManager): - def __init__( - self, - account_index: int, - api_client: ApiClient, - api_keys_list: List[int], - ) -> None: - super().__init__(account_index, api_client, api_keys_list) + """ + It is recommended to wait at least 350ms before using the same api key. + Please be mindful of your transaction frequency when using this nonce manager. + predicted_execution_time_ms from the response could give you a tighter bound. + """ def next_nonce(self, api_key: Optional[int] = None) -> Tuple[int, int]: - """ - It is recommended to wait at least 350ms before using the same api key. - Please be mindful of your transaction frequency when using this nonce manager. - predicted_execution_time_ms from the response could give you a tighter bound. - """ if api_key is None: - self.current = (self.current + 1) % len(self.api_keys_list) - api_key = self.api_keys_list[self.current] - + api_key = self.rotate_key() + self._validate_key(api_key) nonce = self.refresh_nonce(api_key) return api_key, nonce + async def async_next_nonce(self, api_key: Optional[int] = None) -> Tuple[int, int]: + if api_key is None: + api_key = self.rotate_key() + self._validate_key(api_key) + nonce = await self.async_refresh_nonce(api_key) + return api_key, nonce + class NoOpNonceManager(NonceManager): """For users who provide their own nonces (skip_nonce mode).""" - # noinspection PyMissingConstructor - def __init__(self, account_index, api_client, api_keys_list): - # Skip super().__init__() to avoid the HTTP call - self.account_index = account_index - self.api_client = api_client - self.api_keys_list = api_keys_list - self.nonce = {} - self.current = 0 - def next_nonce(self, api_key=None): + def next_nonce(self, api_key: Optional[int] = None) -> Tuple[int, int]: raise ValidationError( "NoOpNonceManager does not manage nonces. " "You must provide nonce and api_key_index explicitly." ) + async def async_next_nonce(self, api_key: Optional[int] = None) -> Tuple[int, int]: + return self.next_nonce(api_key) + def acknowledge_failure(self, api_key): pass # no-op @@ -123,6 +168,13 @@ def refresh_nonce(self, api_key): def hard_refresh_nonce(self, api_key): pass # no-op + async def async_refresh_nonce(self, api_key): + pass # no-op + + async def async_hard_refresh_nonce(self, api_key): + pass # no-op + + class NonceManagerType(enum.Enum): OPTIMISTIC = 1 API = 2 diff --git a/lighter/signer_client.py b/lighter/signer_client.py index de2e41d..1183676 100644 --- a/lighter/signer_client.py +++ b/lighter/signer_client.py @@ -220,22 +220,31 @@ async def wrapper(self, *args, **kwargs): # Extract api_key_index and nonce from kwargs or use defaults api_key_index = bound_args.arguments.get("api_key_index", 255) nonce = bound_args.arguments.get("nonce", -1) - if api_key_index == 255 and nonce == -1: - api_key_index, nonce = self.nonce_manager.next_nonce() + partial_arguments = {k: v for k, v in bound_args.arguments.items() if k not in ("self", "nonce", "api_key_index")} - # Call the original function with modified kwargs - ret: TxHash - try: - partial_arguments = {k: v for k, v in bound_args.arguments.items() if k not in ("self", "nonce", "api_key_index")} - created_tx, ret, err = await func(self, **partial_arguments, nonce=nonce, api_key_index=api_key_index) - if (ret is None and err) or (ret and ret.code != CODE_OK): - self.nonce_manager.acknowledge_failure(api_key_index) - except lighter.exceptions.BadRequestException as e: - if "invalid nonce" in str(e): - self.nonce_manager.hard_refresh_nonce(api_key_index) + if not (api_key_index == 255 and nonce == -1): + # The caller manages nonces explicitly; the nonce manager is not involved. + try: + return await func(self, **partial_arguments, nonce=nonce, api_key_index=api_key_index) + except lighter.exceptions.BadRequestException as e: return None, None, trim_exc(str(e)) - else: - self.nonce_manager.acknowledge_failure(api_key_index) + + # Pick the key outside the lock so concurrent calls spread across keys, + # then hold the key's lock through the send so transactions on the same + # key reach the sequencer in nonce order. + api_key_index = self.nonce_manager.rotate_key() + ret: TxHash + async with self.nonce_manager.lock(api_key_index): + _, nonce = await self.nonce_manager.async_next_nonce(api_key_index) + try: + created_tx, ret, err = await func(self, **partial_arguments, nonce=nonce, api_key_index=api_key_index) + if (ret is None and err) or (ret and ret.code != CODE_OK): + self.nonce_manager.acknowledge_failure(api_key_index) + except lighter.exceptions.BadRequestException as e: + if "invalid nonce" in str(e): + await self.nonce_manager.async_hard_refresh_nonce(api_key_index) + else: + self.nonce_manager.acknowledge_failure(api_key_index) return None, None, trim_exc(str(e)) return created_tx, ret, err @@ -429,17 +438,6 @@ def check_client(self): def create_api_key(self): return create_api_key() - def get_api_key_nonce(self, api_key_index: int, nonce: int) -> Tuple[int, int]: - if api_key_index != self.DEFAULT_API_KEY_INDEX and nonce != self.DEFAULT_NONCE: - return api_key_index, nonce - - if nonce != self.DEFAULT_NONCE: - if len(self.api_key_dict) == 1: - return self.nonce_manager.next_nonce() - else: - raise Exception("ambiguous api key") - return self.nonce_manager.next_nonce() - def create_auth_token_with_expiry(self, deadline: int = DEFAULT_10_MIN_AUTH_EXPIRY, *, timestamp: int = None, api_key_index: int = DEFAULT_API_KEY_INDEX): if deadline == SignerClient.DEFAULT_10_MIN_AUTH_EXPIRY: deadline = 10 * SignerClient.MINUTE