diff --git a/.cursor/rules/precision-decimal-arithmetic.mdc b/.cursor/rules/precision-decimal-arithmetic.mdc new file mode 100644 index 0000000..bbf18e4 --- /dev/null +++ b/.cursor/rules/precision-decimal-arithmetic.mdc @@ -0,0 +1,30 @@ +--- +description: Use Decimal-backed Utils.unit_conversion for all wei conversions; never float * 10**N +globs: "src/dexalot_sdk/**/*.py" +alwaysApply: true +--- + +# Precision-safe arithmetic + +Never multiply user-supplied amounts by `10**N` using Python's `*` operator on floats. The binary-float multiplication silently truncates exact decimals — e.g. `int(2933.0 * 10**18) = 2932999999999999737856` instead of `2933000000000000000000`, and the contract rejects the order with `T-TMDQ-01`. + +Always route through `Utils.unit_conversion` (Decimal-backed) for amount → wei or wei → amount conversion: + +```python +# YES — Decimal-exact, handles int / float / Decimal / numeric-str inputs +amount_wei = Utils.unit_conversion(amount, decimals, to_base=True) +amount_human = Utils.unit_conversion(balance_wei, decimals, to_base=False) + +# NO — float-multiplication precision loss +amount_wei = int(amount * (10 ** decimals)) # ❌ +amount_wei = int(amount * 1e18) # ❌ +amount_human = balance_wei / (10 ** decimals) # ❌ float division +``` + +In CLOB-specific code (`src/dexalot_sdk/core/clob.py`) prefer `CLOBClient._to_wei`, which wraps `Utils.unit_conversion` and is the single conversion entry point used by all four write paths (`add_order`, `_build_order_tuple`, `replace_order`, `_process_replacement`). + +# Display-decimal precision is REJECT-with-tolerance, not silent rounding + +Display decimals (`base_display_decimals` / `quote_display_decimals` on `pair_data`) are enforced by `_normalize_order_amounts`, which REJECTS inputs whose precision exceeds the bound with a `1e-10` tolerance for binary-float noise. Do not add silent-rounding shortcuts (`round(amount, display_decimals)`) — for a trading SDK, silent rounding causes silent slippage. + +All four CLOB write paths must route through `_normalize_order_amounts` so the precision gate is enforced consistently. Bypassing it re-introduces the kind of drift that caused the original missing-rounding bug in `replace_order`. diff --git a/CLAUDE.md b/CLAUDE.md index 581d4ca..ea00260 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -96,6 +96,11 @@ Unit tests in `tests/unit/` have no external dependencies. Integration tests in - **RPC security enforcement**: `_reject_insecure_rpc_urls()` in `base.py` rejects plain `http://` RPC endpoints at provider setup time unless `config.allow_insecure_rpc=True`. Fail-fast before any traffic is sent over plaintext. - **Public market-data endpoints live under `/api/`, not `/privapi/`**: most SDK calls hit `/privapi/...`, but `get_candles` and `get_market_snapshot` (and therefore `get_24h_stats`) hit `/api/trading/candle-chunk` and `/api/stats/market-snapshot` because those routes are only mounted on the public `/api/` tree on the backend. Same host, different prefix — see `ENDPOINT_TRADING_CANDLE_CHUNK` and `ENDPOINT_STATS_MARKET_SNAPSHOT` in `constants.py`. - **`get_chain_token_balances` cache key is order-insensitive**: the public method coerces `tokens: list[str]` into a sorted unique tuple before delegating to the cached internal, so `["AVAX", "USDC"]` and `["USDC", "AVAX"]` share a cache slot. Lists are unhashable; the tuple coercion is also what lets the call participate in `_BALANCE_CACHE` at all. +- **Amount → wei conversion always routes through `Utils.unit_conversion` (Decimal-backed)**: never multiply floats by `10**N` in write paths. `int(2933.0 * 10**18)` truncates to `2932999999999999737856` and the contract rejects the order with `T-TMDQ-01`. The helper internally does `int(Decimal(str(value)) * Decimal(10)**decimals)` and is precision-exact for `int`, `float`, `Decimal`, and numeric `str` inputs. All four CLOB write paths (`add_order`, `_build_order_tuple`, `replace_order`, `_process_replacement`) and all seven TRANSFER write paths share this idiom — see `CLOBClient._to_wei`. +- **Display-decimals are enforced via REJECT-with-tolerance, not silent rounding**: `_normalize_order_amounts` rejects inputs whose precision exceeds the pair's `base_display_decimals` / `quote_display_decimals`, with a `1e-10` tolerance band that absorbs binary-float-representation noise (e.g. `0.1 + 0.2`). Silent rounding would be dangerous in a trading SDK — a stop-loss at `99.99` quietly becoming `99.9` is silent slippage. All four CLOB write paths route through this single helper to prevent drift. +- **Pairs missing display decimals are dropped at ingest**: `_store_clob_pairs` excludes any pair whose API record lacks `base_display_decimals` or `quote_display_decimals` and logs a WARNING. Downstream callers see "pair not found." Display decimals are contractual — defaulting them would mask the contract's `T-TMDQ-01` rejection downstream. +- **`min_trade_amount` / `max_trade_amount` are enforced client-side**: quote-token-denominated bounds checked by `_check_trade_amount_bounds` inside `_normalize_order_amounts` so all four CLOB write paths gain the check. A bound of `0` means "no bound" (some pairs legitimately omit). Stored as `Decimal` so notional comparison is exact. +- **`validate_positive_number` accepts int/float/Decimal/numeric-string**: renamed from `validate_positive_float` (alias kept for one release). Callers wanting precision-exact arithmetic should pass `Decimal('2933')` or `'2933.5'` directly; floats are still accepted and routed through `Decimal(str(value))` internally. --- diff --git a/README.md b/README.md index 94d9cf4..7e9c53e 100644 --- a/README.md +++ b/README.md @@ -1228,3 +1228,56 @@ if not result.success: | "Invalid order_id: must be hex string or bytes32" | Invalid order ID | Use valid hex string | Validation happens before any network calls, so invalid inputs fail fast with clear error messages. + +## Amount Precision and Display Decimals + +Trading and transfer methods accept human-readable amounts and convert them to integer wei (atomic units) internally using `Decimal` arithmetic — never `int(amount * 10**N)`, which silently truncates exact decimals (e.g. `2933.0 * 10**18` becomes `2932999999999999737856` instead of `2933000000000000000000` and the contract rejects with `T-TMDQ-01`). + +### Accepted input types + +`amount` and `price` parameters accept any of: + +- `int` (`amount=1`) +- `float` (`amount=2933.0`) — converted via `Decimal(str(value))` so the user's intended decimal representation is preserved (`str(0.1) == "0.1"`) +- `Decimal` (`amount=Decimal("2933")`) — recommended for precision-sensitive callers (e.g. market-making bots) +- numeric `str` (`amount="2933.5"`) — parsed via `Decimal` + +```python +from decimal import Decimal + +# All four produce identical wei. +await client.add_order("AVAX/USDC", "BUY", 2933.0, 10.0) +await client.add_order("AVAX/USDC", "BUY", 2933, 10.0) +await client.add_order("AVAX/USDC", "BUY", Decimal("2933"), 10.0) +await client.add_order("AVAX/USDC", "BUY", "2933", 10.0) +``` + +### Display-decimal enforcement + +Every Dexalot pair carries `basedisplaydecimals` and `quotedisplaydecimals` that bound how many fractional digits `amount` and `price` may have. The SDK enforces this client-side and **rejects** inputs that exceed the bound rather than silently rounding — silent rounding would slip orders (e.g. a stop at `99.99` quietly becoming `99.9`). + +```python +# Pair AVAX/USDC: quote_display_decimals=4, base_display_decimals=1 +res = await client.add_order("AVAX/USDC", "BUY", 1.99, 10.0) +# Result.fail: "Invalid amount: 1.99 has more than 1 decimals; pair allows 1. +# Round before passing (e.g. Decimal('2.0'))." +``` + +A `1e-10` tolerance band absorbs binary-float-representation noise so `0.1 + 0.2` (which evaluates to `0.30000000000000004`) is accepted at 4 display decimals and snapped to exactly `0.3000`. + +### Min/max trade-amount bounds + +Each pair also carries `mintrade_amnt` and `maxtrade_amnt` bounds (quote-token notional, i.e., `price * amount`). The SDK checks these client-side and returns `Result.fail` for orders outside the range: + +```python +# Pair AVAX/USDC: min_trade_amount=0.3, max_trade_amount=4000 (AVAX notional) +res = await client.add_order("AVAX/USDC", "BUY", 0.01, 1.0) +# Result.fail: "Trade notional 0.01 below min_trade_amount 0.3 (quote-token) +# for pair AVAX/USDC." +``` + +A bound of `0` means "no bound" (some pairs legitimately omit a cap). + +### Pairs missing display decimals + +If the pair metadata API ever omits `basedisplaydecimals` or `quotedisplaydecimals` for a pair, the SDK drops that pair from `client.pairs` and logs a `WARNING`. Downstream order-placement calls will see `Pair X not found` — the correct fail-fast behavior. diff --git a/VERSION b/VERSION index 83ac1cc..c5f3c9c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.5.14 +0.5.15 diff --git a/docs/python-sdk-remediation-plan.md b/docs/python-sdk-remediation-plan.md index ea34d9e..19e414c 100644 --- a/docs/python-sdk-remediation-plan.md +++ b/docs/python-sdk-remediation-plan.md @@ -817,6 +817,158 @@ pins. A major-version bump in any of them can silently break the SDK. --- +## Order Precision & Display-Decimals Hardening + +### O-1: Float-multiplication precision loss in CLOB write paths + +**Status:** ✅ Resolved + +**Finding:** +`add_order`, `_build_order_tuple` (`add_limit_order_list`), `replace_order`, and +`_process_replacement` (`cancel_add_list`) all encoded prices and quantities as +`int(value * (10**decimals))`. Floating-point multiplication silently truncates +exact decimals — e.g. `int(2933.0 * 10**18) = 2932999999999999737856` instead of +`2933000000000000000000`. The contract rejects these with `T-TMDQ-01` +("too many decimals in quantity") and the order fails on-chain. + +**Affected files:** +- [src/dexalot_sdk/core/clob.py](../src/dexalot_sdk/core/clob.py) — all four + CLOB write paths + +**Resolution:** +Introduced `CLOBClient._to_wei` (Decimal-backed, wraps `Utils.unit_conversion`) +and routed all four sites through it. The reporter's 2933.0 case and the +parametrized `(1840, 0.1, USDC-6-decimal, Decimal, str)` regression cases all +encode exactly. Commits: C1–C5 of PR. + +--- + +### O-2: Display-decimal precision gate (REJECT-with-tolerance) + +**Status:** ✅ Resolved + +**Finding:** +Three of the four CLOB write paths called `round(value, display_decimals)` +before encoding; `replace_order` skipped this step entirely, letting the +contract reject overly-precise inputs. Even the three paths that called +`round()` did so on a float, which still produced binary-float noise +downstream. Worse, silent rounding is dangerous in a trading SDK: a stop-loss +at `99.99` quietly becoming `99.9` is silent slippage. + +**Resolution:** +Consolidated all four write paths through `_normalize_order_amounts`. The +helper now REJECTS inputs whose precision exceeds the pair's display decimals, +with a `1e-10` tolerance band that absorbs binary-float-representation noise +(e.g. `0.1 + 0.2 = 0.3 + 4e-17` is accepted; `0.30001` is rejected). Returns +`Result.fail(...)` naming the offending parameter so callers can round +explicitly. Adds a drift-guard test asserting all four paths produce +identical wei for the same input. Commit: C6 of PR. + +--- + +### O-3: Pairs without display decimals silently defaulted to 18 + +**Status:** ✅ Resolved + +**Finding:** +`_store_clob_pairs` did `.get('base_display_decimals', 18)` / +`.get('quote_display_decimals', 18)` when the API record omitted these +fields. That default effectively disabled the precision gate for those +pairs and reproduced the original contract-rejection scenario. + +**Resolution:** +`_store_clob_pairs` now drops any pair missing either display-decimal field +and logs a WARNING. Downstream callers will see "pair not found" — the +correct fail-fast behavior. Commit: C7 of PR. + +--- + +### O-4: Validators rejected Decimal and numeric-string inputs + +**Status:** ✅ Resolved + +**Finding:** +`validate_positive_float` only accepted `int` and `float`, forcing callers +wanting precision-exact arithmetic to pre-convert to float and risk the +binary-float precision bugs. + +**Resolution:** +Renamed to `validate_positive_number` and extended to accept `int`, `float`, +`Decimal`, and numeric `str`. `bool` is explicitly rejected (would have +leaked through as `int=0/1` otherwise). `validate_positive_float` remains as +a backwards-compatible alias for one release. Commit: C8 of PR. + +--- + +### O-5: `min_trade_amount` / `max_trade_amount` not enforced client-side + +**Status:** ✅ Resolved + +**Finding:** +Each Dexalot trading pair carries `min_trade_amount` and `max_trade_amount` +bounds (quote-token notional). The contract enforces these but the SDK +ingested the values and never checked them — wasting a gas-paid transaction +on a bound-violation that could be detected locally. + +**Resolution:** +Added `_check_trade_amount_bounds` and folded it into +`_normalize_order_amounts` so all four CLOB write paths gain the check. +Computes `notional = price * amount` in Decimal and rejects when outside +`[min, max]`. A bound of `0` means "no bound" (some pairs legitimately +omit). Bounds stored as `Decimal` (previously `float`) for exact comparison. +Commit: C9 of PR. + +--- + +### O-6: Float-multiplication precision loss in TRANSFER write paths + +**Status:** ✅ Resolved + +**Finding:** +`transfer_portfolio`, `deposit`, `withdraw`, `get_deposit_bridge_fee`, and +`transfer_token` had the same `int(amount * (10**decimals))` precision bug as +the CLOB paths. + +**Resolution:** +Replaced all five sites with `Utils.unit_conversion`. Parametrized regression +tests cover the 2933.0 case, USDC-6-decimal token, `Decimal`/`str` inputs. +Commit: C10 of PR. + +--- + +### O-7: Balance display conversion used float division + +**Status:** ✅ Resolved + +**Finding:** +`get_portfolio_balance` and `_get_all_portfolio_balances_cached` returned +balance dicts built from `balance_wei / (10**decimals)` — a Python float +division that loses precision for balances above ~2^53 wei. + +**Resolution:** +Routed through `Utils.unit_conversion(..., to_base=False)`. Public return +type unchanged. Regression test asserts a 25-digit wei value round-trips +exactly. Commit: C11 of PR. + +--- + +### O-8: `add_gas` / `remove_gas` used `w3.to_wei` instead of SDK helper + +**Status:** 🔶 Mitigated (consistency only) + +**Finding:** +`add_gas` and `remove_gas` used `w3.to_wei(amount, "ether")`. web3.py's +`to_wei` is itself Decimal-backed (calls `Decimal(str(value))` internally) so +no behavior bug — but the SDK's other six transfer write paths use +`Utils.unit_conversion`. Inconsistent idioms invite future drift to less +safe patterns. + +**Resolution:** +Standardized both sites on `Utils.unit_conversion(amount, 18, to_base=True)`. +No behavior change; parametrized parity test. Commit: C12 of PR. + +--- + ## Suggested Implementation Order This ordering balances risk reduction, effort, and interdependency: diff --git a/docs/python-sdk-user-guide.md b/docs/python-sdk-user-guide.md index a7a6495..a90052e 100644 --- a/docs/python-sdk-user-guide.md +++ b/docs/python-sdk-user-guide.md @@ -176,6 +176,8 @@ result = await client.add_order( ) ``` +> **Precision note**: `amount` and `price` accept `int`, `float`, `Decimal`, and numeric `str`. The SDK converts to wei via `Decimal`-backed arithmetic — never float-multiplication — so exact values like `2933.0` round-trip cleanly. Precision exceeding the pair's `base_display_decimals` / `quote_display_decimals` is **rejected** rather than silently rounded; pass `Decimal('2.0')` or round explicitly. See the "Amount Precision and Display Decimals" section of [README.md](../README.md) for the full contract. + ### Cancel an order ```python diff --git a/pyproject.toml b/pyproject.toml index 51906a2..5405b8e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ license = "MIT" license-files = [ "LICENSE.txt" ] -version = "0.5.14" +version = "0.5.15" description = "Dexalot Python SDK - Core library for Dexalot interaction" readme = "README.md" requires-python = ">=3.12,<3.15" @@ -34,6 +34,11 @@ dependencies = [ "eth-account>=0.11,<1", "websockets>=13.0,<15", "cryptography>=46,<48", + # Pin transitive deps to versions free of known CVEs (pip-audit gate). + # - idna>=3.15 closes CVE-2026-45409 + # - urllib3>=2.7.0 closes PYSEC-2026-141 and PYSEC-2026-142 + "idna>=3.15", + "urllib3>=2.7.0", ] [project.urls] diff --git a/src/dexalot_sdk/__init__.py b/src/dexalot_sdk/__init__.py index d4431a3..00fb9a2 100644 --- a/src/dexalot_sdk/__init__.py +++ b/src/dexalot_sdk/__init__.py @@ -12,7 +12,7 @@ secrets_vault_set, ) -__version__ = "0.5.14" +__version__ = "0.5.15" def get_version() -> str: diff --git a/src/dexalot_sdk/core/clob.py b/src/dexalot_sdk/core/clob.py index a82a94a..617030e 100644 --- a/src/dexalot_sdk/core/clob.py +++ b/src/dexalot_sdk/core/clob.py @@ -1,6 +1,8 @@ import asyncio +import logging import time from collections.abc import Callable +from decimal import ROUND_DOWN, ROUND_HALF_EVEN, Decimal from typing import Any, SupportsInt, cast from ..constants import ( @@ -25,6 +27,8 @@ from ..utils.websocket_manager import WebSocketManager from .base import _ORDERBOOK_CACHE, _SEMI_STATIC_CACHE, DexalotBaseClient +_logger = logging.getLogger("dexalot_sdk") + _CANDLE_INTERVALS: dict[str, tuple[int, str]] = { "1m": (1, "minute"), "5m": (5, "minute"), @@ -166,23 +170,43 @@ async def get_clob_pairs(self) -> Result[list[dict[str, Any]]]: return Result.fail(error_msg) def _store_clob_pairs(self, transformed_data: list[dict[str, Any]]) -> list[dict[str, Any]]: - """Normalize pair metadata into both list and keyed lookup forms.""" + """Normalize pair metadata into both list and keyed lookup forms. + + Pairs whose API record omits ``base_display_decimals`` or + ``quote_display_decimals`` are dropped with a warning. Display + decimals are contractual — silently defaulting them would mask + contract rejections (T-TMDQ-01) downstream. + """ pair_map: dict[str, dict[str, Any]] = {} for item in transformed_data: if item.get("env") not in [ENV_PROD_MULTI_SUBNET, ENV_FUJI_MULTI_SUBNET]: continue pair_name = item["pair"] + base_disp = item.get("base_display_decimals") + quote_disp = item.get("quote_display_decimals") + if base_disp is None or quote_disp is None: + _logger.warning( + "Dropping pair %s: missing display decimals " + "(base_display_decimals=%r, quote_display_decimals=%r). " + "Pair will not be available for order placement.", + pair_name, + base_disp, + quote_disp, + ) + continue + # min/max trade amounts are quote-token notional bounds; store as + # Decimal so we can compare against Decimal price*amount exactly. pair_map[pair_name] = { "pair": pair_name, "base": item["base"], "quote": item["quote"], "base_decimals": item.get("base_decimals"), "quote_decimals": item.get("quote_decimals"), - "base_display_decimals": item.get("base_display_decimals", 18), - "quote_display_decimals": item.get("quote_display_decimals", 18), - "min_trade_amount": float(item.get("min_trade_amount", 0)), - "max_trade_amount": float(item.get("max_trade_amount", 0)), + "base_display_decimals": base_disp, + "quote_display_decimals": quote_disp, + "min_trade_amount": Decimal(str(item.get("min_trade_amount", 0))), + "max_trade_amount": Decimal(str(item.get("max_trade_amount", 0))), "tradePairId": Utils.to_bytes32(pair_name), } @@ -481,11 +505,13 @@ async def add_order( if validation_error is not None: return cast(Result[dict[Any, Any]], validation_error) - # Rounding to display decimals - if "quote_display_decimals" in pair_data and price: - price = round(price, pair_data["quote_display_decimals"]) - if "base_display_decimals" in pair_data: - amount = round(amount, pair_data["base_display_decimals"]) + # Validate precision against the pair's display decimals. + # Inputs with extra precision are rejected (no silent slippage). + norm_res = self._normalize_order_amounts(price, amount, pair_data) + if not norm_res.success: + return cast(Result[dict[Any, Any]], norm_res) + assert norm_res.data is not None + price, amount = norm_res.data # type: ignore[assignment] # Portfolio Balance Check required_token = pair_data["quote"] if side_enum == 0 else pair_data["base"] @@ -495,9 +521,9 @@ async def add_order( if balance_error is not None: return cast(Result[dict[Any, Any]], balance_error) - # Decimals - price_wei = int(price * (10 ** pair_data["quote_decimals"])) if price else 0 - qty_wei = int(amount * (10 ** pair_data["base_decimals"])) + # Decimals (Decimal-backed; never multiply floats by 10**N here) + price_wei = self._to_wei(price, pair_data["quote_decimals"]) if price else 0 + qty_wei = self._to_wei(amount, pair_data["base_decimals"]) # Use caller-provided client_order_id or generate a random one import secrets @@ -1349,24 +1375,161 @@ async def _check_order_balance(self, required_token, required_amount): ) return None - def _normalize_order_amounts(self, price, amount, pair_data): - """Normalize price and amount based on display decimals.""" + @staticmethod + def _to_decimal(value: Any) -> Decimal: + """Coerce a numeric value (int, float, str, Decimal) to Decimal exactly. + + Floats route through ``str(value)`` so the user's intended decimal + representation is preserved (``str(0.1) == "0.1"``). + """ + if isinstance(value, Decimal): + return value + return Decimal(str(value)) + + @staticmethod + def _quantize_to_display(value: Any, display_decimals: int) -> Decimal: + """Truncate ``value`` to ``display_decimals`` fractional digits (ROUND_DOWN). + + Truncation (never rounding up) ensures the SDK never submits an order + that exceeds the user's typed amount or notional. Used by callers that + have already validated precision; see :meth:`_check_display_precision` + for the precision gate used by the public order-placement paths. + """ + d = CLOBClient._to_decimal(value) + quantum = Decimal(10) ** -display_decimals + return d.quantize(quantum, rounding=ROUND_DOWN) + + # Tolerance for float-noise residuals when checking display-decimal + # precision. Genuine binary-float noise from operations like 0.1 + 0.2 + # produces residuals on the order of 1e-17; human-typed extra precision + # produces residuals >= 1e-6. 1e-10 sits in the gap with margin. + _DISPLAY_PRECISION_TOLERANCE = Decimal("1e-10") + + @staticmethod + def _check_display_precision( + value: Any, display_decimals: int, param_name: str + ) -> "Result[Decimal]": + """Validate that ``value`` fits in ``display_decimals`` fractional digits. + + Returns ``Result.ok(quantized)`` if the input is at display precision + (or differs from it only by float-representation noise — see + :attr:`_DISPLAY_PRECISION_TOLERANCE`). Returns ``Result.fail(msg)`` + when the input has genuinely more decimals than the pair allows; + callers must round explicitly rather than have the SDK silently + truncate and slip the order. + """ + d = CLOBClient._to_decimal(value) + if d == 0: + return Result.ok(d) + quantum = Decimal(10) ** -display_decimals + nearest = d.quantize(quantum, rounding=ROUND_HALF_EVEN) + if abs(d - nearest) > CLOBClient._DISPLAY_PRECISION_TOLERANCE: + return Result.fail( + f"Invalid {param_name}: {value} has more than {display_decimals} " + f"decimals; pair allows {display_decimals}. Round before passing " + f"(e.g. Decimal('{nearest}'))." + ) + return Result.ok(nearest) + + @staticmethod + def _check_trade_amount_bounds( + price: Decimal | None, amount: Decimal, pair_data: dict + ) -> "Result[None]": + """Enforce the pair's min/max trade-amount bounds (quote-token notional). + + Computes ``notional = price * amount`` and rejects orders outside + ``[min_trade_amount, max_trade_amount]``. A bound of ``0`` is treated + as "no bound" (some pairs omit the cap). + + Skipped entirely when ``price`` is ``None`` (market orders without + a price; the contract enforces its own protections for those). + """ + if price is None: + return Result.ok(None) + notional = price * amount + min_amt = pair_data.get("min_trade_amount", Decimal(0)) + max_amt = pair_data.get("max_trade_amount", Decimal(0)) + if min_amt > 0 and notional < min_amt: + return Result.fail( + f"Trade notional {notional} below min_trade_amount {min_amt} " + f"(quote-token) for pair {pair_data.get('pair')}." + ) + if max_amt > 0 and notional > max_amt: + return Result.fail( + f"Trade notional {notional} above max_trade_amount {max_amt} " + f"(quote-token) for pair {pair_data.get('pair')}." + ) + return Result.ok(None) + + def _normalize_order_amounts( + self, price: Any, amount: Any, pair_data: dict + ) -> "Result[tuple[Decimal | None, Decimal]]": + """Validate and quantize price/amount against the pair's display decimals + and min/max trade-amount bounds. + + Inputs whose precision exceeds the pair's display decimals are + rejected — the SDK does not silently round, because that would + silently slip orders (e.g. a stop at 99.99 quietly becoming 99.9). + Float-representation noise (residual <= 1e-10) is tolerated and + snapped to the nearest displayable value. + + After display-decimal validation, the resulting notional + (``price * amount``) is checked against the pair's + ``min_trade_amount`` / ``max_trade_amount`` bounds (quote-token + denominated) so the SDK fails fast instead of waiting for the + contract to reject. + + Returns ``Result.ok((price, amount))`` as Decimals, or + ``Result.fail(...)`` describing which check failed. + """ if "quote_display_decimals" in pair_data and price: - price = round(price, pair_data["quote_display_decimals"]) + price_res = self._check_display_precision( + price, pair_data["quote_display_decimals"], "price" + ) + if not price_res.success: + return cast("Result[tuple[Decimal | None, Decimal]]", price_res) + price = price_res.data if "base_display_decimals" in pair_data: - amount = round(amount, pair_data["base_display_decimals"]) - return price, amount + amount_res = self._check_display_precision( + amount, pair_data["base_display_decimals"], "amount" + ) + if not amount_res.success: + return cast("Result[tuple[Decimal | None, Decimal]]", amount_res) + amount = amount_res.data + bounds_res = self._check_trade_amount_bounds(price, amount, pair_data) + if not bounds_res.success: + return cast("Result[tuple[Decimal | None, Decimal]]", bounds_res) + return Result.ok((price, amount)) + + @staticmethod + def _to_wei(value: Any, decimals: int) -> int: + """Precision-safe conversion of a human-readable amount to integer wei. + + Routes through :meth:`Utils.unit_conversion`, which performs + ``int(Decimal(str(value)) * Decimal(10)**decimals)``. Never multiply + floats by ``10**N`` in write paths. + """ + return cast(int, Utils.unit_conversion(value, decimals, to_base=True)) def _build_order_tuple( self, order, pair_data, side_enum, w3, client_order_id_bytes: bytes | None = None - ): - """Build order tuple for contract call.""" + ) -> Result[tuple]: + """Build order tuple for contract call. + + Returns ``Result.ok((order_tuple, client_order_id_hex))`` or + ``Result.fail(msg)`` when price/amount precision exceeds the pair's + display decimals. + """ import secrets - price, amount = self._normalize_order_amounts(order["price"], order["amount"], pair_data) + norm_res = self._normalize_order_amounts(order["price"], order["amount"], pair_data) + if not norm_res.success: + return cast(Result[tuple], norm_res) + assert norm_res.data is not None + price, amount = norm_res.data - price_wei = int(price * (10 ** pair_data["quote_decimals"])) - qty_wei = int(amount * (10 ** pair_data["base_decimals"])) + price_wei = self._to_wei(price, pair_data["quote_decimals"]) + qty_wei = self._to_wei(amount, pair_data["base_decimals"]) if client_order_id_bytes is None: client_order_id_bytes = secrets.token_bytes(32) client_order_id_hex = "0x" + client_order_id_bytes.hex() @@ -1386,7 +1549,7 @@ def _build_order_tuple( 0, # STP ) - return order_tuple, client_order_id_hex + return Result.ok((order_tuple, client_order_id_hex)) async def _check_balance_for_token(self, token, req_amt): """Check if sufficient balance exists for a token. @@ -1452,9 +1615,13 @@ async def _process_orders_for_batch(self, orders, w3): return None, None, None, cid_result.error or "Invalid client_order_id" cid_bytes = self._get_order_id_bytes(raw_cid) - order_tuple, client_order_id_hex = self._build_order_tuple( + build_res = self._build_order_tuple( order, pair_data, side_enum, w3, client_order_id_bytes=cid_bytes ) + if not build_res.success: + return None, None, None, build_res.error or "Order build failed" + assert build_res.data is not None + order_tuple, client_order_id_hex = build_res.data order_tuples.append(order_tuple) client_order_ids.append(client_order_id_hex) @@ -1636,8 +1803,16 @@ async def replace_order( pair_data = self.pairs[pair_name] - price_wei = int(new_price * (10 ** pair_data["quote_decimals"])) - qty_wei = int(new_amount * (10 ** pair_data["base_decimals"])) + # Validate precision against the pair's display decimals before + # encoding. The other write paths do the same — replace_order + # previously skipped this and let the contract reject the order. + norm_res = self._normalize_order_amounts(new_price, new_amount, pair_data) + if not norm_res.success: + return cast(Result[dict], norm_res) + assert norm_res.data is not None + new_price, new_amount = norm_res.data # type: ignore[assignment] + price_wei = self._to_wei(new_price, pair_data["quote_decimals"]) + qty_wei = self._to_wei(new_amount, pair_data["base_decimals"]) import secrets @@ -1831,11 +2006,11 @@ async def _process_replacement( required_balances[req_token] = required_balances.get(req_token, 0) + req_amt - price, amount = rep["price"], rep["amount"] - if "quote_display_decimals" in pair_data and price: - price = round(price, pair_data["quote_display_decimals"]) - if "base_display_decimals" in pair_data: - amount = round(amount, pair_data["base_display_decimals"]) + norm_res = self._normalize_order_amounts(rep["price"], rep["amount"], pair_data) + if not norm_res.success: + return cast(Result[dict], norm_res) + assert norm_res.data is not None + price, amount = norm_res.data if raw_cid := rep.get("client_order_id"): cid_result = validate_order_id_format(raw_cid, "client_order_id") @@ -1848,8 +2023,8 @@ async def _process_replacement( new_order_tuple = ( client_order_id, pair_data["tradePairId"], - int(price * (10 ** pair_data["quote_decimals"])), - int(amount * (10 ** pair_data["base_decimals"])), + self._to_wei(price, pair_data["quote_decimals"]), + self._to_wei(amount, pair_data["base_decimals"]), from_addr, side_enum, 1, # LIMIT diff --git a/src/dexalot_sdk/core/transfer.py b/src/dexalot_sdk/core/transfer.py index 8cef738..ff259be 100644 --- a/src/dexalot_sdk/core/transfer.py +++ b/src/dexalot_sdk/core/transfer.py @@ -700,9 +700,9 @@ async def _get_portfolio_balance_cached( return Result.ok( { - "total": balance_data[0] / (10**decimals), - "available": balance_data[1] / (10**decimals), - "locked": balance_data[2] / (10**decimals), + "total": Utils.unit_conversion(balance_data[0], decimals, to_base=False), + "available": Utils.unit_conversion(balance_data[1], decimals, to_base=False), + "locked": Utils.unit_conversion(balance_data[2], decimals, to_base=False), } ) except Exception as e: @@ -772,8 +772,8 @@ async def _get_all_portfolio_balances_cached(self, query_address: str | None) -> decimals = self._get_token_decimals(symbol, self.subnet_chain_id) if decimals is None: decimals = self._get_token_decimals(symbol, self.chain_id) or 18 - total = totals[i] / (10**decimals) - available = availables[i] / (10**decimals) + total = Utils.unit_conversion(totals[i], decimals, to_base=False) + available = Utils.unit_conversion(availables[i], decimals, to_base=False) all_balances[symbol] = { "total": total, "available": available, @@ -818,7 +818,7 @@ async def add_gas(self, amount: float, wait_for_receipt: bool = True) -> Result[ return Result.fail("Subnet Provider or Portfolio Contract not initialized.") try: - amount_wei = w3.to_wei(amount, "ether") + amount_wei = Utils.unit_conversion(amount, 18, to_base=True) tx_hash = await self._build_and_send_tx( w3, contract.functions.withdrawNative(from_addr, amount_wei), @@ -856,7 +856,7 @@ async def remove_gas(self, amount: float, wait_for_receipt: bool = True) -> Resu return Result.fail("Subnet Provider or Portfolio Contract not initialized.") try: - amount_wei = w3.to_wei(amount, "ether") + amount_wei = Utils.unit_conversion(amount, 18, to_base=True) tx_hash = await self._build_and_send_tx( w3, contract.functions.depositNative(from_addr, 0), @@ -901,7 +901,7 @@ async def transfer_portfolio( try: decimals = self._get_token_decimals(token, self.subnet_chain_id) or 18 - amount_wei = int(amount * (10**decimals)) + amount_wei = Utils.unit_conversion(amount, decimals, to_base=True) symbol_bytes32 = Utils.to_bytes32(token) # Check balance first @@ -1147,7 +1147,7 @@ async def deposit( return Result.fail("Could not resolve token decimals") decimals = decimals_result.data - amount_wei = int(amount * (10**decimals)) + amount_wei = Utils.unit_conversion(amount, decimals, to_base=True) bridge_id = self._get_bridge_id(canonical_source_chain, use_layerzero) symbol_bytes32 = Utils.to_bytes32(token) @@ -1266,7 +1266,7 @@ async def withdraw( f"{canonical_destination_chain} (ID {dest_chain_id})." ) - amount_wei = int(amount * (10**decimals)) + amount_wei = Utils.unit_conversion(amount, decimals, to_base=True) bridge_id = self._get_bridge_id(canonical_destination_chain, use_layerzero) symbol_bytes32 = Utils.to_bytes32(token) @@ -1350,7 +1350,7 @@ async def get_deposit_bridge_fee( f"{canonical_source_chain} (ID {src_chain_id})." ) - amount_wei = int(amount * (10**decimals)) + amount_wei = Utils.unit_conversion(amount, decimals, to_base=True) symbol_bytes32 = Utils.to_bytes32(token) bridge_id = self._get_bridge_id( canonical_source_chain, False @@ -1403,7 +1403,7 @@ async def transfer_token(self, token: str, to_address: str, amount: float) -> Re if decimals is None: decimals = self._get_token_decimals(token, self.chain_id) or 18 - amount_wei = int(amount * (10**decimals)) + amount_wei = Utils.unit_conversion(amount, decimals, to_base=True) symbol_bytes32 = Utils.to_bytes32(token) tx_hash = await self._build_and_send_tx( diff --git a/src/dexalot_sdk/utils/input_validators.py b/src/dexalot_sdk/utils/input_validators.py index 680cba5..59702ad 100644 --- a/src/dexalot_sdk/utils/input_validators.py +++ b/src/dexalot_sdk/utils/input_validators.py @@ -6,6 +6,7 @@ import math import re +from decimal import Decimal, InvalidOperation from .result import Result @@ -14,8 +15,24 @@ _HEX_PATTERN = re.compile(r"^[0-9a-fA-F]+$") -def validate_positive_float(value: object, param_name: str) -> Result[None]: - """Validate that a value is a positive finite float. +def _validate_positive_decimal(d: Decimal, param_name: str) -> Result[None]: + """Shared positivity / NaN / Infinity checks for Decimal-convertible values.""" + if d.is_nan(): + return Result.fail(f"Invalid {param_name}: cannot be NaN") + if d.is_infinite(): + return Result.fail(f"Invalid {param_name}: cannot be infinite") + if d <= 0: + return Result.fail(f"Invalid {param_name}: must be positive (> 0), got {d}") + return Result.ok(None) + + +def validate_positive_number(value: object, param_name: str) -> Result[None]: + """Validate that a value is a positive finite number (int, float, Decimal, or numeric str). + + Accepts ``int``, ``float``, ``Decimal``, and numeric ``str`` inputs so + callers wanting precision-exact arithmetic (e.g. for order amounts on + pairs with strict display decimals) can pass ``Decimal('2933')`` or + ``'2933.5'`` instead of a float. Args: value: The value to validate (runtime-checked; may be any type) @@ -24,21 +41,47 @@ def validate_positive_float(value: object, param_name: str) -> Result[None]: Returns: Result.ok(None) if valid, Result.fail(error_message) if invalid """ - if not isinstance(value, (int, float)): + if isinstance(value, bool): + # bool is a subclass of int in Python; reject explicitly so True/False + # don't slip through as amount=1 / amount=0. return Result.fail( - f"Invalid {param_name}: must be numeric (int or float), got {type(value).__name__}" + f"Invalid {param_name}: must be numeric (int, float, Decimal, or str), " + f"got {type(value).__name__}" ) - if math.isnan(value): - return Result.fail(f"Invalid {param_name}: cannot be NaN") + if isinstance(value, float): + if math.isnan(value): + return Result.fail(f"Invalid {param_name}: cannot be NaN") + if math.isinf(value): + return Result.fail(f"Invalid {param_name}: cannot be infinite") + if value <= 0: + return Result.fail(f"Invalid {param_name}: must be positive (> 0), got {value}") + return Result.ok(None) - if math.isinf(value): - return Result.fail(f"Invalid {param_name}: cannot be infinite") + if isinstance(value, int): + if value <= 0: + return Result.fail(f"Invalid {param_name}: must be positive (> 0), got {value}") + return Result.ok(None) - if value <= 0: - return Result.fail(f"Invalid {param_name}: must be positive (> 0), got {value}") + if isinstance(value, Decimal): + return _validate_positive_decimal(value, param_name) - return Result.ok(None) + if isinstance(value, str): + try: + d = Decimal(value) + except InvalidOperation: + return Result.fail(f"Invalid {param_name}: not a valid numeric string, got {value!r}") + return _validate_positive_decimal(d, param_name) + + return Result.fail( + f"Invalid {param_name}: must be numeric (int, float, Decimal, or str), " + f"got {type(value).__name__}" + ) + + +# Backwards-compatible alias. Existing callers still import this name; +# slated for removal in a future release (see CLAUDE.md). +validate_positive_float = validate_positive_number def validate_positive_int(value: object, param_name: str) -> Result[None]: @@ -268,7 +311,7 @@ def validate_order_params( return pair_result # Validate amount - amount_result = validate_positive_float(amount, "amount") + amount_result = validate_positive_number(amount, "amount") if not amount_result.success: return amount_result @@ -277,7 +320,7 @@ def validate_order_params( if order_type_upper == "LIMIT": if price is None: return Result.fail("Invalid price: required for LIMIT orders, got None") - price_result = validate_positive_float(price, "price") + price_result = validate_positive_number(price, "price") if not price_result.success: return price_result @@ -301,7 +344,7 @@ def validate_transfer_params(token: object, amount: object, to_address: object) return token_result # Validate amount - amount_result = validate_positive_float(amount, "amount") + amount_result = validate_positive_number(amount, "amount") if not amount_result.success: return amount_result @@ -334,7 +377,7 @@ def validate_swap_params(from_token: object, to_token: object, amount: object) - return to_token_result # Validate amount - amount_result = validate_positive_float(amount, "amount") + amount_result = validate_positive_number(amount, "amount") if not amount_result.success: return amount_result diff --git a/tests/unit/core/test_clob.py b/tests/unit/core/test_clob.py index ee1bb11..add8336 100644 --- a/tests/unit/core/test_clob.py +++ b/tests/unit/core/test_clob.py @@ -2,6 +2,7 @@ import json import os import time +from decimal import Decimal from unittest.mock import AsyncMock, MagicMock, mock_open, patch import pytest @@ -455,6 +456,47 @@ async def test_add_order_success(self, client): assert call_args["quantity"] == 1000000000000000000 # 1.0 * 10^18 assert call_args["side"] == 0 # BUY + @pytest.mark.parametrize( + "amount,base_decimals,expected_qty_wei", + [ + # The exact reporter case: 2933.0 * 1e18 used to truncate to ...934464. + (2933.0, 18, 2933000000000000000000), + (1840.0, 18, 1840000000000000000000), + # USDC-style 6-decimal token + (100.0, 6, 100_000_000), + # Sub-unit values + (0.1, 18, 100000000000000000), + ], + ) + async def test_add_order_quantity_precision( + self, client, amount, base_decimals, expected_qty_wei + ): + """add_order encodes quantity via Decimal arithmetic — no float-mul drift.""" + from dexalot_sdk.utils.result import Result + + client.pairs = { + "AVAX/USDC": { + "pair": "AVAX/USDC", + "base": "AVAX", + "quote": "USDC", + "base_decimals": base_decimals, + "quote_decimals": 6, + "base_display_decimals": 1, + "quote_display_decimals": 4, + "tradePairId": b"TPID", + } + } + mock_receipt = MagicMock() + mock_receipt.status = 1 + client._send_trade_tx = AsyncMock(return_value=("0xTxHash", mock_receipt)) + client.get_portfolio_balance = AsyncMock(return_value=Result.ok({"available": amount + 1})) + + res = await client.add_order("AVAX/USDC", "SELL", amount, 10.0) + + assert res.success + call_args = client.trade_pairs_contract.functions.addNewOrder.call_args[0][0] + assert call_args["quantity"] == expected_qty_wei + async def test_add_order_validations(self, client): """Test add_order validations.""" # No account @@ -686,6 +728,71 @@ async def test_replace_order(self, client): assert args[2] == 10000000 # 10.0 * 10^6 assert args[3] == 1000000000000000000 # 1.0 * 10^18 + @pytest.mark.parametrize( + "new_amount,expected_qty_wei", + [ + (2933.0, 2933000000000000000000), + (1840.0, 1840000000000000000000), + (0.1, 100000000000000000), + ], + ) + async def test_replace_order_quantity_precision(self, client, new_amount, expected_qty_wei): + """replace_order encodes via Decimal arithmetic (the 2933.0 case).""" + client.pairs = { + "AVAX/USDC": { + "pair": "AVAX/USDC", + "base": "AVAX", + "quote": "USDC", + "base_decimals": 18, + "quote_decimals": 6, + "base_display_decimals": 1, + "quote_display_decimals": 4, + "tradePairId": b"TPID", + } + } + self._stub_resolved_order(client, pair="AVAX/USDC", trade_pair_id=b"TPID") + client._send_trade_tx = AsyncMock(return_value=("0xTxHash", MagicMock(status=1))) + + res = await client.replace_order("0x01", 10.0, new_amount) + assert res.success + args = client.trade_pairs_contract.functions.cancelReplaceOrder.call_args[0] + assert args[3] == expected_qty_wei + + async def test_replace_order_enforces_display_decimal_precision(self, client): + """replace_order rejects inputs whose precision exceeds display decimals. + + Before the precision fix, replace_order skipped this check entirely and + let the contract reject the order on-chain with T-TMDQ-01. Now the SDK + rejects precision-out-of-bounds inputs locally, preventing wasted + gas and silent-slippage scenarios. + """ + client.pairs = { + "AVAX/USDC": { + "pair": "AVAX/USDC", + "base": "AVAX", + "quote": "USDC", + "base_decimals": 18, + "quote_decimals": 6, + "base_display_decimals": 1, + "quote_display_decimals": 4, + "tradePairId": b"TPID", + } + } + self._stub_resolved_order(client, pair="AVAX/USDC", trade_pair_id=b"TPID") + client._send_trade_tx = AsyncMock(return_value=("0xTxHash", MagicMock(status=1))) + + # 1.94 has 2 decimals; pair allows 1 for amount → rejected. + rejected = await client.replace_order("0x01", 0.1234, 1.94) + assert not rejected.success + assert "more than 1 decimals" in rejected.error + + # Precision-clean inputs pass through and encode exactly. + res = await client.replace_order("0x01", 0.1234, 1.9) + assert res.success + args = client.trade_pairs_contract.functions.cancelReplaceOrder.call_args[0] + assert args[2] == 123400 # 0.1234 * 10^6 (precision-exact) + assert args[3] == 1900000000000000000 # 1.9 * 10^18 (precision-exact) + async def test_get_open_orders(self, client): """Test get_open_orders.""" mock_orders = [ @@ -1172,6 +1279,41 @@ async def test_add_limit_order_list(self, client): assert order_tuples[0][5] == 0 # side BUY assert order_tuples[0][3] == 1000000000000000000 # amount + @pytest.mark.parametrize( + "amount,expected_qty_wei", + [ + (2933.0, 2933000000000000000000), + (1840.0, 1840000000000000000000), + (0.1, 100000000000000000), + ], + ) + async def test_add_limit_order_list_quantity_precision(self, client, amount, expected_qty_wei): + """add_limit_order_list (via _build_order_tuple) encodes quantity exactly.""" + from dexalot_sdk.utils.result import Result + + client.pairs = { + "AVAX/USDC": { + "tradePairId": b"TPID", + "pair": "AVAX/USDC", + "base_decimals": 18, + "quote_decimals": 6, + "base_display_decimals": 1, + "quote_display_decimals": 4, + "quote": "USDC", + "base": "AVAX", + } + } + client.get_portfolio_balance = AsyncMock(return_value=Result.ok({"available": amount + 1})) + client._ensure_pair_exists = AsyncMock(return_value=True) + client._send_trade_tx = AsyncMock(return_value=("0xTxHash", MagicMock(status=1))) + + res = await client.add_limit_order_list( + [{"pair": "AVAX/USDC", "side": "SELL", "amount": amount, "price": 10.0}] + ) + assert res.success + order_tuples = client.trade_pairs_contract.functions.addOrderList.call_args[0][0] + assert order_tuples[0][3] == expected_qty_wei # quantity is index 3 + async def test_cancel_add_list(self, client): """Test cancel_add_list.""" client.pairs = { @@ -1211,6 +1353,48 @@ async def test_cancel_add_list(self, client): assert args[1][0][1] == b"TPID" assert args[1][0][2] == 11000000 # 11.0 * 10^6 + @pytest.mark.parametrize( + "amount,expected_qty_wei", + [ + (2933.0, 2933000000000000000000), + (1840.0, 1840000000000000000000), + (0.1, 100000000000000000), + ], + ) + async def test_cancel_add_list_quantity_precision(self, client, amount, expected_qty_wei): + """cancel_add_list (via _process_replacement) encodes quantity exactly.""" + client.pairs = { + "AVAX/USDC": { + "pair": "AVAX/USDC", + "base_decimals": 18, + "quote_decimals": 6, + "base_display_decimals": 1, + "quote_display_decimals": 4, + "tradePairId": b"TPID", + "quote": "USDC", + "base": "AVAX", + } + } + self._stub_resolved_order(client, pair="AVAX/USDC", trade_pair_id=b"TPID") + client._ensure_pair_exists = AsyncMock(return_value=True) + client._send_trade_tx = AsyncMock(return_value=("0xTxHash", MagicMock(status=1))) + + res = await client.cancel_add_list( + [ + { + "order_id": "0x01", + "amount": amount, + "price": 10.0, + "pair": "AVAX/USDC", + "side": "SELL", + } + ] + ) + assert res.success + args = client.trade_pairs_contract.functions.cancelAddList.call_args[0] + # _newOrders[0] = (cid, tradePairId, price_wei, qty_wei, ...) + assert args[1][0][3] == expected_qty_wei + async def test_cancel_add_list_infers_side_from_existing_order(self, client): """cancel_add_list infers side from existing order when not provided.""" client.pairs = { @@ -2299,7 +2483,11 @@ async def test_clob_missing_coverage_4(self, client): client.pairs[VALID_PAIR]["quote_display_decimals"] = 2 async def test_clob_rounding(self, client): - """Test rounding logic in add_order.""" + """add_order rejects inputs whose precision exceeds display decimals. + + Previously the SDK silently rounded; now it returns Result.fail to + prevent silent slippage. Callers must round explicitly. + """ client.pairs = { VALID_PAIR: { "pair": VALID_PAIR, @@ -2318,17 +2506,17 @@ async def test_clob_rounding(self, client): client._send_trade_tx = AsyncMock(return_value=("tx", MagicMock(status=1))) client._ensure_pair_exists = AsyncMock(return_value=True) - await client.add_order(VALID_PAIR, "BUY", 1.1234, 10.5678) - # Verify rounded values were passed to the contract function via _send_trade_tx - # We need to look at what was passed to addNewOrder before it reached _send_trade_tx - # But since _send_trade_tx is called with the RESULT of the contract function... - # Wait, the SDK does: func = self.trade_pairs_contract.functions.addNewOrder(...) - # So we should inspect call_args of addNewOrder. + # 4 decimals against a 2-decimal pair → rejected. + res = await client.add_order(VALID_PAIR, "BUY", 1.1234, 10.5678) + assert not res.success + assert "more than 2 decimals" in res.error + + # Precision-clean values pass through. + res = await client.add_order(VALID_PAIR, "BUY", 1.12, 10.57) + assert res.success call_args = client.trade_pairs_contract.functions.addNewOrder.call_args[0][0] - # Price 10.57 * 10^6 = 10570000 - assert call_args["price"] == 10570000 - # Qty 1.12 * 10^18 - assert call_args["quantity"] >= 1120000000000000000 + assert call_args["price"] == 10570000 # 10.57 * 10^6 + assert call_args["quantity"] == 1120000000000000000 # 1.12 * 10^18 async def test_clob_order_utils(self, client): """Test various order utils and fallbacks.""" @@ -2496,11 +2684,19 @@ async def test_clob_batch_rounding(self, client): client._send_trade_tx = AsyncMock(return_value=("tx", MagicMock(status=1))) client._ensure_pair_exists = AsyncMock(return_value=True) - await client.add_limit_order_list( + # Inputs with extra precision are now rejected. + rejected = await client.add_limit_order_list( [{"pair": "ZZ/USDC", "side": "BUY", "amount": 1.1234, "price": 10.5678}] ) + assert not rejected.success + assert "more than 2 decimals" in rejected.error + + # Precision-clean values pass through. + await client.add_limit_order_list( + [{"pair": "ZZ/USDC", "side": "BUY", "amount": 1.12, "price": 10.57}] + ) call_args = client.trade_pairs_contract.functions.addOrderList.call_args[0][0] - assert call_args[0][2] == 10570000 + assert call_args[0][2] == 10570000 # 10.57 * 10^6 client._send_trade_tx.side_effect = Exception("Transaction reverted") result = await client.add_limit_order_list( @@ -2594,13 +2790,13 @@ async def test_clob_missing_coverage_6(self, client): client._ensure_pair_exists = AsyncMock(return_value=True) replacements = [ - # Int ID, SELL side, Needs rounding + # Int ID, SELL side, precision-clean values (matches 2 display decimals) { "order_id": 12345, "pair": "ZZ/USDC", "side": "SELL", - "amount": 1.1234, - "price": 10.5678, + "amount": 1.12, + "price": 10.57, }, # Bytes ID {"order_id": b"\x01" * 32, "pair": "ZZ/USDC", "side": "BUY", "amount": 1, "price": 1}, @@ -3626,6 +3822,8 @@ def test_order_pair_cache_helpers_cover_skip_and_rehydrate_guard(self, client): "quote": "USDC", "base_decimals": 18, "quote_decimals": 6, + "base_display_decimals": 1, + "quote_display_decimals": 4, "min_trade_amount": "0.1", "max_trade_amount": "10", }, @@ -3640,6 +3838,63 @@ def test_order_pair_cache_helpers_cover_skip_and_rehydrate_guard(self, client): client._rehydrate_cached_get_clob_pairs(Result.fail("cache miss")) assert client.pairs == before + def test_store_clob_pairs_drops_pairs_missing_display_decimals(self, client): + """Pairs without display decimals are dropped (with a warning). + + Display decimals are contractual: silently defaulting them would mask + the contract's T-TMDQ-01 rejection downstream. A pair missing these + fields cannot be safely used to place orders, so it's excluded from + the pair map and a warning is logged. + """ + from dexalot_sdk.constants import ENV_FUJI_MULTI_SUBNET + + transformed = [ + # OK — has both display decimals. + { + "env": ENV_FUJI_MULTI_SUBNET, + "pair": "AVAX/USDC", + "base": "AVAX", + "quote": "USDC", + "base_decimals": 18, + "quote_decimals": 6, + "base_display_decimals": 1, + "quote_display_decimals": 4, + }, + # Missing base_display_decimals → drop with warning. + { + "env": ENV_FUJI_MULTI_SUBNET, + "pair": "NOBASE/USDC", + "base": "NOBASE", + "quote": "USDC", + "base_decimals": 18, + "quote_decimals": 6, + "quote_display_decimals": 4, + }, + # quote_display_decimals explicitly None → drop with warning. + { + "env": ENV_FUJI_MULTI_SUBNET, + "pair": "NULL/USDC", + "base": "NULL", + "quote": "USDC", + "base_decimals": 18, + "quote_decimals": 6, + "base_display_decimals": 1, + "quote_display_decimals": None, + }, + ] + + with patch("dexalot_sdk.core.clob._logger") as mock_logger: + pair_list = client._store_clob_pairs(transformed) + + assert {p["pair"] for p in pair_list} == {"AVAX/USDC"} + assert "NOBASE/USDC" not in client.pairs + assert "NULL/USDC" not in client.pairs + # Two warnings, one per dropped pair. + assert mock_logger.warning.call_count == 2 + warning_calls = [c.args[1] for c in mock_logger.warning.call_args_list] + assert "NOBASE/USDC" in warning_calls + assert "NULL/USDC" in warning_calls + def test_order_normalization_helper_edge_cases(self, client): """Direct helper tests cover block coercion and pair-id resolution edge cases.""" @@ -3794,6 +4049,333 @@ async def test_replace_and_cancel_add_list_fail_with_formatting_fallback_message assert not cancel_add_result.success assert cancel_add_result.error == "Order formatting failed" + @pytest.mark.parametrize( + "value,display_decimals,should_accept", + [ + # Precision-clean inputs accepted at exact display precision + (2933.0, 4, True), + (1840.0, 1, True), + (10.57, 2, True), + # Float-noise residuals are tolerated and snapped to the nearest + (0.30000000000000004, 4, True), # = 0.3000 + 4e-17 + (0.1 + 0.2, 4, True), # = 0.30000000000000004 + # Genuine extra precision is rejected (no silent slippage) + (10.5678, 2, False), + (1.123, 1, False), + (0.30001, 4, False), + # Borderline: residual just above tolerance → reject + (1.0000001, 0, False), # residual 1e-7 > tolerance 1e-10 + # Decimal and string inputs work the same + (Decimal("10.57"), 2, True), + ("10.5678", 2, False), + ], + ) + def test_check_display_precision_reject_with_tolerance( + self, value, display_decimals, should_accept + ): + """_check_display_precision tolerates float noise; rejects real extra precision.""" + res = CLOBClient._check_display_precision(value, display_decimals, "amount") + assert res.success == should_accept + if not should_accept: + assert res.error is not None + assert f"more than {display_decimals} decimals" in res.error + + def test_check_display_precision_zero_short_circuits(self): + """Zero is valid at any precision and short-circuits before quantize.""" + for val in (0, 0.0, Decimal("0"), "0"): + res = CLOBClient._check_display_precision(val, 4, "amount") + assert res.success + assert res.data == Decimal(0) + + @pytest.mark.parametrize( + "price,amount,min_amt,max_amt,should_succeed,expected_error_fragment", + [ + # Notional 10 * 1 = 10, between min=1 and max=100 → ok + (Decimal("10"), Decimal("1"), Decimal("1"), Decimal("100"), True, None), + # Notional 10 * 0.05 = 0.5, below min=1 → reject + ( + Decimal("10"), + Decimal("0.05"), + Decimal("1"), + Decimal("100"), + False, + "below min_trade_amount", + ), + # Notional 10 * 20 = 200, above max=100 → reject + ( + Decimal("10"), + Decimal("20"), + Decimal("1"), + Decimal("100"), + False, + "above max_trade_amount", + ), + # min == 0 means "no min bound" — tiny notional accepted + (Decimal("1"), Decimal("0.0001"), Decimal("0"), Decimal("100"), True, None), + # max == 0 means "no max bound" — huge notional accepted + (Decimal("1"), Decimal("9999999"), Decimal("1"), Decimal("0"), True, None), + # Boundary: notional == min is accepted (>=) + (Decimal("1"), Decimal("1"), Decimal("1"), Decimal("100"), True, None), + # Boundary: notional == max is accepted (<=) + (Decimal("1"), Decimal("100"), Decimal("1"), Decimal("100"), True, None), + ], + ) + def test_check_trade_amount_bounds( + self, price, amount, min_amt, max_amt, should_succeed, expected_error_fragment + ): + """_check_trade_amount_bounds rejects notional outside [min, max] (quote-denominated).""" + pair_data = { + "pair": "AVAX/USDC", + "min_trade_amount": min_amt, + "max_trade_amount": max_amt, + } + res = CLOBClient._check_trade_amount_bounds(price, amount, pair_data) + assert res.success == should_succeed + if not should_succeed: + assert res.error is not None + assert expected_error_fragment in res.error + + def test_check_trade_amount_bounds_skips_when_price_none(self): + """Market-order paths (price=None) skip the bound check.""" + pair_data = { + "pair": "AVAX/USDC", + "min_trade_amount": Decimal("100"), + "max_trade_amount": Decimal("200"), + } + # amount alone is far outside the range, but price=None skips entirely. + res = CLOBClient._check_trade_amount_bounds(None, Decimal("0.001"), pair_data) + assert res.success + + async def test_all_clob_write_paths_enforce_min_trade_amount(self, client): + """All four CLOB write paths reject sub-min trade notional consistently.""" + from dexalot_sdk.utils.result import Result + + pair_data = { + "pair": "AVAX/USDC", + "base": "AVAX", + "quote": "USDC", + "base_decimals": 18, + "quote_decimals": 6, + "base_display_decimals": 1, + "quote_display_decimals": 4, + # Notional must be >= 5 quote-tokens; price=1 * amount=0.1 = 0.1 → reject. + "min_trade_amount": Decimal("5"), + "max_trade_amount": Decimal("0"), + "tradePairId": b"PID", + } + client.pairs = {"AVAX/USDC": pair_data} + client._ensure_pair_exists = AsyncMock(return_value=True) + client.get_portfolio_balance = AsyncMock(return_value=Result.ok({"available": 1000.0})) + client._send_trade_tx = AsyncMock(return_value=("0xTxHash", MagicMock(status=1))) + + a = await client.add_order("AVAX/USDC", "BUY", 0.1, 1.0) + assert not a.success and "below min_trade_amount" in a.error + + b = await client.add_limit_order_list( + [{"pair": "AVAX/USDC", "side": "BUY", "amount": 0.1, "price": 1.0}] + ) + assert not b.success and "below min_trade_amount" in b.error + + self._stub_resolved_order(client, pair="AVAX/USDC", trade_pair_id=b"PID") + c = await client.replace_order("0x01", 1.0, 0.1) + assert not c.success and "below min_trade_amount" in c.error + + self._stub_resolved_order(client, pair="AVAX/USDC", trade_pair_id=b"PID") + d = await client.cancel_add_list( + [ + { + "order_id": "0x01", + "amount": 0.1, + "price": 1.0, + "pair": "AVAX/USDC", + "side": "BUY", + } + ] + ) + assert not d.success and "below min_trade_amount" in d.error + + async def test_all_clob_write_paths_reject_extra_precision(self, client): + """All four CLOB write paths reject inputs that exceed display decimals. + + Drift guard: each path must call _normalize_order_amounts so the + REJECT-with-tolerance gate is enforced consistently. A path that + skipped the helper would silently encode extra-precision amounts + and the contract would reject on-chain. + """ + from dexalot_sdk.utils.result import Result + + pair_data = { + "pair": "AVAX/USDC", + "base": "AVAX", + "quote": "USDC", + "base_decimals": 18, + "quote_decimals": 6, + "base_display_decimals": 1, + "quote_display_decimals": 4, + "tradePairId": b"PID", + } + client.pairs = {"AVAX/USDC": pair_data} + client._ensure_pair_exists = AsyncMock(return_value=True) + client.get_portfolio_balance = AsyncMock(return_value=Result.ok({"available": 1000.0})) + client._send_trade_tx = AsyncMock(return_value=("0xTxHash", MagicMock(status=1))) + + # amount=1.99 has 2 fractional digits; pair allows 1 → rejected. + a = await client.add_order("AVAX/USDC", "BUY", 1.99, 10.0) + assert not a.success and "more than 1 decimals" in a.error + + b = await client.add_limit_order_list( + [{"pair": "AVAX/USDC", "side": "BUY", "amount": 1.99, "price": 10.0}] + ) + assert not b.success and "more than 1 decimals" in b.error + + self._stub_resolved_order(client, pair="AVAX/USDC", trade_pair_id=b"PID") + c = await client.replace_order("0x01", 10.0, 1.99) + assert not c.success and "more than 1 decimals" in c.error + + self._stub_resolved_order(client, pair="AVAX/USDC", trade_pair_id=b"PID") + d = await client.cancel_add_list( + [ + { + "order_id": "0x01", + "amount": 1.99, + "price": 10.0, + "pair": "AVAX/USDC", + "side": "BUY", + } + ] + ) + assert not d.success and "more than 1 decimals" in d.error + + async def test_all_clob_write_paths_encode_identically(self, client): + """All four CLOB write paths produce identical wei for identical input. + + Drift guard: if any path bypasses _normalize_order_amounts → _to_wei, + precision can re-diverge (the original replace_order bug). Pair-clean + inputs must encode to the same wei across add_order, + add_limit_order_list, replace_order, cancel_add_list. + """ + from dexalot_sdk.utils.result import Result + + pair_data = { + "pair": "AVAX/USDC", + "base": "AVAX", + "quote": "USDC", + "base_decimals": 18, + "quote_decimals": 6, + "base_display_decimals": 1, + "quote_display_decimals": 4, + "tradePairId": b"PID", + } + client.pairs = {"AVAX/USDC": pair_data} + client._ensure_pair_exists = AsyncMock(return_value=True) + client.get_portfolio_balance = AsyncMock(return_value=Result.ok({"available": 1000.0})) + client._send_trade_tx = AsyncMock(return_value=("0xTxHash", MagicMock(status=1))) + + # Precision-clean inputs (1 base dp, 4 quote dp). + expected_price_wei = 10567000 # 10.567 * 10^6 + expected_qty_wei = 1900000000000000000 # 1.9 * 10^18 + + await client.add_order("AVAX/USDC", "BUY", 1.9, 10.567) + a = client.trade_pairs_contract.functions.addNewOrder.call_args[0][0] + assert a["price"] == expected_price_wei + assert a["quantity"] == expected_qty_wei + + await client.add_limit_order_list( + [{"pair": "AVAX/USDC", "side": "BUY", "amount": 1.9, "price": 10.567}] + ) + b = client.trade_pairs_contract.functions.addOrderList.call_args[0][0] + assert b[0][2] == expected_price_wei + assert b[0][3] == expected_qty_wei + + self._stub_resolved_order(client, pair="AVAX/USDC", trade_pair_id=b"PID") + await client.replace_order("0x01", 10.567, 1.9) + c = client.trade_pairs_contract.functions.cancelReplaceOrder.call_args[0] + assert c[2] == expected_price_wei + assert c[3] == expected_qty_wei + + self._stub_resolved_order(client, pair="AVAX/USDC", trade_pair_id=b"PID") + await client.cancel_add_list( + [ + { + "order_id": "0x01", + "amount": 1.9, + "price": 10.567, + "pair": "AVAX/USDC", + "side": "BUY", + } + ] + ) + d = client.trade_pairs_contract.functions.cancelAddList.call_args[0][1] + assert d[0][2] == expected_price_wei + assert d[0][3] == expected_qty_wei + + @pytest.mark.parametrize( + "value,expected", + [ + (2933.0, "2933"), + (1840.0, "1840"), + (0.1, "0.1"), + (0.30000000000000004, "0.30000000000000004"), + ("2933", "2933"), + ("2933.5", "2933.5"), + ], + ) + def test_to_decimal_preserves_user_intent(self, value, expected): + """_to_decimal routes floats through str() so user-typed values survive.""" + from decimal import Decimal + + assert CLOBClient._to_decimal(value) == Decimal(expected) + + def test_to_decimal_passes_decimal_through(self): + """_to_decimal returns Decimal inputs unchanged.""" + from decimal import Decimal + + d = Decimal("1.23456789012345678901234567890") + assert CLOBClient._to_decimal(d) is d + + @pytest.mark.parametrize( + "value,display_decimals,expected", + [ + # Truncates (ROUND_DOWN) — never rounds up + (2.99, 1, "2.9"), + (2.51, 1, "2.5"), + (2.55, 1, "2.5"), + (2933.95, 1, "2933.9"), + (0.123456789, 4, "0.1234"), + # Exact values pass through cleanly + (2933.0, 1, "2933.0"), + (0.1, 4, "0.1000"), + # Display_decimals=0 truncates to integer + (2.99, 0, "2"), + ], + ) + def test_quantize_to_display_truncates_round_down(self, value, display_decimals, expected): + """_quantize_to_display uses ROUND_DOWN — never overshoots user input.""" + from decimal import Decimal + + assert CLOBClient._quantize_to_display(value, display_decimals) == Decimal(expected) + + @pytest.mark.parametrize( + "value,decimals,expected", + [ + # The exact bug from the reporter: 2933.0 must round-trip cleanly + (2933.0, 18, 2933000000000000000000), + (1840.0, 18, 1840000000000000000000), + # USDC (6 decimals) + (1.5, 6, 1500000), + (100, 6, 100000000), + # Decimal inputs — pass through exactly + (__import__("decimal").Decimal("2933"), 18, 2933000000000000000000), + (__import__("decimal").Decimal("0.000001"), 6, 1), + # Numeric strings — pass through exactly + ("2933", 18, 2933000000000000000000), + ("0.1", 18, 100000000000000000), + ], + ) + def test_to_wei_is_decimal_exact(self, value, decimals, expected): + """_to_wei never loses precision regardless of input type (the 2933.0 bug).""" + assert CLOBClient._to_wei(value, decimals) == expected + class TestWebSocketManager: """Tests for WebSocketManager (async websockets-based implementation).""" diff --git a/tests/unit/core/test_transfer.py b/tests/unit/core/test_transfer.py index 623ad71..bef4540 100644 --- a/tests/unit/core/test_transfer.py +++ b/tests/unit/core/test_transfer.py @@ -1,5 +1,6 @@ import asyncio import os +from decimal import Decimal from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -594,6 +595,24 @@ async def test_get_portfolio_balance(self, client): assert res.data["total"] == 10.0 assert res.data["available"] == 5.0 + async def test_get_portfolio_balance_large_wei_precision(self, client): + """Large wei values convert back to human units exactly (Decimal path). + + With the previous float division, balance_wei // (10**18) for very + large balances would lose the last digit or two of precision. Routing + through Utils.unit_conversion (Decimal-backed) preserves them. + """ + # A balance just at the edge where float division starts to drift + # (much larger than 2**53). Routing through Decimal keeps it exact. + large_wei = 12345678901234567890123456 # 25 digits + client.portfolio_sub_contract.functions.getBalance.return_value.call = AsyncMock( + return_value=(large_wei, large_wei, large_wei) + ) + res = await client.get_portfolio_balance("USDC") + assert res.success + # 6-decimal token (USDC): divide by 10^6 + assert res.data["total"] == float(Decimal(large_wei) / Decimal(10**6)) + async def test_get_portfolio_balance_empty_token(self, client): result = await client.get_portfolio_balance("") assert not result.success @@ -662,6 +681,28 @@ async def test_remove_gas(self, client): assert res.data["tx_hash"] == "0x74785f68617368" assert res.data["operation"] == "remove_gas" + @pytest.mark.parametrize( + "amount,expected_wei", + [ + # web3.py's to_wei also uses Decimal(str(...)) internally, so this + # commit is a no-op for behavior — these assertions confirm that. + (2933.0, 2933000000000000000000), + (1.0, 10**18), + (Decimal("0.000000000000000001"), 1), # 1 wei + ], + ) + async def test_add_gas_precision(self, client, amount, expected_wei): + """add_gas encodes amount via Utils.unit_conversion (consistency with + other transfer write paths).""" + client.portfolio_sub_contract.functions.withdrawNative.return_value.fn_name = ( + "withdrawNative" + ) + res = await client.add_gas(amount) + assert res.success + call_args = client.portfolio_sub_contract.functions.withdrawNative.call_args + # withdrawNative(_from, _amount) + assert call_args[0][1] == expected_wei + async def test_transfer_portfolio(self, client): from dexalot_sdk.utils.result import Result @@ -674,6 +715,30 @@ async def test_transfer_portfolio(self, client): assert call_args[0][1] == Utils.to_bytes32("USDC") assert call_args[0][2] == 1000000 + @pytest.mark.parametrize( + "amount,decimals,expected_wei", + [ + (2933.0, 18, 2933000000000000000000), + (100.0, 6, 100_000_000), + (Decimal("0.000001"), 6, 1), + ], + ) + async def test_transfer_portfolio_precision(self, client, amount, decimals, expected_wei): + """transfer_portfolio encodes amount via Decimal arithmetic.""" + from unittest.mock import patch + + from dexalot_sdk.core.transfer import TransferClient + from dexalot_sdk.utils.result import Result + + client.get_portfolio_balance = AsyncMock( + return_value=Result.ok({"available": float(amount) + 1}) + ) + with patch.object(TransferClient, "_get_token_decimals", return_value=decimals): + res = await client.transfer_portfolio("USDC", amount, VALID_RECIPIENT) + assert res.success + call_args = client.portfolio_sub_contract.functions.transferToken.call_args + assert call_args[0][2] == expected_wei + async def test_transfer_token(self, client): res = await client.transfer_token("USDC", VALID_RECIPIENT, 1.0) assert res.success @@ -687,6 +752,34 @@ async def test_transfer_token(self, client): assert call_args[0][2] == Utils.to_bytes32("USDC") assert call_args[0][3] == 1000000 # 1.0 * 10^6 + @pytest.mark.parametrize( + "amount,decimals,expected_wei", + [ + # The 2933.0 case from the bug report — 18-decimal token + (2933.0, 18, 2933000000000000000000), + (1840.0, 18, 1840000000000000000000), + # USDC-style 6-decimal token + (100.0, 6, 100_000_000), + # Sub-unit values + (0.1, 6, 100_000), + # Decimal / string inputs + (Decimal("2933"), 18, 2933000000000000000000), + ("2933.5", 18, 2933500000000000000000), + ], + ) + async def test_transfer_token_precision(self, client, amount, decimals, expected_wei): + """transfer_token encodes amount via Decimal arithmetic (no float-mul drift).""" + from unittest.mock import patch + + from dexalot_sdk.core.transfer import TransferClient + + with patch.object(TransferClient, "_get_token_decimals", return_value=decimals): + res = await client.transfer_token("USDC", VALID_RECIPIENT, amount) + assert res.success + call_args = client.portfolio_sub_contract.functions.transferToken.call_args + # transferToken(_from, _to, _symbol, _quantity) + assert call_args[0][3] == expected_wei + async def test_transfer_token_errors(self, client): """Test transfer_token errors.""" client.account = None @@ -722,6 +815,37 @@ async def test_get_deposit_bridge_fee(self, client): assert fee.success assert fee.data == 0.005 + @pytest.mark.parametrize( + "amount,decimals,expected_wei", + [ + (2933.0, 18, 2933000000000000000000), + (Decimal("0.000001"), 6, 1), + ], + ) + async def test_get_deposit_bridge_fee_precision(self, client, amount, decimals, expected_wei): + """get_deposit_bridge_fee encodes amount via Decimal arithmetic.""" + from unittest.mock import patch + + from dexalot_sdk.core.transfer import TransferClient + + captured_amount_wei = [] + + async def capture_internal(w3, contract, bridge_id, symbol, amt_wei): + captured_amount_wei.append(amt_wei) + return 5000000000000000 + + client._get_bridge_fee_internal = capture_internal + mock_bridge = MagicMock() + client.portfolio_main_avax_contract.functions.portfolioBridge.return_value.call = AsyncMock( + return_value="0xBridge" + ) + client.connected_chain_providers["Avalanche"].eth.contract.side_effect = None + client.connected_chain_providers["Avalanche"].eth.contract.return_value = mock_bridge + with patch.object(TransferClient, "_get_token_decimals", return_value=decimals): + res = await client.get_deposit_bridge_fee("AVAX", amount, "Avalanche") + assert res.success + assert captured_amount_wei == [expected_wei] + async def test_deposit(self, client): from dexalot_sdk.utils.result import Result @@ -745,6 +869,62 @@ async def test_deposit(self, client): assert call_args[0][2] == 10000000 # 10.0 * 10^6 assert call_args[0][3] == 2 # Bridge ID for Avalanche + @pytest.mark.parametrize( + "amount,decimals,expected_wei", + [ + (2933.0, 18, 2933000000000000000000), + (1840.0, 18, 1840000000000000000000), + (100.0, 6, 100_000_000), + ], + ) + async def test_deposit_precision(self, client, amount, decimals, expected_wei): + """deposit encodes amount via Decimal arithmetic.""" + from unittest.mock import patch + + from dexalot_sdk.core.transfer import TransferClient + from dexalot_sdk.utils.result import Result + + client.get_deposit_bridge_fee = AsyncMock(return_value=Result.ok(0.01)) + mock_token = MagicMock() + mock_token.functions.allowance.return_value.call = AsyncMock(return_value=10**40) + mock_token.functions.getBridgeFee.return_value.call = AsyncMock(return_value=0) + client.connected_chain_providers["Avalanche"].eth.contract.side_effect = None + client.connected_chain_providers["Avalanche"].eth.contract.return_value = mock_token + client.portfolio_main_avax_contract.functions.depositToken.return_value.fn_name = ( + "depositToken" + ) + with patch.object(TransferClient, "_get_token_decimals", return_value=decimals): + res = await client.deposit("USDC", amount, "Avalanche") + assert res.success + call_args = client.portfolio_main_avax_contract.functions.depositToken.call_args + # depositToken(_from, _symbol, _quantity, _bridgeId) + assert call_args[0][2] == expected_wei + + @pytest.mark.parametrize( + "amount,decimals,expected_wei", + [ + (2933.0, 18, 2933000000000000000000), + (5.0, 6, 5_000_000), + (Decimal("0.123456"), 6, 123_456), + ], + ) + async def test_withdraw_precision(self, client, amount, decimals, expected_wei): + """withdraw encodes amount via Decimal arithmetic.""" + from unittest.mock import patch + + from dexalot_sdk.core.transfer import TransferClient + + mock_token = MagicMock() + mock_token.functions.allowance.return_value.call = AsyncMock(return_value=10**40) + client.w3_l1.eth.contract.side_effect = None + client.w3_l1.eth.contract.return_value = mock_token + with patch.object(TransferClient, "_get_token_decimals", return_value=decimals): + res = await client.withdraw("USDC", amount, "Avalanche") + assert res.success + call_args = client.portfolio_sub_contract.functions.withdrawToken.call_args + # withdrawToken(_to, _symbol, _quantity, _feeType, _chainId) + assert call_args[0][2] == expected_wei + async def test_deposit_native(self, client): """Test deposit of native token (AVAX).""" # Mock _get_bridge_fee_internal diff --git a/tests/unit/utils/test_input_validators.py b/tests/unit/utils/test_input_validators.py index 4e94b8e..4814f6b 100644 --- a/tests/unit/utils/test_input_validators.py +++ b/tests/unit/utils/test_input_validators.py @@ -49,11 +49,23 @@ def test_inf_fails(self): assert not result.success assert_contains(result.error, "cannot be infinite") - def test_non_numeric_fails(self): - """Test that non-numeric types fail.""" - result = validate_positive_float("1.0", "amount") + def test_numeric_strings_now_accepted(self): + """Numeric strings are accepted (parsed as Decimal).""" + assert validate_positive_float("1.0", "amount").success + assert validate_positive_float("2933", "amount").success + + def test_decimal_now_accepted(self): + """Decimal is accepted (precision-preserving alternative to float).""" + from decimal import Decimal + + assert validate_positive_float(Decimal("2933"), "amount").success + assert not validate_positive_float(Decimal("0"), "amount").success + + def test_non_numeric_string_fails(self): + """Non-numeric strings still fail.""" + result = validate_positive_float("abc", "amount") assert not result.success - assert_contains(result.error, "must be numeric") + assert_contains(result.error, "not a valid numeric string") class TestValidatePositiveInt: diff --git a/tests/unit/utils/test_validators.py b/tests/unit/utils/test_validators.py index 4e94b8e..47d5070 100644 --- a/tests/unit/utils/test_validators.py +++ b/tests/unit/utils/test_validators.py @@ -49,9 +49,49 @@ def test_inf_fails(self): assert not result.success assert_contains(result.error, "cannot be infinite") - def test_non_numeric_fails(self): - """Test that non-numeric types fail.""" - result = validate_positive_float("1.0", "amount") + def test_numeric_strings_now_accepted(self): + """Numeric strings are accepted (parsed as Decimal).""" + assert validate_positive_float("1.0", "amount").success + assert validate_positive_float("2933", "amount").success + assert validate_positive_float("0.000001", "amount").success + + def test_decimal_now_accepted(self): + """Decimal is accepted (precision-preserving alternative to float).""" + from decimal import Decimal + + assert validate_positive_float(Decimal("1"), "amount").success + assert validate_positive_float(Decimal("2933"), "amount").success + assert not validate_positive_float(Decimal("0"), "amount").success + assert not validate_positive_float(Decimal("-1"), "amount").success + assert not validate_positive_float(Decimal("NaN"), "amount").success + + def test_decimal_infinity_fails(self): + """Decimal Infinity (and string 'Infinity') are rejected.""" + from decimal import Decimal + + res = validate_positive_float(Decimal("Infinity"), "amount") + assert not res.success + assert_contains(res.error, "cannot be infinite") + + res = validate_positive_float("Infinity", "amount") + assert not res.success + assert_contains(res.error, "cannot be infinite") + + def test_non_numeric_string_fails(self): + """Non-numeric strings still fail.""" + result = validate_positive_float("abc", "amount") + assert not result.success + assert_contains(result.error, "not a valid numeric string") + + def test_bool_fails(self): + """bool is explicitly rejected even though it's a subclass of int.""" + result = validate_positive_float(True, "amount") + assert not result.success + assert_contains(result.error, "must be numeric") + + def test_non_numeric_type_fails(self): + """Other non-numeric types fail.""" + result = validate_positive_float([1.0], "amount") assert not result.success assert_contains(result.error, "must be numeric") diff --git a/uv.lock b/uv.lock index c2eeecf..39831e1 100644 --- a/uv.lock +++ b/uv.lock @@ -733,13 +733,15 @@ wheels = [ [[package]] name = "dexalot-sdk" -version = "0.5.14" +version = "0.5.15" source = { editable = "." } dependencies = [ { name = "aiohttp" }, { name = "cryptography" }, { name = "eth-account" }, + { name = "idna" }, { name = "python-dotenv" }, + { name = "urllib3" }, { name = "web3" }, { name = "websockets" }, ] @@ -768,7 +770,9 @@ requires-dist = [ { name = "aiohttp", specifier = ">=3.9,<4" }, { name = "cryptography", specifier = ">=46,<48" }, { name = "eth-account", specifier = ">=0.11,<1" }, + { name = "idna", specifier = ">=3.15" }, { name = "python-dotenv", specifier = ">=1.0,<2" }, + { name = "urllib3", specifier = ">=2.7.0" }, { name = "web3", specifier = ">=6.0.0,<8" }, { name = "websockets", specifier = ">=13.0,<15" }, ] @@ -1061,11 +1065,11 @@ wheels = [ [[package]] name = "idna" -version = "3.11" +version = "3.16" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1a/88/bcf9709822fe69d02c2a6a77956c98ce6ea8ca8767a9aadcedc7eb6a2390/idna-3.16.tar.gz", hash = "sha256:d7a6da03db833450fca25d2358ac9ff06cd624577a4aea3a596d5c0f77b8e03d", size = 203770, upload-time = "2026-05-22T00:16:18.781Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, + { url = "https://files.pythonhosted.org/packages/94/16/70255075a9859a0e3adb789b68ceb0e210dec03934245fd98d248226572f/idna-3.16-py3-none-any.whl", hash = "sha256:cc246e3a3f89580c3a951b5ad298ca4638078b2cdd4f115654332b5c26daded5", size = 74165, upload-time = "2026-05-22T00:16:16.698Z" }, ] [[package]] @@ -2513,11 +2517,11 @@ wheels = [ [[package]] name = "urllib3" -version = "2.6.3" +version = "2.7.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, ] [[package]]