From fea2d93b644caa9c76e94f5e6d2bb7be74b9ce62 Mon Sep 17 00:00:00 2001 From: Josu Date: Thu, 6 Aug 2026 07:26:08 +0000 Subject: [PATCH 1/3] Speed up native signer response cleanup --- lighter/signer_client.py | 42 ++++++++++++++++++++----- test/test_signer_client.py | 64 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 99 insertions(+), 7 deletions(-) create mode 100644 test/test_signer_client.py diff --git a/lighter/signer_client.py b/lighter/signer_client.py index 53a172a..fab341d 100644 --- a/lighter/signer_client.py +++ b/lighter/signer_client.py @@ -58,6 +58,8 @@ class SignedTxResponse(ctypes.Structure): __signer = None +__native_free = None +__native_allocator = None def __get_shared_library(): @@ -87,20 +89,46 @@ def __get_shared_library(): ) +def __get_native_free(): + global __native_free, __native_allocator + if __native_free is not None: + return __native_free + + if os.name == "posix": + # Go's C.CString allocates with C.malloc. On POSIX there is one process + # allocator, so calling free directly avoids an expensive Python -> Go + # transition for every returned string. + try: + __native_allocator = ctypes.CDLL(None) + native_free = __native_allocator.free + native_free.argtypes = [ctypes.c_void_p] + native_free.restype = None + __native_free = native_free + except (AttributeError, OSError): + __native_free = get_signer().Free + else: + # Windows can have multiple CRT heaps. Keep using the signer's exported + # Free function so allocation and deallocation use the same runtime. + __native_free = get_signer().Free + + return __native_free + + +def free_pointer(ptr: Any) -> None: + if ptr: + __get_native_free()(ptr) + + def decode_and_free(ptr: Any) -> Optional[str]: if not ptr: return None try: - # Read the string from the pointer - c_str = ctypes.cast(ptr, ctypes.c_char_p).value + c_str = ctypes.string_at(ptr) if c_str is not None: return c_str.decode('utf-8') return None finally: - # Free the memory using the signer's own Free function to ensure - # the same C runtime that allocated the memory also frees it. - # This is critical on Windows where different CRTs have separate heaps. - __signer.Free(ptr) + free_pointer(ptr) def __populate_shared_library_functions(signer): @@ -376,7 +404,7 @@ def __decode_tx_info(result: SignedTxResponse) -> Union[Tuple[str, str, str, Non err_str = decode_and_free(result.err) tx_info_str = decode_and_free(result.txInfo) tx_hash_str = decode_and_free(result.txHash) - decode_and_free(result.messageToSign) + free_pointer(result.messageToSign) if err_str: return None, None, None, err_str diff --git a/test/test_signer_client.py b/test/test_signer_client.py new file mode 100644 index 0000000..86321b0 --- /dev/null +++ b/test/test_signer_client.py @@ -0,0 +1,64 @@ +import ctypes +import unittest +from unittest import mock + +from lighter import signer_client + + +class TestSignerMemoryManagement(unittest.TestCase): + def test_decode_and_free_releases_pointer_once(self): + value = ctypes.create_string_buffer(b"lighter") + pointer = ctypes.addressof(value) + native_free = mock.Mock() + + with mock.patch.object(signer_client, "__native_free", native_free): + self.assertEqual(signer_client.decode_and_free(pointer), "lighter") + + native_free.assert_called_once_with(pointer) + + def test_decode_and_free_releases_pointer_when_decoding_fails(self): + value = ctypes.create_string_buffer(b"\xff") + pointer = ctypes.addressof(value) + native_free = mock.Mock() + + with mock.patch.object(signer_client, "__native_free", native_free): + with self.assertRaises(UnicodeDecodeError): + signer_client.decode_and_free(pointer) + + native_free.assert_called_once_with(pointer) + + def test_free_pointer_ignores_null(self): + native_free = mock.Mock() + + with mock.patch.object(signer_client, "__native_free", native_free): + signer_client.free_pointer(None) + + native_free.assert_not_called() + + def test_windows_uses_signer_allocator(self): + signer = mock.Mock() + with mock.patch.object(signer_client, "__native_free", None): + with mock.patch.object(signer_client.os, "name", "nt"): + with mock.patch.object( + signer_client, "get_signer", return_value=signer): + native_free = getattr( + signer_client, "__get_native_free")() + + self.assertIs(native_free, signer.Free) + + def test_posix_falls_back_when_process_allocator_is_unavailable(self): + signer = mock.Mock() + with mock.patch.object(signer_client, "__native_free", None): + with mock.patch.object(signer_client.os, "name", "posix"): + with mock.patch.object( + signer_client.ctypes, "CDLL", side_effect=OSError): + with mock.patch.object( + signer_client, "get_signer", return_value=signer): + native_free = getattr( + signer_client, "__get_native_free")() + + self.assertIs(native_free, signer.Free) + + +if __name__ == "__main__": + unittest.main() From 169fbfdf0c0919dedfce63efa4bf52ae6c6c5f24 Mon Sep 17 00:00:00 2001 From: Josu San Martin <5554649+josusanmartin@users.noreply.github.com> Date: Thu, 6 Aug 2026 10:38:54 +0000 Subject: [PATCH 2/3] Add low-overhead native batch signing --- lighter/signer_client.py | 219 ++++++++++++++++++++++++++++++++++++- test/test_signer_client.py | 208 +++++++++++++++++++++++++++++++++++ 2 files changed, 423 insertions(+), 4 deletions(-) diff --git a/lighter/signer_client.py b/lighter/signer_client.py index fab341d..4dbfe7c 100644 --- a/lighter/signer_client.py +++ b/lighter/signer_client.py @@ -6,6 +6,8 @@ import platform import logging import os +import struct +import threading import time from typing import Dict, List, Optional, Union, Tuple, Any @@ -57,7 +59,23 @@ class SignedTxResponse(ctypes.Structure): ] +class SignedTxBatchResponse(ctypes.Structure): + _fields_ = [ + ('data', ctypes.c_void_p), + ('length', ctypes.c_int64), + ('err', ctypes.c_void_p), + ] + + +_SIGNED_TX_BATCH_PREFIX = struct.Struct(' Optional[str]: free_pointer(ptr) +def decode_signed_tx_batch(data: bytes): + if len(data) < _SIGNED_TX_BATCH_PREFIX.size: + raise ValueError("packed signer response is missing its count") + + count, = _SIGNED_TX_BATCH_PREFIX.unpack_from(data) + if count > _MAX_CREATE_ORDER_BATCH: + raise ValueError( + "packed signer response count exceeds " + f"{_MAX_CREATE_ORDER_BATCH}" + ) + + offset = _SIGNED_TX_BATCH_PREFIX.size + decoded = [] + for _ in range(count): + if offset + _SIGNED_TX_BATCH_HEADER.size > len(data): + raise ValueError("packed signer response has a truncated header") + tx_type, tx_info_length, tx_hash_length, error_length = ( + _SIGNED_TX_BATCH_HEADER.unpack_from(data, offset) + ) + offset += _SIGNED_TX_BATCH_HEADER.size + payload_length = tx_info_length + tx_hash_length + error_length + if payload_length > len(data) - offset: + raise ValueError("packed signer response has a truncated payload") + + tx_info_end = offset + tx_info_length + tx_hash_end = tx_info_end + tx_hash_length + error_end = tx_hash_end + error_length + tx_info = data[offset:tx_info_end].decode('utf-8') or None + tx_hash = data[tx_info_end:tx_hash_end].decode('utf-8') or None + error = data[tx_hash_end:error_end].decode('utf-8') or None + offset = error_end + if error: + decoded.append((None, None, None, error)) + else: + decoded.append((tx_type, tx_info, tx_hash, None)) + + if offset != len(data): + raise ValueError("packed signer response has trailing data") + return decoded + + +def _copy_and_free_signed_tx_batch(response: SignedTxBatchResponse): + try: + error = ( + ctypes.string_at(response.err).decode('utf-8') + if response.err else None + ) + packed = ( + ctypes.string_at(response.data, response.length) + if response.data else b'' + ) + finally: + free_pointer(response.data) + free_pointer(response.err) + return packed, error + + +def _enable_stack_bound_cache(signer) -> bool: + """Enable the optional private-Go-hook callback optimization.""" + stack_bound_cache = getattr(signer, "FastEnableStackBoundCache", None) + if stack_bound_cache is None: + return False + stack_bound_cache.argtypes = [] + stack_bound_cache.restype = ctypes.c_int + return bool(stack_bound_cache()) + + def __populate_shared_library_functions(signer): + if not _enable_stack_bound_cache(signer): + logging.getLogger(__name__).debug( + "native signer stack-bound cache is unavailable") + signer.GenerateAPIKey.argtypes = [] signer.GenerateAPIKey.restype = ApiKeyResponse @@ -148,6 +237,19 @@ def __populate_shared_library_functions(signer): ctypes.c_int, ctypes.c_int, ctypes.c_longlong, ctypes.c_longlong, ctypes.c_int, ctypes.c_int, ctypes.c_uint8, ctypes.c_uint8, ctypes.c_uint8, ctypes.c_longlong, ctypes.c_int, ctypes.c_longlong] signer.SignCreateOrder.restype = SignedTxResponse + if hasattr(signer, "SignCreateOrdersBatch"): + signer.SignCreateOrdersBatch.argtypes = [ + ctypes.POINTER(CreateOrderTxReq), ctypes.c_int, + ctypes.c_longlong, ctypes.c_int, ctypes.c_int, + ctypes.c_uint8, ctypes.c_uint8, ctypes.c_uint8, + ctypes.c_longlong, ctypes.c_int, ctypes.c_longlong, + ] + signer.SignCreateOrdersBatch.restype = SignedTxBatchResponse + + if hasattr(signer, "PrepareSignerNonces"): + signer.PrepareSignerNonces.argtypes = [ctypes.c_int] + signer.PrepareSignerNonces.restype = ctypes.c_void_p + signer.SignCreateGroupedOrders.argtypes = [ctypes.c_uint8, ctypes.POINTER(CreateOrderTxReq), ctypes.c_int, ctypes.c_longlong, ctypes.c_int, ctypes.c_int, ctypes.c_uint8, ctypes.c_uint8, ctypes.c_uint8, ctypes.c_longlong, ctypes.c_int, ctypes.c_longlong] signer.SignCreateGroupedOrders.restype = SignedTxResponse @@ -213,14 +315,18 @@ def __populate_shared_library_functions(signer): def get_signer(): - # check if singleton exists already global __signer if __signer is not None: return __signer - # create shared library & populate methods - __signer = __get_shared_library() - __populate_shared_library_functions(__signer) + # Publish only a fully configured library. This prevents another thread + # from calling a function while ctypes signatures and the runtime hook are + # still being initialized. + with __signer_lock: + if __signer is None: + signer = __get_shared_library() + __populate_shared_library_functions(signer) + __signer = signer return __signer @@ -547,6 +653,111 @@ def sign_create_order( self.account_index, )) + def prepare_signer_nonces(self, count: int) -> None: + """Precompute one-use Schnorr commitments for a later signing burst. + + Prepared nonces are held in a process-global in-memory pool until any + signer consumes them. They must not be persisted, serialized, or + shared across a process fork. + """ + if count < 0 or count > _MAX_PREPARED_NONCES: + raise ValueError( + "prepared nonce count must be between 0 and " + f"{_MAX_PREPARED_NONCES}" + ) + prepare = getattr(self.signer, "PrepareSignerNonces", None) + if prepare is None: + raise RuntimeError( + "the loaded signer does not support prepared nonces" + ) + error = decode_and_free(prepare(count)) + if error: + raise RuntimeError(error) + + def sign_create_orders_batch( + self, + orders: List[CreateOrderTxReq], + first_nonce: int, + *, + integrator_account_index: int = 0, + integrator_taker_fee: int = 0, + integrator_maker_fee: int = 0, + self_trade_behavior_mode: int = 0, + self_trade_equality_mode: int = 0, + skip_nonce: int = SKIP_NONCE_OFF, + api_key_index: int = DEFAULT_API_KEY_INDEX + ): + """Sign independent create-order transactions with consecutive + nonces. + """ + if not orders: + return [] + if len(orders) > _MAX_CREATE_ORDER_BATCH: + raise ValueError( + "create-order batch cannot exceed " + f"{_MAX_CREATE_ORDER_BATCH} orders" + ) + if first_nonce < 0: + raise ValueError( + "batch signing requires an explicit non-negative first nonce; " + "it does not perform network I/O" + ) + if first_nonce > _MAX_SIGNER_NONCE - (len(orders) - 1): + raise ValueError("create-order batch nonce range overflows int64") + + batch_signer = getattr(self.signer, "SignCreateOrdersBatch", None) + if batch_signer is None: + return [ + self.sign_create_order( + order.MarketIndex, + order.ClientOrderIndex, + order.BaseAmount, + order.Price, + order.IsAsk, + order.Type, + order.TimeInForce, + order.ReduceOnly, + order.TriggerPrice, + order.OrderExpiry, + integrator_account_index=integrator_account_index, + integrator_taker_fee=integrator_taker_fee, + integrator_maker_fee=integrator_maker_fee, + self_trade_behavior_mode=self_trade_behavior_mode, + self_trade_equality_mode=self_trade_equality_mode, + skip_nonce=skip_nonce, + nonce=first_nonce + index, + api_key_index=api_key_index, + ) + for index, order in enumerate(orders) + ] + + orders_type = CreateOrderTxReq * len(orders) + orders_array = orders_type(*orders) + response = batch_signer( + orders_array, + len(orders), + integrator_account_index, + integrator_taker_fee, + integrator_maker_fee, + self_trade_behavior_mode, + self_trade_equality_mode, + skip_nonce, + first_nonce, + api_key_index, + self.account_index, + ) + packed, error = _copy_and_free_signed_tx_batch(response) + + if error: + raise RuntimeError(error) + decoded = decode_signed_tx_batch(packed) + if len(decoded) != len(orders): + raise RuntimeError( + "native signer returned " + f"{len(decoded)} results for {len(orders)} orders" + ) + return decoded + def sign_create_grouped_orders( self, grouping_type: int, diff --git a/test/test_signer_client.py b/test/test_signer_client.py index 86321b0..1b56465 100644 --- a/test/test_signer_client.py +++ b/test/test_signer_client.py @@ -1,11 +1,35 @@ import ctypes +import concurrent.futures +import struct +import time import unittest from unittest import mock from lighter import signer_client +def pack_batch_record(tx_type, tx_info=b'', tx_hash=b'', error=b''): + return ( + struct.pack(' Date: Thu, 6 Aug 2026 10:39:04 +0000 Subject: [PATCH 3/3] Fix create-order market index ABI width --- lighter/signer_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lighter/signer_client.py b/lighter/signer_client.py index 4dbfe7c..fda7fd6 100644 --- a/lighter/signer_client.py +++ b/lighter/signer_client.py @@ -32,7 +32,7 @@ class ApiKeyResponse(ctypes.Structure): class CreateOrderTxReq(ctypes.Structure): _fields_ = [ - ("MarketIndex", ctypes.c_int), + ("MarketIndex", ctypes.c_int16), ("ClientOrderIndex", ctypes.c_longlong), ("BaseAmount", ctypes.c_longlong), ("Price", ctypes.c_uint32),