Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .cursor/rules/precision-decimal-arithmetic.mdc
Original file line number Diff line number Diff line change
@@ -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`.
5 changes: 5 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---

Expand Down
53 changes: 53 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.5.14
0.5.15
152 changes: 152 additions & 0 deletions docs/python-sdk-remediation-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions docs/python-sdk-user-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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]
Expand Down
2 changes: 1 addition & 1 deletion src/dexalot_sdk/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
secrets_vault_set,
)

__version__ = "0.5.14"
__version__ = "0.5.15"


def get_version() -> str:
Expand Down
Loading
Loading