Skip to content

fix: precision-safe amount/price handling across CLOB and TRANSFER#8

Merged
ngurmen merged 15 commits into
mainfrom
mng/order-precision-and-display-decimals
May 26, 2026
Merged

fix: precision-safe amount/price handling across CLOB and TRANSFER#8
ngurmen merged 15 commits into
mainfrom
mng/order-precision-and-display-decimals

Conversation

@ngurmen
Copy link
Copy Markdown
Contributor

@ngurmen ngurmen commented May 26, 2026

Summary

  • Route every CLOB and TRANSFER amount/price encoding through Decimal-backed Utils.unit_conversion (new CLOBClient._to_wei helper); fixes T-TMDQ-01 rejections from inputs like 2933.0 whose int(value * 10**18) silently truncated to ...934464.
  • Add missing display-decimal precision check to replace_order and consolidate all four CLOB write paths through _normalize_order_amounts so the gate cannot drift again; drift-guard test asserts identical wei across add_order / add_limit_order_list / replace_order / cancel_add_list.
  • Switch the display-decimal gate from silent rounding to REJECT-with-tolerance (1e-10 band absorbs binary-float noise like 0.1 + 0.2; anything coarser returns Result.fail so a stop at 99.99 cannot silently become 99.9).
  • Drop pairs missing basedisplaydecimals/quotedisplaydecimals at ingest with a WARNING instead of defaulting to 18 (which would mask contract rejections).
  • Enforce mintrade_amnt / maxtrade_amnt client-side as quote-token notional bounds in all four CLOB write paths.
  • Extend validate_positive_floatvalidate_positive_number to accept Decimal and numeric str (alias kept for one release); rejects bool explicitly.
  • Replace float division in get_portfolio_balance and _get_all_portfolio_balances_cached with Decimal-backed conversion so large wei balances round-trip exactly.
  • Standardize add_gas / remove_gas on Utils.unit_conversion to keep the transfer module idiom consistent.
  • Pin idna >= 3.15 and urllib3 >= 2.7.0 to clear CVE-2026-45409, PYSEC-2026-141, PYSEC-2026-142 from pip-audit.
  • Docs: README "Amount Precision and Display Decimals" section; six new CLAUDE.md non-obvious-decisions entries; remediation-plan O-1 through O-8; .cursor/rules/precision-decimal-arithmetic.mdc; user-guide precision callout.
  • Bump version 0.5.14 → 0.5.15.

Quality Gates

  • make test (963 passed, +112 new)
  • make lint (ruff clean)
  • make mypy (strict, clean)
  • make security (bandit + pip-audit, no known vulnerabilities)
  • make cov (core/clob.py 100%, core/transfer.py 100%, utils/input_validators.py 100%)

Behavioural changes

  • Inputs whose precision exceeds the pair's display decimals are now rejected with Result.fail(...) instead of silently rounded. Callers that relied on the SDK rounding internally must round explicitly before passing (or pass Decimal for full precision).
  • Orders outside [min_trade_amount, max_trade_amount] fail in the SDK before any on-chain submission instead of being rejected by the contract.
  • Pairs whose API record omits display decimals are no longer present in client.pairs; downstream calls see "Pair X not found." A WARNING is logged at ingest.
  • replace_order now applies the display-decimal precision gate (previously skipped it, so over-precise replacements wasted gas on contract rejection).

ngurmen added 15 commits May 26, 2026 21:54
…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.
@ngurmen ngurmen requested review from hsyndeniz and ilkerulutas May 26, 2026 20:25
@ngurmen ngurmen merged commit 997dd07 into main May 26, 2026
3 checks passed
@ngurmen ngurmen deleted the mng/order-precision-and-display-decimals branch May 26, 2026 20:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants