From 20de90f7d8d7f862fc300bcc8306f8766f9378bd Mon Sep 17 00:00:00 2001 From: HEY Research Lab <327801004+hey-research-lab@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:48:57 +0800 Subject: [PATCH] Scale withdraw, transfer and margin amounts without float truncation int(2.01 * 1e6) is 2009999, so withdraw(USDC, 2.01) signed 2.009999 USDC. Scale through the amount's decimal representation instead. --- lighter/signer_client.py | 18 +++++++++++++----- test/test_amount_scaling.py | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 5 deletions(-) create mode 100644 test/test_amount_scaling.py diff --git a/lighter/signer_client.py b/lighter/signer_client.py index eeb1c8f..f682ab9 100644 --- a/lighter/signer_client.py +++ b/lighter/signer_client.py @@ -1,4 +1,5 @@ import ctypes +from decimal import Decimal from fractions import Fraction from functools import wraps import inspect @@ -264,6 +265,13 @@ def trim_exc(exception_body: str): return exception_body.strip().split("\n")[-1] +def to_ticks(amount, scale) -> int: + # Scale through the amount's decimal representation: int(2.01 * 1e6) is 2009999, + # because 2.01 * 1e6 == 2009999.9999999998 in binary floating point. + # Precision finer than one tick is still truncated, as before. + return int(Decimal(str(amount)) * Decimal(scale)) + + def process_api_key_and_nonce(func): @wraps(func) async def wrapper(self, *args, **kwargs): @@ -938,7 +946,7 @@ async def create_market_order_quote_amount( nonce: int = DEFAULT_NONCE, api_key_index: int = DEFAULT_API_KEY_INDEX, ): - quote_amount = int(quote_amount * 1e6) + quote_amount = to_ticks(quote_amount, self.USDC_TICKER_SCALE) ob_orders = await self.order_api.order_book_orders(market_index, 100) if ideal_price is None: logging.debug( @@ -1235,7 +1243,7 @@ async def create_sl_limit_order( @process_api_key_and_nonce async def withdraw(self, asset_id: int, route_type: int, amount: float, skip_nonce : int = SKIP_NONCE_OFF, nonce: int = DEFAULT_NONCE, api_key_index: int = DEFAULT_API_KEY_INDEX) -> Union[Tuple[Withdraw, RespSendTx, None], Tuple[None, None, str]]: if asset_id in self.ASSET_TO_TICKER_SCALE: - amount = int(amount * self.ASSET_TO_TICKER_SCALE[asset_id]) + amount = to_ticks(amount, self.ASSET_TO_TICKER_SCALE[asset_id]) else: raise ValueError(f"Unsupported asset id: {asset_id}") @@ -1350,7 +1358,7 @@ async def approve_integrator( @process_api_key_and_nonce async def transfer(self, eth_private_key: str, to_account_index: int, asset_id: int, route_from: int, route_to: int, amount: float, fee: int, memo: str, skip_nonce : int = SKIP_NONCE_OFF, nonce: int = DEFAULT_NONCE, api_key_index: int = DEFAULT_API_KEY_INDEX): if asset_id in self.ASSET_TO_TICKER_SCALE: - amount = int(amount * self.ASSET_TO_TICKER_SCALE[asset_id]) + amount = to_ticks(amount, self.ASSET_TO_TICKER_SCALE[asset_id]) else: raise ValueError(f"Unsupported asset id: {asset_id}") @@ -1366,7 +1374,7 @@ async def transfer(self, eth_private_key: str, to_account_index: int, asset_id: @process_api_key_and_nonce async def transfer_same_master_account(self, to_account_index: int, asset_id: int, route_from: int, route_to: int, amount: float, fee: int, memo: str, skip_nonce : int = SKIP_NONCE_OFF, nonce: int = DEFAULT_NONCE, api_key_index: int = DEFAULT_API_KEY_INDEX): if asset_id in self.ASSET_TO_TICKER_SCALE: - amount = int(amount * self.ASSET_TO_TICKER_SCALE[asset_id]) + amount = to_ticks(amount, self.ASSET_TO_TICKER_SCALE[asset_id]) else: raise ValueError(f"Unsupported asset id: {asset_id}") @@ -1468,7 +1476,7 @@ async def update_leverage(self, market_index, margin_mode, leverage, skip_nonce @process_api_key_and_nonce async def update_margin(self, market_index: int, usdc_amount: float, direction: int, skip_nonce : int = SKIP_NONCE_OFF, nonce: int = DEFAULT_NONCE, api_key_index: int = DEFAULT_API_KEY_INDEX): - usdc_amount = int(usdc_amount * self.USDC_TICKER_SCALE) + usdc_amount = to_ticks(usdc_amount, self.USDC_TICKER_SCALE) tx_type, tx_info, tx_hash, error = self.sign_update_margin(market_index, usdc_amount, direction, skip_nonce, nonce, api_key_index) if error is not None: diff --git a/test/test_amount_scaling.py b/test/test_amount_scaling.py new file mode 100644 index 0000000..5c749d6 --- /dev/null +++ b/test/test_amount_scaling.py @@ -0,0 +1,37 @@ +import asyncio +import types +import unittest + +from lighter.signer_client import SignerClient + + +def _capture_withdraw_amount(asset_id, amount): + captured = {} + + def sign_withdraw(asset_index, route_type, amount, skip_nonce, nonce, api_key_index): + captured["amount"] = amount + return None, None, None, "stop" + + fake = types.SimpleNamespace( + ASSET_TO_TICKER_SCALE=SignerClient.ASSET_TO_TICKER_SCALE, + sign_withdraw=sign_withdraw, + ) + asyncio.run(SignerClient.withdraw(fake, asset_id, SignerClient.ROUTE_PERP, amount, nonce=1, api_key_index=0)) + return captured["amount"] + + +class TestAmountScaling(unittest.TestCase): + def test_withdraw_usdc_amount_is_exact(self): + # 2.01 * 1e6 == 2009999.9999999998 in binary floating point + self.assertEqual(_capture_withdraw_amount(SignerClient.ASSET_ID_USDC, 2.01), 2_010_000) + + def test_withdraw_eth_amount_is_exact(self): + # 0.29 * 1e8 == 28999999.999999996 in binary floating point + self.assertEqual(_capture_withdraw_amount(SignerClient.ASSET_ID_ETH, 0.29), 29_000_000) + + def test_sub_tick_precision_is_still_truncated(self): + self.assertEqual(_capture_withdraw_amount(SignerClient.ASSET_ID_USDC, 1.0000009), 1_000_000) + + +if __name__ == "__main__": + unittest.main()