From 027aeb717ed00170ce50d405957c197f8225f181 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 6 Jul 2026 09:34:23 +0000 Subject: [PATCH 1/4] nonce manager: lazy per-key nonce fetch and per-key send locks - SignerClient construction no longer performs network I/O; nonces are fetched lazily on first use per api key (async path uses the generated TransactionApi instead of blocking requests) - process_api_key_and_nonce holds a per-api-key asyncio.Lock across nonce assignment and send, so concurrent orders on one key arrive in nonce order while different keys send in parallel - explicit nonce/api_key_index callers bypass the nonce manager entirely - remove unused get_api_key_nonce - add unit tests covering lazy fetch, rotation, ordering under asyncio.gather, per-key parallelism, failure/invalid-nonce recovery Co-Authored-By: mihai --- examples/create_market_order_async_client.py | 51 ++++ lighter/nonce_manager.py | 134 ++++++--- lighter/signer_client.py | 48 ++-- test/test_nonce_manager.py | 275 +++++++++++++++++++ 4 files changed, 442 insertions(+), 66 deletions(-) create mode 100644 examples/create_market_order_async_client.py create mode 100644 test/test_nonce_manager.py 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 diff --git a/test/test_nonce_manager.py b/test/test_nonce_manager.py new file mode 100644 index 0000000..c158e05 --- /dev/null +++ b/test/test_nonce_manager.py @@ -0,0 +1,275 @@ +# coding: utf-8 + +import asyncio +import random +import unittest +from types import SimpleNamespace +from unittest import mock + +from lighter.errors import ValidationError +from lighter.exceptions import BadRequestException +from lighter.nonce_manager import ( + ApiNonceManager, + NoOpNonceManager, + NonceManagerType, + OptimisticNonceManager, + nonce_manager_factory, +) +from lighter.signer_client import CODE_OK, process_api_key_and_nonce + + +def make_manager(manager_cls=OptimisticNonceManager, api_keys=(1,), start_nonce=100): + api_client = mock.Mock() + manager = manager_cls(account_index=7, api_client=api_client, api_keys_list=list(api_keys)) + counters = {} + + async def fetch(api_key): + counters[api_key] = counters.get(api_key, 0) + 1 + return start_nonce + + manager._fetch_nonce = mock.AsyncMock(side_effect=fetch) + return manager, counters + + +class DummySigner: + """Minimal stand-in for SignerClient to exercise process_api_key_and_nonce.""" + + def __init__(self, manager): + self.nonce_manager = manager + self.sent = [] + self.in_flight = 0 + self.max_in_flight = 0 + + @process_api_key_and_nonce + async def send(self, delay=0.0, fail=False, exc=None, nonce=-1, api_key_index=255): + self.in_flight += 1 + self.max_in_flight = max(self.max_in_flight, self.in_flight) + try: + if delay: + await asyncio.sleep(delay) + if exc is not None: + raise exc + self.sent.append((api_key_index, nonce)) + if fail: + return None, SimpleNamespace(code=400), "boom" + return "tx", SimpleNamespace(code=CODE_OK), None + finally: + self.in_flight -= 1 + + +class TestConstructionIsIOFree(unittest.TestCase): + def test_no_network_on_construction(self): + with mock.patch("lighter.nonce_manager.requests.get") as sync_get, \ + mock.patch("lighter.nonce_manager.TransactionApi") as tx_api: + for manager_type in NonceManagerType: + nonce_manager_factory( + nonce_manager_type=manager_type, + account_index=1, + api_client=mock.Mock(), + api_keys_list=[2, 4], + ) + sync_get.assert_not_called() + tx_api.assert_not_called() + + def test_empty_api_keys_rejected(self): + with self.assertRaises(ValidationError): + OptimisticNonceManager(account_index=1, api_client=mock.Mock(), api_keys_list=[]) + + def test_factory_rejects_invalid_type(self): + with self.assertRaises(ValidationError): + nonce_manager_factory("bogus", 1, mock.Mock(), [2]) + + +class TestOptimisticNonceManager(unittest.IsolatedAsyncioTestCase): + async def test_lazy_fetch_happens_once_per_key(self): + manager, counters = make_manager(api_keys=(3,), start_nonce=100) + key, nonce = await manager.async_next_nonce(3) + self.assertEqual((key, nonce), (3, 100)) + key, nonce = await manager.async_next_nonce(3) + self.assertEqual((key, nonce), (3, 101)) + self.assertEqual(counters[3], 1) + + async def test_rotation_across_keys(self): + manager, counters = make_manager(api_keys=(1, 2), start_nonce=50) + first = await manager.async_next_nonce() + second = await manager.async_next_nonce() + third = await manager.async_next_nonce() + self.assertEqual(first, (2, 50)) + self.assertEqual(second, (1, 50)) + self.assertEqual(third, (2, 51)) + self.assertEqual(counters, {1: 1, 2: 1}) + + async def test_unknown_api_key_rejected(self): + manager, _ = make_manager(api_keys=(1,)) + with self.assertRaises(ValidationError): + await manager.async_next_nonce(9) + with self.assertRaises(ValidationError): + manager.next_nonce(9) + + async def test_acknowledge_failure_reuses_nonce(self): + manager, _ = make_manager(api_keys=(1,), start_nonce=10) + _, nonce = await manager.async_next_nonce(1) + manager.acknowledge_failure(1) + _, nonce_again = await manager.async_next_nonce(1) + self.assertEqual(nonce, nonce_again) + + async def test_acknowledge_failure_before_first_use_is_safe(self): + manager, _ = make_manager(api_keys=(1,), start_nonce=10) + manager.acknowledge_failure(1) # must not raise + _, nonce = await manager.async_next_nonce(1) + self.assertEqual(nonce, 10) + + async def test_async_hard_refresh_resets_to_server_value(self): + manager, _ = make_manager(api_keys=(1,), start_nonce=10) + await manager.async_next_nonce(1) + await manager.async_next_nonce(1) + manager._fetch_nonce = mock.AsyncMock(return_value=42) + await manager.async_hard_refresh_nonce(1) + _, nonce = await manager.async_next_nonce(1) + self.assertEqual(nonce, 42) + + def test_sync_lazy_fetch(self): + manager = OptimisticNonceManager(account_index=7, api_client=mock.Mock(), api_keys_list=[1]) + with mock.patch("lighter.nonce_manager.get_nonce_from_api", return_value=20) as sync_get: + self.assertEqual(manager.next_nonce(1), (1, 20)) + self.assertEqual(manager.next_nonce(1), (1, 21)) + sync_get.assert_called_once() + + +class TestApiNonceManager(unittest.IsolatedAsyncioTestCase): + async def test_refreshes_on_every_call(self): + manager, counters = make_manager(ApiNonceManager, api_keys=(1,), start_nonce=30) + self.assertEqual(await manager.async_next_nonce(1), (1, 30)) + self.assertEqual(await manager.async_next_nonce(1), (1, 30)) + self.assertEqual(counters[1], 2) + + async def test_rotation(self): + manager, _ = make_manager(ApiNonceManager, api_keys=(1, 2), start_nonce=30) + self.assertEqual((await manager.async_next_nonce())[0], 2) + self.assertEqual((await manager.async_next_nonce())[0], 1) + + +class TestNoOpNonceManager(unittest.IsolatedAsyncioTestCase): + async def test_next_nonce_raises(self): + manager = NoOpNonceManager(account_index=1, api_client=mock.Mock(), api_keys_list=[1]) + with self.assertRaises(ValidationError): + manager.next_nonce() + with self.assertRaises(ValidationError): + await manager.async_next_nonce() + + async def test_refresh_and_failure_are_noops(self): + manager = NoOpNonceManager(account_index=1, api_client=mock.Mock(), api_keys_list=[1]) + manager.acknowledge_failure(1) + manager.refresh_nonce(1) + manager.hard_refresh_nonce(1) + await manager.async_refresh_nonce(1) + await manager.async_hard_refresh_nonce(1) + self.assertEqual(manager.nonce, {}) + + +class TestProcessApiKeyAndNonce(unittest.IsolatedAsyncioTestCase): + async def test_single_key_concurrent_sends_are_ordered(self): + manager, _ = make_manager(api_keys=(1,), start_nonce=0) + signer = DummySigner(manager) + random.seed(1234) + results = await asyncio.gather( + *[signer.send(delay=random.uniform(0.0, 0.02)) for _ in range(20)] + ) + for _, ret, err in results: + self.assertIsNone(err) + self.assertEqual(ret.code, CODE_OK) + nonces = [nonce for _, nonce in signer.sent] + self.assertEqual(nonces, sorted(nonces)) + self.assertEqual(len(set(nonces)), 20) + self.assertEqual(nonces, list(range(0, 20))) + + async def test_multiple_keys_send_in_parallel(self): + manager, _ = make_manager(api_keys=(1, 2), start_nonce=0) + signer = DummySigner(manager) + await asyncio.gather(*[signer.send(delay=0.05) for _ in range(2)]) + self.assertEqual(signer.max_in_flight, 2) + self.assertEqual({key for key, _ in signer.sent}, {1, 2}) + + async def test_same_key_sends_are_serialized(self): + manager, _ = make_manager(api_keys=(1,), start_nonce=0) + signer = DummySigner(manager) + await asyncio.gather(*[signer.send(delay=0.02) for _ in range(3)]) + self.assertEqual(signer.max_in_flight, 1) + + async def test_failure_return_code_reuses_nonce(self): + manager, _ = make_manager(api_keys=(1,), start_nonce=0) + signer = DummySigner(manager) + await signer.send(fail=True) + await signer.send() + nonces = [nonce for _, nonce in signer.sent] + self.assertEqual(nonces, [0, 0]) + + async def test_invalid_nonce_triggers_async_hard_refresh(self): + manager, _ = make_manager(api_keys=(1,), start_nonce=0) + # first fetch initializes lazily (10), second fetch is the hard refresh (42) + manager._fetch_nonce = mock.AsyncMock(side_effect=[10, 42]) + signer = DummySigner(manager) + exc = BadRequestException(status=400, reason="invalid nonce") + created_tx, ret, err = await signer.send(exc=exc) + self.assertIsNone(created_tx) + self.assertIsNone(ret) + self.assertIn("invalid nonce", err) + self.assertEqual(manager._fetch_nonce.await_count, 2) + await signer.send() + self.assertEqual(signer.sent, [(1, 42)]) + + async def test_other_bad_request_acknowledges_failure(self): + manager, _ = make_manager(api_keys=(1,), start_nonce=0) + signer = DummySigner(manager) + exc = BadRequestException(status=400, reason="insufficient balance") + _, _, err = await signer.send(exc=exc) + self.assertIn("insufficient balance", err) + await signer.send() + self.assertEqual(signer.sent, [(1, 0)]) # failed nonce is reused + + async def test_non_bad_request_exception_propagates(self): + manager, _ = make_manager(api_keys=(1,), start_nonce=0) + signer = DummySigner(manager) + with self.assertRaises(RuntimeError): + await signer.send(exc=RuntimeError("boom")) + + async def test_explicit_nonce_bypasses_manager(self): + manager, counters = make_manager(api_keys=(1,), start_nonce=0) + signer = DummySigner(manager) + created_tx, ret, err = await signer.send(nonce=7, api_key_index=3) + self.assertIsNone(err) + self.assertEqual(signer.sent, [(3, 7)]) + self.assertEqual(counters, {}) + self.assertEqual(manager.nonce, {}) + + async def test_explicit_nonce_bad_request_returns_error_without_touching_manager(self): + manager, counters = make_manager(api_keys=(1,), start_nonce=0) + signer = DummySigner(manager) + exc = BadRequestException(status=400, reason="invalid nonce") + created_tx, ret, err = await signer.send(nonce=7, api_key_index=3, exc=exc) + self.assertIsNone(created_tx) + self.assertIsNone(ret) + self.assertIn("invalid nonce", err) + self.assertEqual(counters, {}) + self.assertEqual(manager.nonce, {}) + + async def test_noop_manager_requires_explicit_nonce(self): + manager = NoOpNonceManager(account_index=1, api_client=mock.Mock(), api_keys_list=[1]) + signer = DummySigner(manager) + with self.assertRaises(ValidationError): + await signer.send() + created_tx, ret, err = await signer.send(nonce=5, api_key_index=1) + self.assertIsNone(err) + self.assertEqual(signer.sent, [(1, 5)]) + + async def test_concurrent_first_use_fetches_once(self): + manager, counters = make_manager(api_keys=(1,), start_nonce=0) + signer = DummySigner(manager) + await asyncio.gather(*[signer.send() for _ in range(5)]) + self.assertEqual(counters[1], 1) + nonces = [nonce for _, nonce in signer.sent] + self.assertEqual(nonces, list(range(0, 5))) + + +if __name__ == "__main__": + unittest.main() From 54093f60725c0daa998fdc5f1912c3e64a6b8666 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 6 Jul 2026 22:08:53 +0000 Subject: [PATCH 2/4] refactor: make process_api_key_and_nonce more readable - extract arg binding into extract_tx_args - name the explicit-nonce and rejected-tx conditions - module-level DEFAULT_NONCE / DEFAULT_API_KEY_INDEX constants - docstring describing the locking/ordering behavior Co-Authored-By: mihai --- lighter/signer_client.py | 71 +++++++++++++++++++++++++--------------- 1 file changed, 44 insertions(+), 27 deletions(-) diff --git a/lighter/signer_client.py b/lighter/signer_client.py index 1183676..783786b 100644 --- a/lighter/signer_client.py +++ b/lighter/signer_client.py @@ -22,6 +22,8 @@ from lighter.transactions import CreateOrder, CancelOrder, Withdraw, CreateGroupedOrders CODE_OK = 200 +DEFAULT_NONCE = -1 +DEFAULT_API_KEY_INDEX = 255 class ApiKeyResponse(ctypes.Structure): @@ -208,53 +210,68 @@ def trim_exc(exception_body: str): return exception_body.strip().split("\n")[-1] +def extract_tx_args(func, self, args, kwargs): + """Bind the call arguments and split out nonce and api_key_index.""" + bound = inspect.signature(func).bind(self, *args, **kwargs) + bound.apply_defaults() + api_key_index = bound.arguments.get("api_key_index", DEFAULT_API_KEY_INDEX) + nonce = bound.arguments.get("nonce", DEFAULT_NONCE) + send_args = {k: v for k, v in bound.arguments.items() if k not in ("self", "nonce", "api_key_index")} + return send_args, api_key_index, nonce + + def process_api_key_and_nonce(func): + """ + Fills in nonce and api_key_index for transaction methods. + + If the caller provides nonce/api_key_index explicitly, the transaction is + sent as-is and the nonce manager is not involved. + + Otherwise an api key is picked round-robin and that key's lock is held from + nonce assignment through the send, so transactions on the same key reach + the sequencer in nonce order (one in-flight transaction per key; different + keys send in parallel). Failure accounting happens inside the same critical + section: a rejected transaction rolls its nonce back for reuse, and an + "invalid nonce" error re-syncs the nonce from the API. + """ @wraps(func) async def wrapper(self, *args, **kwargs): - # Get the signature - sig = inspect.signature(func) - - # Bind args and kwargs to the function's signature - bound_args = sig.bind(self, *args, **kwargs) - bound_args.apply_defaults() - # 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) - partial_arguments = {k: v for k, v in bound_args.arguments.items() if k not in ("self", "nonce", "api_key_index")} - - if not (api_key_index == 255 and nonce == -1): - # The caller manages nonces explicitly; the nonce manager is not involved. + send_args, api_key_index, nonce = extract_tx_args(func, self, args, kwargs) + + caller_manages_nonce = api_key_index != DEFAULT_API_KEY_INDEX or nonce != DEFAULT_NONCE + if caller_manages_nonce: try: - return await func(self, **partial_arguments, nonce=nonce, api_key_index=api_key_index) + return await func(self, **send_args, nonce=nonce, api_key_index=api_key_index) except lighter.exceptions.BadRequestException as e: return None, None, trim_exc(str(e)) - # 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() + nonce_manager = self.nonce_manager + api_key_index = 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) + async with nonce_manager.lock(api_key_index): + _, nonce = await 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) + created_tx, ret, err = await func(self, **send_args, nonce=nonce, api_key_index=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) + await nonce_manager.async_hard_refresh_nonce(api_key_index) else: - self.nonce_manager.acknowledge_failure(api_key_index) + nonce_manager.acknowledge_failure(api_key_index) return None, None, trim_exc(str(e)) + tx_rejected = (ret is None and err) or (ret is not None and ret.code != CODE_OK) + if tx_rejected: + nonce_manager.acknowledge_failure(api_key_index) + return created_tx, ret, err return wrapper class SignerClient: - DEFAULT_NONCE = -1 - DEFAULT_API_KEY_INDEX = 255 + DEFAULT_NONCE = DEFAULT_NONCE + DEFAULT_API_KEY_INDEX = DEFAULT_API_KEY_INDEX SKIP_NONCE_OFF = 0 SKIP_NONCE_ON = 1 From 34e18a22db82a5b49fa4bf7d9a443b26f8fa9590 Mon Sep 17 00:00:00 2001 From: mihaimarcu Date: Tue, 7 Jul 2026 00:28:45 +0200 Subject: [PATCH 3/4] Revert "refactor: make process_api_key_and_nonce more readable" This reverts commit 54093f60725c0daa998fdc5f1912c3e64a6b8666. --- lighter/signer_client.py | 71 +++++++++++++++------------------------- 1 file changed, 27 insertions(+), 44 deletions(-) diff --git a/lighter/signer_client.py b/lighter/signer_client.py index 783786b..1183676 100644 --- a/lighter/signer_client.py +++ b/lighter/signer_client.py @@ -22,8 +22,6 @@ from lighter.transactions import CreateOrder, CancelOrder, Withdraw, CreateGroupedOrders CODE_OK = 200 -DEFAULT_NONCE = -1 -DEFAULT_API_KEY_INDEX = 255 class ApiKeyResponse(ctypes.Structure): @@ -210,68 +208,53 @@ def trim_exc(exception_body: str): return exception_body.strip().split("\n")[-1] -def extract_tx_args(func, self, args, kwargs): - """Bind the call arguments and split out nonce and api_key_index.""" - bound = inspect.signature(func).bind(self, *args, **kwargs) - bound.apply_defaults() - api_key_index = bound.arguments.get("api_key_index", DEFAULT_API_KEY_INDEX) - nonce = bound.arguments.get("nonce", DEFAULT_NONCE) - send_args = {k: v for k, v in bound.arguments.items() if k not in ("self", "nonce", "api_key_index")} - return send_args, api_key_index, nonce - - def process_api_key_and_nonce(func): - """ - Fills in nonce and api_key_index for transaction methods. - - If the caller provides nonce/api_key_index explicitly, the transaction is - sent as-is and the nonce manager is not involved. - - Otherwise an api key is picked round-robin and that key's lock is held from - nonce assignment through the send, so transactions on the same key reach - the sequencer in nonce order (one in-flight transaction per key; different - keys send in parallel). Failure accounting happens inside the same critical - section: a rejected transaction rolls its nonce back for reuse, and an - "invalid nonce" error re-syncs the nonce from the API. - """ @wraps(func) async def wrapper(self, *args, **kwargs): - send_args, api_key_index, nonce = extract_tx_args(func, self, args, kwargs) - - caller_manages_nonce = api_key_index != DEFAULT_API_KEY_INDEX or nonce != DEFAULT_NONCE - if caller_manages_nonce: + # Get the signature + sig = inspect.signature(func) + + # Bind args and kwargs to the function's signature + bound_args = sig.bind(self, *args, **kwargs) + bound_args.apply_defaults() + # 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) + partial_arguments = {k: v for k, v in bound_args.arguments.items() if k not in ("self", "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, **send_args, nonce=nonce, api_key_index=api_key_index) + 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)) - nonce_manager = self.nonce_manager - api_key_index = nonce_manager.rotate_key() - + # 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 nonce_manager.lock(api_key_index): - _, nonce = await nonce_manager.async_next_nonce(api_key_index) + 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, **send_args, nonce=nonce, api_key_index=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): - await nonce_manager.async_hard_refresh_nonce(api_key_index) + await self.nonce_manager.async_hard_refresh_nonce(api_key_index) else: - nonce_manager.acknowledge_failure(api_key_index) + self.nonce_manager.acknowledge_failure(api_key_index) return None, None, trim_exc(str(e)) - tx_rejected = (ret is None and err) or (ret is not None and ret.code != CODE_OK) - if tx_rejected: - nonce_manager.acknowledge_failure(api_key_index) - return created_tx, ret, err return wrapper class SignerClient: - DEFAULT_NONCE = DEFAULT_NONCE - DEFAULT_API_KEY_INDEX = DEFAULT_API_KEY_INDEX + DEFAULT_NONCE = -1 + DEFAULT_API_KEY_INDEX = 255 SKIP_NONCE_OFF = 0 SKIP_NONCE_ON = 1 From 2f309479d6b0250dba48549da9b59830c6611d55 Mon Sep 17 00:00:00 2001 From: mihaimarcu Date: Tue, 7 Jul 2026 00:29:34 +0200 Subject: [PATCH 4/4] erase unused test code --- test/test_nonce_manager.py | 275 ------------------------------------- 1 file changed, 275 deletions(-) delete mode 100644 test/test_nonce_manager.py diff --git a/test/test_nonce_manager.py b/test/test_nonce_manager.py deleted file mode 100644 index c158e05..0000000 --- a/test/test_nonce_manager.py +++ /dev/null @@ -1,275 +0,0 @@ -# coding: utf-8 - -import asyncio -import random -import unittest -from types import SimpleNamespace -from unittest import mock - -from lighter.errors import ValidationError -from lighter.exceptions import BadRequestException -from lighter.nonce_manager import ( - ApiNonceManager, - NoOpNonceManager, - NonceManagerType, - OptimisticNonceManager, - nonce_manager_factory, -) -from lighter.signer_client import CODE_OK, process_api_key_and_nonce - - -def make_manager(manager_cls=OptimisticNonceManager, api_keys=(1,), start_nonce=100): - api_client = mock.Mock() - manager = manager_cls(account_index=7, api_client=api_client, api_keys_list=list(api_keys)) - counters = {} - - async def fetch(api_key): - counters[api_key] = counters.get(api_key, 0) + 1 - return start_nonce - - manager._fetch_nonce = mock.AsyncMock(side_effect=fetch) - return manager, counters - - -class DummySigner: - """Minimal stand-in for SignerClient to exercise process_api_key_and_nonce.""" - - def __init__(self, manager): - self.nonce_manager = manager - self.sent = [] - self.in_flight = 0 - self.max_in_flight = 0 - - @process_api_key_and_nonce - async def send(self, delay=0.0, fail=False, exc=None, nonce=-1, api_key_index=255): - self.in_flight += 1 - self.max_in_flight = max(self.max_in_flight, self.in_flight) - try: - if delay: - await asyncio.sleep(delay) - if exc is not None: - raise exc - self.sent.append((api_key_index, nonce)) - if fail: - return None, SimpleNamespace(code=400), "boom" - return "tx", SimpleNamespace(code=CODE_OK), None - finally: - self.in_flight -= 1 - - -class TestConstructionIsIOFree(unittest.TestCase): - def test_no_network_on_construction(self): - with mock.patch("lighter.nonce_manager.requests.get") as sync_get, \ - mock.patch("lighter.nonce_manager.TransactionApi") as tx_api: - for manager_type in NonceManagerType: - nonce_manager_factory( - nonce_manager_type=manager_type, - account_index=1, - api_client=mock.Mock(), - api_keys_list=[2, 4], - ) - sync_get.assert_not_called() - tx_api.assert_not_called() - - def test_empty_api_keys_rejected(self): - with self.assertRaises(ValidationError): - OptimisticNonceManager(account_index=1, api_client=mock.Mock(), api_keys_list=[]) - - def test_factory_rejects_invalid_type(self): - with self.assertRaises(ValidationError): - nonce_manager_factory("bogus", 1, mock.Mock(), [2]) - - -class TestOptimisticNonceManager(unittest.IsolatedAsyncioTestCase): - async def test_lazy_fetch_happens_once_per_key(self): - manager, counters = make_manager(api_keys=(3,), start_nonce=100) - key, nonce = await manager.async_next_nonce(3) - self.assertEqual((key, nonce), (3, 100)) - key, nonce = await manager.async_next_nonce(3) - self.assertEqual((key, nonce), (3, 101)) - self.assertEqual(counters[3], 1) - - async def test_rotation_across_keys(self): - manager, counters = make_manager(api_keys=(1, 2), start_nonce=50) - first = await manager.async_next_nonce() - second = await manager.async_next_nonce() - third = await manager.async_next_nonce() - self.assertEqual(first, (2, 50)) - self.assertEqual(second, (1, 50)) - self.assertEqual(third, (2, 51)) - self.assertEqual(counters, {1: 1, 2: 1}) - - async def test_unknown_api_key_rejected(self): - manager, _ = make_manager(api_keys=(1,)) - with self.assertRaises(ValidationError): - await manager.async_next_nonce(9) - with self.assertRaises(ValidationError): - manager.next_nonce(9) - - async def test_acknowledge_failure_reuses_nonce(self): - manager, _ = make_manager(api_keys=(1,), start_nonce=10) - _, nonce = await manager.async_next_nonce(1) - manager.acknowledge_failure(1) - _, nonce_again = await manager.async_next_nonce(1) - self.assertEqual(nonce, nonce_again) - - async def test_acknowledge_failure_before_first_use_is_safe(self): - manager, _ = make_manager(api_keys=(1,), start_nonce=10) - manager.acknowledge_failure(1) # must not raise - _, nonce = await manager.async_next_nonce(1) - self.assertEqual(nonce, 10) - - async def test_async_hard_refresh_resets_to_server_value(self): - manager, _ = make_manager(api_keys=(1,), start_nonce=10) - await manager.async_next_nonce(1) - await manager.async_next_nonce(1) - manager._fetch_nonce = mock.AsyncMock(return_value=42) - await manager.async_hard_refresh_nonce(1) - _, nonce = await manager.async_next_nonce(1) - self.assertEqual(nonce, 42) - - def test_sync_lazy_fetch(self): - manager = OptimisticNonceManager(account_index=7, api_client=mock.Mock(), api_keys_list=[1]) - with mock.patch("lighter.nonce_manager.get_nonce_from_api", return_value=20) as sync_get: - self.assertEqual(manager.next_nonce(1), (1, 20)) - self.assertEqual(manager.next_nonce(1), (1, 21)) - sync_get.assert_called_once() - - -class TestApiNonceManager(unittest.IsolatedAsyncioTestCase): - async def test_refreshes_on_every_call(self): - manager, counters = make_manager(ApiNonceManager, api_keys=(1,), start_nonce=30) - self.assertEqual(await manager.async_next_nonce(1), (1, 30)) - self.assertEqual(await manager.async_next_nonce(1), (1, 30)) - self.assertEqual(counters[1], 2) - - async def test_rotation(self): - manager, _ = make_manager(ApiNonceManager, api_keys=(1, 2), start_nonce=30) - self.assertEqual((await manager.async_next_nonce())[0], 2) - self.assertEqual((await manager.async_next_nonce())[0], 1) - - -class TestNoOpNonceManager(unittest.IsolatedAsyncioTestCase): - async def test_next_nonce_raises(self): - manager = NoOpNonceManager(account_index=1, api_client=mock.Mock(), api_keys_list=[1]) - with self.assertRaises(ValidationError): - manager.next_nonce() - with self.assertRaises(ValidationError): - await manager.async_next_nonce() - - async def test_refresh_and_failure_are_noops(self): - manager = NoOpNonceManager(account_index=1, api_client=mock.Mock(), api_keys_list=[1]) - manager.acknowledge_failure(1) - manager.refresh_nonce(1) - manager.hard_refresh_nonce(1) - await manager.async_refresh_nonce(1) - await manager.async_hard_refresh_nonce(1) - self.assertEqual(manager.nonce, {}) - - -class TestProcessApiKeyAndNonce(unittest.IsolatedAsyncioTestCase): - async def test_single_key_concurrent_sends_are_ordered(self): - manager, _ = make_manager(api_keys=(1,), start_nonce=0) - signer = DummySigner(manager) - random.seed(1234) - results = await asyncio.gather( - *[signer.send(delay=random.uniform(0.0, 0.02)) for _ in range(20)] - ) - for _, ret, err in results: - self.assertIsNone(err) - self.assertEqual(ret.code, CODE_OK) - nonces = [nonce for _, nonce in signer.sent] - self.assertEqual(nonces, sorted(nonces)) - self.assertEqual(len(set(nonces)), 20) - self.assertEqual(nonces, list(range(0, 20))) - - async def test_multiple_keys_send_in_parallel(self): - manager, _ = make_manager(api_keys=(1, 2), start_nonce=0) - signer = DummySigner(manager) - await asyncio.gather(*[signer.send(delay=0.05) for _ in range(2)]) - self.assertEqual(signer.max_in_flight, 2) - self.assertEqual({key for key, _ in signer.sent}, {1, 2}) - - async def test_same_key_sends_are_serialized(self): - manager, _ = make_manager(api_keys=(1,), start_nonce=0) - signer = DummySigner(manager) - await asyncio.gather(*[signer.send(delay=0.02) for _ in range(3)]) - self.assertEqual(signer.max_in_flight, 1) - - async def test_failure_return_code_reuses_nonce(self): - manager, _ = make_manager(api_keys=(1,), start_nonce=0) - signer = DummySigner(manager) - await signer.send(fail=True) - await signer.send() - nonces = [nonce for _, nonce in signer.sent] - self.assertEqual(nonces, [0, 0]) - - async def test_invalid_nonce_triggers_async_hard_refresh(self): - manager, _ = make_manager(api_keys=(1,), start_nonce=0) - # first fetch initializes lazily (10), second fetch is the hard refresh (42) - manager._fetch_nonce = mock.AsyncMock(side_effect=[10, 42]) - signer = DummySigner(manager) - exc = BadRequestException(status=400, reason="invalid nonce") - created_tx, ret, err = await signer.send(exc=exc) - self.assertIsNone(created_tx) - self.assertIsNone(ret) - self.assertIn("invalid nonce", err) - self.assertEqual(manager._fetch_nonce.await_count, 2) - await signer.send() - self.assertEqual(signer.sent, [(1, 42)]) - - async def test_other_bad_request_acknowledges_failure(self): - manager, _ = make_manager(api_keys=(1,), start_nonce=0) - signer = DummySigner(manager) - exc = BadRequestException(status=400, reason="insufficient balance") - _, _, err = await signer.send(exc=exc) - self.assertIn("insufficient balance", err) - await signer.send() - self.assertEqual(signer.sent, [(1, 0)]) # failed nonce is reused - - async def test_non_bad_request_exception_propagates(self): - manager, _ = make_manager(api_keys=(1,), start_nonce=0) - signer = DummySigner(manager) - with self.assertRaises(RuntimeError): - await signer.send(exc=RuntimeError("boom")) - - async def test_explicit_nonce_bypasses_manager(self): - manager, counters = make_manager(api_keys=(1,), start_nonce=0) - signer = DummySigner(manager) - created_tx, ret, err = await signer.send(nonce=7, api_key_index=3) - self.assertIsNone(err) - self.assertEqual(signer.sent, [(3, 7)]) - self.assertEqual(counters, {}) - self.assertEqual(manager.nonce, {}) - - async def test_explicit_nonce_bad_request_returns_error_without_touching_manager(self): - manager, counters = make_manager(api_keys=(1,), start_nonce=0) - signer = DummySigner(manager) - exc = BadRequestException(status=400, reason="invalid nonce") - created_tx, ret, err = await signer.send(nonce=7, api_key_index=3, exc=exc) - self.assertIsNone(created_tx) - self.assertIsNone(ret) - self.assertIn("invalid nonce", err) - self.assertEqual(counters, {}) - self.assertEqual(manager.nonce, {}) - - async def test_noop_manager_requires_explicit_nonce(self): - manager = NoOpNonceManager(account_index=1, api_client=mock.Mock(), api_keys_list=[1]) - signer = DummySigner(manager) - with self.assertRaises(ValidationError): - await signer.send() - created_tx, ret, err = await signer.send(nonce=5, api_key_index=1) - self.assertIsNone(err) - self.assertEqual(signer.sent, [(1, 5)]) - - async def test_concurrent_first_use_fetches_once(self): - manager, counters = make_manager(api_keys=(1,), start_nonce=0) - signer = DummySigner(manager) - await asyncio.gather(*[signer.send() for _ in range(5)]) - self.assertEqual(counters[1], 1) - nonces = [nonce for _, nonce in signer.sent] - self.assertEqual(nonces, list(range(0, 5))) - - -if __name__ == "__main__": - unittest.main()