fix: precision-safe amount/price handling across CLOB and TRANSFER#8
Merged
Conversation
…elpers Adds three helpers on CLOBClient (no call-site changes yet): - _to_decimal(value): coerces int/float/str/Decimal to exact Decimal, routing floats through str() so user intent is preserved. - _quantize_to_display(value, display_decimals): truncates to the pair's display decimals using ROUND_DOWN so the SDK never overshoots a user-typed amount. - _to_wei(value, decimals): wraps Utils.unit_conversion for precision- exact conversion to integer wei, replacing int(float * 10**N). Subsequent commits wire these into add_order, _build_order_tuple, replace_order, and _process_replacement to fix the precision bug where e.g. 2933.0 * 10**18 truncates to 2932999999999999934464.
add_order previously did int(price * (10 ** quote_decimals)) and int(amount * (10 ** base_decimals)) with floating-point multiplication, which silently truncates exact decimals like 2933.0 to e.g. 2932999999999999934464 (instead of 2933000000000000000000). The contract then rejects the order with T-TMDQ-01 'too many decimals in quantity'. Routes both encodings through CLOBClient._to_wei (Decimal-backed) introduced in the prior commit. No public API change; existing tests continue to pass with identical assertions. Adds parametrized precision regression tests covering the exact 2933.0 case from the bug report, 1840.0, sub-unit floats, and a USDC-style 6-decimal token.
_build_order_tuple is the encoding helper used by add_limit_order_list (and indirectly by every batched order path). It previously had the same float * 10**N precision bug as add_order, silently truncating clean inputs like 2933.0 to ...934464 wei. Routes both price and quantity through CLOBClient._to_wei. No call sites change shape; existing assertions still hold. Adds parametrized precision regression tests through the batch path.
…c to replace_order Two bugs co-located in replace_order: 1. Float-multiplication precision loss (same as add_order / _build_order_tuple): int(new_price * (10 ** quote_decimals)) and int(new_amount * (10 ** base_decimals)) silently truncate exact inputs like 2933.0 to ...934464 wei. 2. Display-decimal rounding step was missing entirely. The other three CLOB write paths (add_order, _build_order_tuple, _process_replacement) all called _normalize_order_amounts before the wei encoding — replace_order did not. So an input like new_price=0.123456789 with quote_display_decimals=4 was encoded with full float precision and rejected by the contract. Both are fixed by routing through _normalize_order_amounts + CLOBClient._to_wei (Decimal-backed), matching the other paths. Adds parametrized precision tests + an explicit test asserting that inputs exceeding display decimals are rounded before encoding.
…_list) _process_replacement is the encoding helper used by cancel_add_list, which atomically cancels existing orders and submits new ones. Like the other three CLOB write paths, it had the float * 10**N precision bug that silently truncated exact decimals like 2933.0 to ...934464. Routes the price and quantity encodings through CLOBClient._to_wei. Adds parametrized precision tests through the cancel-add batch path.
…sion gate Two changes (revisiting an earlier intent of switching to ROUND_DOWN): 1. All four CLOB write paths (add_order, _build_order_tuple, replace_order, _process_replacement) now route through _normalize_order_amounts. Previously add_order and _process_replacement inlined the rounding logic, and replace_order skipped it — that drift was the root cause of an earlier missing- rounding bug. _build_order_tuple now returns Result so precision errors propagate cleanly through _process_orders_for_batch. 2. _normalize_order_amounts 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). This replaces the original silent- rounding behavior to prevent silent-slippage scenarios where a stop-loss at 99.99 would quietly trigger at 99.9. For a trading SDK, silent rounding is more dangerous than verbose rejection: a market-making bot placing thousands of orders cannot afford the drift between typed and executed values. Callers with intentional rounding behavior should round before passing. Behavior change: callers that previously relied on banker's rounding to silently snap to display decimals will now get Result.fail with a clear error message naming the offending parameter and value. Adds drift-guard tests asserting (a) all four write paths reject extra-precision inputs consistently and (b) all four encode identical wei for the same precision-clean input. Updates existing rounding tests (test_clob_rounding, test_clob_batch_rounding, test_clob_missing_coverage_6, test_replace_order_*) to use precision- clean inputs and assert REJECT for over-precision.
…to 18
_store_clob_pairs previously did .get('base_display_decimals', 18) and
.get('quote_display_decimals', 18) for any pair whose API record omitted
these fields. That default effectively disabled display-decimal
validation for those pairs, letting the SDK encode unlimited precision
and forwarding the contract's T-TMDQ-01 rejection to the user.
Display decimals are contractual: a pair that doesn't supply them
cannot be safely used to place orders. Drop the pair from self.pairs
and log a WARNING. Downstream callers will see 'Pair {x} not found'
which is the correct fail-fast behavior.
Adds a logger to the module and a dedicated test for the drop-and-warn
path. Updates the existing pair-cache helper test to include display
decimals on its sample fixture.
…er validator validate_positive_float previously accepted only int and float, forcing callers wanting precision-exact arithmetic (e.g. for amounts on pairs with strict display decimals) to pre-convert to float and risk the binary-float precision bugs this PR is fixing. Renames the validator to validate_positive_number and accepts: - int, float (existing behavior) - Decimal (recommended for precision-exact amounts) - numeric str (e.g. '2933.5', parsed via Decimal) Rejects bool explicitly (would have leaked through as int=0/1 otherwise) and non-numeric strings with a clear error. Extracts the shared positivity / NaN / Infinity checks for Decimal-convertible values into a _validate_positive_decimal helper to keep the public validator under ruff's complexity limit. Keeps validate_positive_float as a backwards-compatible alias for one release. All internal call sites migrated to the new name. Adds coverage for the previously-uncovered Decimal-Infinity branch in the validator (clob.py and input_validators.py now at 100% line coverage) and for the zero short-circuit in _check_display_precision.
Each Dexalot trading pair carries min_trade_amount and max_trade_amount bounds (quote-token denominated, i.e., notional in the quote currency). The contract rejects orders outside these bounds, but the SDK previously did nothing with the values — they were ingested into pair_data and never checked. Adds _check_trade_amount_bounds(price, amount, pair_data) and folds it into _normalize_order_amounts so all four CLOB write paths get the check for free without duplicating logic. Computes notional = price * amount in Decimal (precision-exact) and rejects when outside [min_trade_amount, max_trade_amount]. A bound of 0 means 'no bound' (some pairs legitimately omit a cap). Skips the check when price is None (market-order paths where the contract applies its own protection). Stores the bounds as Decimal in _store_clob_pairs so comparison against Decimal notional is exact (previously stored as float). Adds parametrized bound tests for the helper and a drift-guard test asserting all four write paths reject sub-min notional consistently. clob.py and input_validators.py remain at 100% line coverage.
transfer_portfolio, deposit, withdraw, get_deposit_bridge_fee, and transfer_token all encoded user-supplied amounts via int(amount * (10**decimals)), the same float-multiplication precision bug the CLOB write paths had. For example, int(2933.0 * 10**18) truncates to 2932999999999999737856 instead of 2933000000000000000000, which can cause downstream contract rejections. Routes the five sites through Utils.unit_conversion (Decimal-backed, already exists). No public API change — float inputs are still accepted and now produce precision-exact wei. Adds parametrized precision regression tests covering the reporter's 2933.0 case, USDC-style 6-decimal tokens, Decimal inputs, and numeric string inputs across all five paths. transfer.py is now at 100% line coverage.
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 large wei values. For balances above ~2^53 wei (anywhere near 10^16 for high-supply tokens), the last digits of the human-readable value drift. Routes the four sites through Utils.unit_conversion (Decimal-backed, to_base=False). Public API unchanged — the returned dict values are still float-typed. Adds a regression test asserting a 25-digit wei value converts back to its exact human equivalent. transfer.py remains at 100% coverage.
…ersion add_gas and remove_gas previously used w3.to_wei(amount, 'ether') for the ALOT→wei conversion. web3.py's to_wei is already Decimal-backed internally (calls Decimal(str(value))), so the behavior is identical — but the SDK now has its own Utils.unit_conversion helper that the other six transfer write paths use. Standardizing on it avoids the risk of someone future-refactoring with a less safe pattern (e.g. int(amount * 10**18)) because the surrounding code uses 'a different idiom anyway'. No behavior change; adds a parametrized precision test for add_gas asserting equivalence on a range of values including 1-wei. transfer.py remains at 100% line coverage.
…ounds Summarizes the behavioral contract introduced in C1-C12: - README.md: adds 'Amount Precision and Display Decimals' section covering accepted input types (int / float / Decimal / numeric str), the REJECT- with-tolerance display-decimal gate, min/max trade-amount enforcement, and the drop-on-missing-display-decimals ingest behavior. - CLAUDE.md: adds six new entries under 'Non-Obvious Decisions' so future Claude Code sessions understand why the SDK rejects extra precision instead of silently rounding, why pairs missing display decimals are dropped, etc. - docs/python-sdk-remediation-plan.md: adds 'Order Precision & Display- Decimals Hardening' section (O-1 through O-8) marking each item ✅ Resolved with its commit reference. - docs/python-sdk-user-guide.md: adds a precision callout to the add_order example pointing to the README contract. - .cursor/rules/precision-decimal-arithmetic.mdc: new rule pinning all SDK source files — never multiply floats by 10**N, always use Utils.unit_conversion or CLOBClient._to_wei; do not add silent-rounding shortcuts.
… CVEs pip-audit reported three vulnerabilities in transitive dependencies: - idna 3.11 — CVE-2026-45409 (fixed in 3.15) - urllib3 2.6.3 — PYSEC-2026-141 (fixed in 2.7.0) - urllib3 2.6.3 — PYSEC-2026-142 (fixed in 2.7.0) Both packages reach the SDK transitively (idna through aiohttp/web3 URL parsing; urllib3 through dev dependencies like twine and pip-audit itself). Adds them as direct constraints in pyproject.toml so uv pins the safe versions in the lockfile. After resolution: idna 3.16, urllib3 2.7.0. 'make security' is now green; no known vulnerabilities found. 'make test' / 'make lint' / 'make mypy' all pass.
Patch release covering this branch's precision and security fixes: - CLOB + TRANSFER amount→wei conversion via Decimal-backed Utils.unit_conversion (fixes T-TMDQ-01 rejections for inputs like 2933.0) - CLOB display-decimal precision REJECT-with-tolerance gate - replace_order missing-rounding regression - Drop+warn for pairs missing display decimals - validate_positive_number accepts Decimal / numeric str - Client-side min/max trade-amount enforcement - Transfer balance read paths use Decimal division - add_gas/remove_gas standardized on Utils.unit_conversion - pip-audit CVE pins: idna >= 3.15, urllib3 >= 2.7.0 VERSION, pyproject.toml, src/dexalot_sdk/__init__.py, and uv.lock all synced via scripts/version_manager.py bump patch + uv lock.
hsyndeniz
approved these changes
May 26, 2026
ilkerulutas
approved these changes
May 26, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Utils.unit_conversion(newCLOBClient._to_weihelper); fixesT-TMDQ-01rejections from inputs like2933.0whoseint(value * 10**18)silently truncated to...934464.replace_orderand consolidate all four CLOB write paths through_normalize_order_amountsso the gate cannot drift again; drift-guard test asserts identical wei acrossadd_order/add_limit_order_list/replace_order/cancel_add_list.0.1 + 0.2; anything coarser returnsResult.failso a stop at99.99cannot silently become99.9).basedisplaydecimals/quotedisplaydecimalsat ingest with a WARNING instead of defaulting to 18 (which would mask contract rejections).mintrade_amnt/maxtrade_amntclient-side as quote-token notional bounds in all four CLOB write paths.validate_positive_float→validate_positive_numberto acceptDecimaland numericstr(alias kept for one release); rejectsboolexplicitly.get_portfolio_balanceand_get_all_portfolio_balances_cachedwith Decimal-backed conversion so large wei balances round-trip exactly.add_gas/remove_gasonUtils.unit_conversionto keep the transfer module idiom consistent.idna >= 3.15andurllib3 >= 2.7.0to clear CVE-2026-45409, PYSEC-2026-141, PYSEC-2026-142 frompip-audit..cursor/rules/precision-decimal-arithmetic.mdc; user-guide precision callout.0.5.14 → 0.5.15.Quality Gates
core/clob.py100%,core/transfer.py100%,utils/input_validators.py100%)Behavioural changes
Result.fail(...)instead of silently rounded. Callers that relied on the SDK rounding internally must round explicitly before passing (or passDecimalfor full precision).[min_trade_amount, max_trade_amount]fail in the SDK before any on-chain submission instead of being rejected by the contract.client.pairs; downstream calls see "Pair X not found." A WARNING is logged at ingest.replace_ordernow applies the display-decimal precision gate (previously skipped it, so over-precise replacements wasted gas on contract rejection).