diff --git a/.gitignore b/.gitignore index 66b0951..9294986 100644 --- a/.gitignore +++ b/.gitignore @@ -49,3 +49,6 @@ site/ .env* !.env.example !env.example + +# Temporary planning docs (not for commit) +docs/*-plan.md diff --git a/CLAUDE.md b/CLAUDE.md index ea00260..fa7e3fc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -101,6 +101,11 @@ Unit tests in `tests/unit/` have no external dependencies. Integration tests in - **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. +- **Order enums have one home — `core/order_types.py`**: `Side`, `OrderType` (`MARKET`/`LIMIT` only), `TimeInForce` (`GTC`/`FOK`/`IOC`/`PO`), `SelfTradePrevention`, `OrderStatus` IntEnums with on-chain integer values, plus alias-aware parsers and `validate_order_combo`. Read paths (`_format_order_data`, `_transform_order_from_api`) and write paths share these maps via `enum_int_to_name` / `_resolve_order_modifiers`, so the integer↔label mapping cannot drift. Canonical output labels are the on-chain names (`PO`, not `POST_ONLY`); aliases are accepted on input only. +- **STOP/STOPLIMIT: reserved on-chain, unplaceable, but read-labelled**: the contract `Type1` enum is `{MARKET, LIMIT, STOP, STOPLIMIT}` (verified against `ITradePairs.sol`), but STOP/STOPLIMIT are unused — `NewOrder` has no trigger-price field and no pair enables them. So the write-side `OrderType` enum is `{MARKET, LIMIT}` (the SDK never originates a stop order) while the read map `ORDER_TYPE_NAMES` includes all four contract names so reads stay faithful. Genuinely-unknown integers still map to `"UNKNOWN()"`. +- **`time_in_force` / `stp` default to today's behavior**: `add_order` (and per-order dicts in `add_limit_order_list` / `cancel_add_list`) accept optional `time_in_force` (default `GTC`) and `stp` (default `CANCEL_TAKER`), replacing the previously hardcoded `type2=0` / `stp=0`. `validate_order_combo` enforces only the one contract-faithful rule — **LIMIT requires a price** — verified against `TradePairs.sol`: the contract ignores `type2`/price for MARKET (no revert), and PO / per-pair-allowed-type / self-trade / FOK rules are enforced on-chain (`T-T2PO-01`, `T-POOA-01`, `T-IVOT-01`, `T-FOKF-01`, `T-STPR-01`). Do **not** re-add a MARKET-must-be-IOC/FOK check — it is stricter than the contract and rejected the default (GTC) MARKET order. `type2`/`stp` integer values are confirmed against the contract enums. +- **MARKET BUY skips the pre-flight balance check**: without a price the quote-token notional is unknown, so `add_order` / `_build_order_tuple` / `_process_replacement` skip the balance check for a MARKET BUY and let the contract enforce it. MARKET SELL (base amount known) and all LIMIT paths keep the check. +- **`replace_order` cannot change order type/TIF/stp**: `cancelReplaceOrder` carries only a new price and quantity, so the replacement inherits the original `type1`/`type2`/`stp`. To change those, cancel and re-place (e.g. `cancel_add_list`). The method intentionally exposes no `time_in_force`/`stp` params. --- @@ -162,6 +167,7 @@ All paths relative to `src/dexalot_sdk/`. | Entry point | `core/client.py` | | Config | `core/config.py` | | Base client | `core/base.py` | +| Order-type enums/validation | `core/order_types.py` | | Caching | `utils/cache.py` | | Result type | `utils/result.py` | | Retry | `utils/retry.py` | diff --git a/README.md b/README.md index 42741cb..1215938 100644 --- a/README.md +++ b/README.md @@ -839,6 +839,73 @@ async def place_order_safely(client, pair, side, amount, price): See `examples/error_handling.py` for comprehensive error handling patterns. +## Order Types & Time-in-Force + +CLOB orders are described by three on-chain fields, all exposed through optional +parameters that default to today's behavior: + +- **`order_type`** (`type1`) — `"LIMIT"` (default) or `"MARKET"`. +- **`time_in_force`** (`type2`) — `"GTC"` (default), `"FOK"`, `"IOC"`, or `"PO"` + (Post-Only). Aliases such as `"POST_ONLY"`, `"FILL_OR_KILL"`, + `"IMMEDIATE_OR_CANCEL"` are accepted. +- **`stp`** (self-trade prevention) — `"CANCEL_TAKER"` (default), + `"CANCEL_MAKER"`, `"CANCEL_BOTH"`, or `"CANCEL_NONE"`. The contract spellings + (`"CANCELTAKER"`, `"CANCELMAKER"`, `"CANCELBOTH"`, `"NONE"`) are also accepted. + +> Stop / stop-limit orders cannot be **placed**: although the contract `Type1` +> enum reserves `STOP`/`STOPLIMIT`, they are unused on-chain (the order struct +> has no trigger-price field and no pair enables them), so `order_type` accepts +> only `MARKET`/`LIMIT`. Order *reads* still label `type1` 2/3 as +> `STOP`/`STOPLIMIT` for fidelity with the contract enum. + +### Validation + +The SDK only pre-validates the one rule the contract itself relies on — **a +`LIMIT` order requires a price** — and otherwise defers to the contract, which +matches its real behavior: + +- **`MARKET`** ignores `time_in_force` and any supplied price on-chain (no + revert), so the SDK does not constrain them. A MARKET order is typically + filled immediately. +- **`PO`** (Post-Only) is maker-only; a Post-Only order that would take reverts + `T-T2PO-01`, and a pair may require all orders be Post-Only (`T-POOA-01`). +- A pair may **disable** specific order types (e.g. MARKET) — those reverts + surface as `T-IVOT-01`. Other relevant reverts: `T-FOKF-01` (unfillable FOK), + `T-STPR-01` (self-trade prevented). + +### Examples + +```python +# Limit GTC (default) — unchanged from before +await client.add_order("AVAX/USDC", "BUY", 1.0, 25.0) + +# Limit, Immediate-or-Cancel +await client.add_order("AVAX/USDC", "BUY", 1.0, 25.0, time_in_force="IOC") + +# Post-Only (maker-only); reverts T-T2PO-01 if it would take +await client.add_order("AVAX/USDC", "SELL", 1.0, 25.0, time_in_force="PO") + +# Market BUY (no price). The pre-flight balance check is skipped for a +# MARKET BUY since the notional is unknown without a price. +await client.add_order("AVAX/USDC", "BUY", 1.0, None, order_type="MARKET") + +# Self-trade prevention: cancel the resting (maker) order on a self-match +await client.add_order("AVAX/USDC", "BUY", 1.0, 25.0, stp="CANCEL_MAKER") + +# Batch with mixed types (add_order_list is an alias for add_limit_order_list) +await client.add_order_list([ + {"pair": "AVAX/USDC", "side": "BUY", "amount": 1.0, "price": 24.0, + "time_in_force": "PO"}, + {"pair": "AVAX/USDC", "side": "SELL", "amount": 1.0, + "order_type": "MARKET", "time_in_force": "IOC"}, +]) +``` + +**Replacing orders:** `replace_order` uses the contract's `cancelReplaceOrder`, +which carries only a new price and quantity — the replacement **inherits** the +original order's `order_type`, `time_in_force`, and `stp`. To change those, +cancel and place a new order (e.g. via `cancel_add_list`). + ## Transaction Receipt Handling All state-changing operations (placing orders, deposits, withdrawals, etc.) now support a `wait_for_receipt` parameter that controls whether the SDK waits for blockchain transaction confirmation before returning. @@ -889,8 +956,8 @@ Use `wait_for_receipt=False` when: All state-changing methods support `wait_for_receipt`: **CLOB Operations:** -- `add_order(pair, side, amount, price, order_type="LIMIT", client_order_id=None, wait_for_receipt=True)` -- `add_limit_order_list(orders, wait_for_receipt=True)` +- `add_order(pair, side, amount, price, order_type="LIMIT", client_order_id=None, time_in_force="GTC", stp="CANCEL_TAKER", wait_for_receipt=True)` +- `add_limit_order_list(orders, wait_for_receipt=True)` (alias: `add_order_list`) - `cancel_order(order_id, wait_for_receipt=True)` - `cancel_order_by_client_id(client_order_id, wait_for_receipt=True)` - `cancel_list_orders(order_ids, wait_for_receipt=True)` diff --git a/VERSION b/VERSION index 3afb327..a918a2a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.5.16 +0.6.0 diff --git a/docs/python-sdk-user-guide.md b/docs/python-sdk-user-guide.md index a90052e..9b3c8fe 100644 --- a/docs/python-sdk-user-guide.md +++ b/docs/python-sdk-user-guide.md @@ -44,7 +44,7 @@ Prefer passing a pre-built `eth_account.Account` object to the constructor inste ## Core concepts -### Result[T] — result-first operational API +### Result\[T\] — result-first operational API Async operational methods return a `Result` object. Expected failures such as network errors, validation errors, and contract reverts are returned as `Result.fail(...)`. Some configuration or programmer errors can still raise immediately, so always check `.success` before accessing `.data` on Result-returning calls: @@ -176,7 +176,7 @@ 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. +> **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 the [README](https://github.com/Dexalot/dexalot-sdk-python/blob/main/README.md) for the full contract. ### Cancel an order diff --git a/docs/sdk-caching.md b/docs/sdk-caching.md index 8e98cf1..785227f 100644 --- a/docs/sdk-caching.md +++ b/docs/sdk-caching.md @@ -388,5 +388,5 @@ The cache has a maximum size of 256 entries per level. If you're concerned about ## See Also -- [Python SDK README](../python/dexalot-sdk/README.md) - Python SDK main documentation -- [TypeScript SDK README](../typescript/dexalot-sdk/README.md) - TypeScript SDK main documentation +- [Python SDK README](https://github.com/Dexalot/dexalot-sdk-python/blob/main/README.md) - Python SDK main documentation +- [TypeScript SDK README](https://github.com/Dexalot/dexalot-sdk-typescript/blob/main/README.md) - TypeScript SDK main documentation diff --git a/docs/websocket.md b/docs/websocket.md index 8445e17..7eb4dfb 100644 --- a/docs/websocket.md +++ b/docs/websocket.md @@ -74,7 +74,7 @@ Example trader event subscribe message : {"type":"tradereventsubscribe", "signature":"0xXXXXXXXXXXXXXXXXXX:0xXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"} ``` To generate signature you can use the sample code provided in RestApi Signed Endpoints. -* [Rest Api](/en/apiv2/RestApi.md) +* [Rest Api](rest-api.md) Sample code unsubscribe: ```ts diff --git a/pyproject.toml b/pyproject.toml index 2af98be..3cc031b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ license = "MIT" license-files = [ "LICENSE.txt" ] -version = "0.5.16" +version = "0.6.0" description = "Dexalot Python SDK - Core library for Dexalot interaction" readme = "README.md" requires-python = ">=3.12,<3.15" diff --git a/src/dexalot_sdk/__init__.py b/src/dexalot_sdk/__init__.py index 5fa290a..dbdb837 100644 --- a/src/dexalot_sdk/__init__.py +++ b/src/dexalot_sdk/__init__.py @@ -12,7 +12,7 @@ secrets_vault_set, ) -__version__ = "0.5.16" +__version__ = "0.6.0" def get_version() -> str: diff --git a/src/dexalot_sdk/core/clob.py b/src/dexalot_sdk/core/clob.py index 333debd..813e0f6 100644 --- a/src/dexalot_sdk/core/clob.py +++ b/src/dexalot_sdk/core/clob.py @@ -26,6 +26,19 @@ from ..utils.retry import async_retry from ..utils.websocket_manager import WebSocketManager from .base import _BALANCE_CACHE, _ORDERBOOK_CACHE, _SEMI_STATIC_CACHE, DexalotBaseClient +from .order_types import ( + ORDER_STATUS_NAMES, + ORDER_TYPE_NAMES, + SIDE_NAMES, + TIME_IN_FORCE_NAMES, + OrderType, + Side, + enum_int_to_name, + parse_order_type, + parse_stp, + parse_time_in_force, + validate_order_combo, +) _logger = logging.getLogger("dexalot_sdk") @@ -453,6 +466,8 @@ async def add_order( order_type: str = "LIMIT", wait_for_receipt: bool = True, client_order_id: str | None = None, + time_in_force: str = "GTC", + stp: str = "CANCEL_TAKER", ) -> Result[dict]: """Place a single limit or market order on the CLOB. @@ -464,17 +479,28 @@ async def add_order( side: ``"BUY"`` or ``"SELL"`` (case-insensitive). amount: Order quantity in base-token units (human-readable). price: Limit price in quote-token units. Required for ``"LIMIT"`` - orders; ignored for ``"MARKET"`` orders. + orders; must be ``None`` / ``0`` for ``"MARKET"`` orders. order_type: ``"LIMIT"`` (default) or ``"MARKET"``. wait_for_receipt: If ``True``, block until the transaction is confirmed on-chain and return the receipt status. client_order_id: Optional 32-byte hex string (``"0x"`` + 64 hex chars) to use as the client order identifier. When omitted, a random ID is generated. + time_in_force: Time-in-force / execution modifier — ``"GTC"`` + (default), ``"FOK"``, ``"IOC"``, or ``"PO"`` (Post-Only). + Aliases such as ``"POST_ONLY"`` are accepted. MARKET orders + must use ``"IOC"`` or ``"FOK"``. + stp: Self-trade-prevention mode — ``"CANCEL_TAKER"`` (default), + ``"CANCEL_MAKER"``, ``"CANCEL_BOTH"``, or ``"CANCEL_NONE"``. Returns: Result containing ``{"status": str, "tx_hash": str, "client_order_id": str}`` on success, or an error message on failure. + + Note: + For a MARKET BUY the notional is unknown without a price, so the + pre-flight balance check is skipped and the contract enforces its + own protection. All other paths keep the pre-flight check. """ if not self.account: return Result.fail("Private key not configured.") @@ -505,6 +531,16 @@ async def add_order( if validation_error is not None: return cast(Result[dict[Any, Any]], validation_error) + # Resolve time-in-force and self-trade-prevention, then check the + # (type1, type2, price) combination client-side before sending. + mods_res = self._resolve_order_modifiers( + type_enum, time_in_force, stp, has_price=bool(price) + ) + if not mods_res.success: + return cast(Result[dict[Any, Any]], mods_res) + assert mods_res.data is not None + type2_enum, stp_enum = mods_res.data + # 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) @@ -513,13 +549,15 @@ async def add_order( 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"] - required_amount = (price * amount) if side_enum == 0 else amount - - balance_error = await self._check_order_balance(required_token, required_amount) - if balance_error is not None: - return cast(Result[dict[Any, Any]], balance_error) + # Portfolio balance check. Skipped for a MARKET BUY: without a + # price the quote-token notional is unknown, so the contract + # enforces the balance protection instead. + if not (type_enum == OrderType.MARKET and side_enum == Side.BUY): + required_token = pair_data["quote"] if side_enum == 0 else pair_data["base"] + required_amount = (price * amount) if side_enum == 0 else amount + balance_error = await self._check_order_balance(required_token, required_amount) + if balance_error is not None: + return cast(Result[dict[Any, Any]], balance_error) # Decimals (Decimal-backed; never multiply floats by 10**N here) price_wei = self._to_wei(price, pair_data["quote_decimals"]) if price else 0 @@ -546,8 +584,8 @@ async def add_order( "traderaddress": from_addr, "side": side_enum, "type1": type_enum, - "type2": 0, # GTC - "stp": 0, # Cancel Newest + "type2": type2_enum, + "stp": stp_enum, } # Estimate gas, build, sign, send, wait for receipt @@ -905,10 +943,13 @@ def _coerce_optional_order_block(self, value: object, field_name: str) -> int | return self._coerce_order_block(value, field_name) def _enum_to_name(self, value: object, mapping: dict[int, str]) -> object: - """Normalize enum integers from contract/API reads into string labels.""" - if isinstance(value, int): - return mapping.get(value, value) - return value + """Normalize enum integers from contract/API reads into string labels. + + Delegates to :func:`order_types.enum_int_to_name` so the read paths and + write paths share one mapping. Unknown integers become an explicit + ``"UNKNOWN()"`` sentinel rather than a fabricated label. + """ + return enum_int_to_name(value, mapping) def _to_hex_identifier(self, value: object) -> object: """Convert bytes-like identifiers to hex strings while preserving strings.""" @@ -1047,24 +1088,13 @@ def _transform_order_from_api(self, order: dict) -> dict: quantity_filled=self._coerce_order_numeric(filled_qty), total_fee=self._coerce_order_numeric(total_fee), trader_address=trader_address, - side=self._enum_to_name(order.get("side"), {0: "BUY", 1: "SELL"}), + side=self._enum_to_name(order.get("side"), SIDE_NAMES), type1=self._enum_to_name( order.get("type1") if order.get("type1") is not None else order.get("type"), - {0: "MARKET", 1: "LIMIT", 2: "STOP", 3: "STOPLIMIT"}, - ), - type2=self._enum_to_name(order.get("type2"), {0: "GTC", 1: "FOK", 2: "IOC", 3: "PO"}), - status=self._enum_to_name( - order.get("status"), - { - 0: "NEW", - 1: "REJECTED", - 2: "PARTIAL", - 3: "FILLED", - 4: "CANCELED", - 5: "EXPIRED", - 6: "KILLED", - }, + ORDER_TYPE_NAMES, ), + type2=self._enum_to_name(order.get("type2"), TIME_IN_FORCE_NAMES), + status=self._enum_to_name(order.get("status"), ORDER_STATUS_NAMES), update_block=update_block, create_block=create_block, create_ts=create_ts, @@ -1424,23 +1454,10 @@ async def _format_order_data(self, order_data) -> Result[dict]: quantity_filled=quantity_filled, total_fee=total_fee, trader_address=w3_l1.to_checksum_address(order_data[8]), - side=self._enum_to_name(order_data[9], {0: "BUY", 1: "SELL"}), - type1=self._enum_to_name( - order_data[10], {0: "MARKET", 1: "LIMIT", 2: "STOP", 3: "STOPLIMIT"} - ), - type2=self._enum_to_name(order_data[11], {0: "GTC", 1: "FOK", 2: "IOC", 3: "PO"}), - status=self._enum_to_name( - order_data[12], - { - 0: "NEW", - 1: "REJECTED", - 2: "PARTIAL", - 3: "FILLED", - 4: "CANCELED", - 5: "EXPIRED", - 6: "KILLED", - }, - ), + side=self._enum_to_name(order_data[9], SIDE_NAMES), + type1=self._enum_to_name(order_data[10], ORDER_TYPE_NAMES), + type2=self._enum_to_name(order_data[11], TIME_IN_FORCE_NAMES), + status=self._enum_to_name(order_data[12], ORDER_STATUS_NAMES), update_block=order_data[13], create_block=order_data[14], ) @@ -1483,6 +1500,28 @@ def _validate_order_params(self, side, order_type, price, type_enum): return side_enum, type_enum, None + def _resolve_order_modifiers( + self, type1_enum: int, time_in_force: object, stp: object, has_price: bool + ) -> "Result[tuple[int, int]]": + """Resolve and validate (time_in_force, stp) for an order. + + Returns ``Result.ok((type2_enum, stp_enum))`` or a failure describing + the invalid modifier / combination. Shared by every write path so the + order-type matrix is enforced uniformly. + """ + tif_res = parse_time_in_force(time_in_force) + if not tif_res.success: + return cast("Result[tuple[int, int]]", tif_res) + type2_enum = cast(int, tif_res.data) + stp_res = parse_stp(stp) + if not stp_res.success: + return cast("Result[tuple[int, int]]", stp_res) + stp_enum = cast(int, stp_res.data) + combo_res = validate_order_combo(type1_enum, type2_enum, has_price=has_price) + if not combo_res.success: + return cast("Result[tuple[int, int]]", combo_res) + return Result.ok((type2_enum, stp_enum)) + async def _check_order_balance(self, required_token, required_amount): """Check if sufficient balance exists for an order.""" balance_info = await cast(Any, self).get_portfolio_balance(required_token) @@ -1640,21 +1679,43 @@ def _to_wei(value: Any, decimals: int) -> int: def _build_order_tuple( self, order, pair_data, side_enum, w3, client_order_id_bytes: bytes | None = None ) -> Result[tuple]: - """Build order tuple for contract call. + """Build a NewOrder tuple for a batch contract call. + + Honours the per-order ``order_type`` (default LIMIT), ``time_in_force`` + (default GTC) and ``stp`` (default CANCEL_TAKER) keys, validating the + combination client-side. - Returns ``Result.ok((order_tuple, client_order_id_hex))`` or - ``Result.fail(msg)`` when price/amount precision exceeds the pair's - display decimals. + Returns ``Result.ok((order_tuple, client_order_id_hex, req_token, + req_amt))`` where ``req_amt`` is the quote/base balance the order + requires, or ``None`` when the pre-flight balance check should be + skipped (a MARKET BUY, whose notional is unknown without a price). + Returns ``Result.fail(msg)`` on an invalid type/precision/combination. """ import secrets - norm_res = self._normalize_order_amounts(order["price"], order["amount"], pair_data) + ot_res = parse_order_type(order.get("order_type", "LIMIT")) + if not ot_res.success: + return cast(Result[tuple], ot_res) + type1_enum = cast(int, ot_res.data) + + norm_res = self._normalize_order_amounts(order.get("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 = self._to_wei(price, pair_data["quote_decimals"]) + mods_res = self._resolve_order_modifiers( + type1_enum, + order.get("time_in_force", "GTC"), + order.get("stp", "CANCEL_TAKER"), + has_price=bool(price), + ) + if not mods_res.success: + return cast(Result[tuple], mods_res) + assert mods_res.data is not None + type2_enum, stp_enum = mods_res.data + + price_wei = self._to_wei(price, pair_data["quote_decimals"]) if price else 0 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) @@ -1670,12 +1731,22 @@ def _build_order_tuple( qty_wei, trader, side_enum, - 1, # LIMIT - 0, # GTC - 0, # STP + type1_enum, + type2_enum, + stp_enum, ) - return Result.ok((order_tuple, client_order_id_hex)) + # Balance requirement. Skipped for a MARKET BUY: notional is unknown + # without a price, so the contract enforces the protection instead. + if type1_enum == OrderType.MARKET and side_enum == Side.BUY: + req_token, req_amt = None, None + elif side_enum == 0: # BUY (non-market): notional = price * amount + assert price is not None + req_token, req_amt = pair_data["quote"], float(price * amount) + else: # SELL: requirement is the base amount + req_token, req_amt = pair_data["base"], float(amount) + + return Result.ok((order_tuple, client_order_id_hex, req_token, req_amt)) async def _check_balance_for_token(self, token, req_amt): """Check if sufficient balance exists for a token. @@ -1721,18 +1792,10 @@ async def _process_orders_for_batch(self, orders, w3): pair_data = self.pairs[pair] - side_enum, req_token, error = self._parse_order_side(order["side"], pair_data) + side_enum, _req_token, error = self._parse_order_side(order["side"], pair_data) if error: return None, None, None, error - # Calculate required amount - if side_enum == 0: # BUY - req_amt = order["price"] * order["amount"] - else: # SELL - req_amt = order["amount"] - - required_balances[req_token] = required_balances.get(req_token, 0) + req_amt - # Use caller-provided client_order_id or let _build_order_tuple generate one cid_bytes: bytes | None = None if raw_cid := order.get("client_order_id"): @@ -1747,7 +1810,9 @@ async def _process_orders_for_batch(self, orders, w3): 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_tuple, client_order_id_hex, req_token, req_amt = build_res.data + if req_amt is not None: + required_balances[req_token] = required_balances.get(req_token, 0) + req_amt order_tuples.append(order_tuple) client_order_ids.append(client_order_id_hex) @@ -1757,16 +1822,27 @@ async def _process_orders_for_batch(self, orders, w3): async def add_limit_order_list( self, orders: list[dict], wait_for_receipt: bool = True ) -> Result[dict]: - """Place multiple limit orders in a single on-chain transaction. + """Place multiple orders in a single on-chain transaction. - Checks aggregated portfolio balances across all orders before submitting. + Despite the name this is not limited to LIMIT orders — each order may + set its own type via the optional keys below. Checks aggregated + portfolio balances across all orders before submitting (a MARKET BUY + is excluded from the pre-flight check; see :meth:`add_order`). Args: orders: List of order dicts, each with: - ``pair`` (str): Trading pair, e.g. ``"AVAX/USDC"``. - ``side`` (str): ``"BUY"`` or ``"SELL"``. - ``amount`` (float): Quantity in base-token units. - - ``price`` (float): Limit price in quote-token units. + - ``price`` (float): Limit price in quote-token units + (omit / ``None`` for MARKET orders). + - ``order_type`` (str, optional): ``"LIMIT"`` (default) or + ``"MARKET"``. + - ``time_in_force`` (str, optional): ``"GTC"`` (default), + ``"FOK"``, ``"IOC"`` or ``"PO"``. + - ``stp`` (str, optional): self-trade-prevention mode + (default ``"CANCEL_TAKER"``). + - ``client_order_id`` (str, optional): 32-byte hex string. wait_for_receipt: If ``True``, block until the transaction is confirmed on-chain. @@ -1822,6 +1898,21 @@ async def add_limit_order_list( error_msg = self._sanitize_error(e, "placing batch orders") return Result.fail(error_msg) + async def add_order_list( + self, orders: list[dict], wait_for_receipt: bool = True + ) -> Result[dict]: + """Alias for :meth:`add_limit_order_list`. + + The batch path accepts mixed order types (per-order ``order_type`` / + ``time_in_force`` / ``stp``), so this name reads more accurately than + the historical ``add_limit_order_list``, which is retained for + backward compatibility. + """ + return cast( + Result[dict], + await self.add_limit_order_list(orders, wait_for_receipt=wait_for_receipt), + ) + @track_method("clob") async def cancel_list_orders( self, order_ids: list, wait_for_receipt: bool = True @@ -1885,6 +1976,14 @@ async def replace_order( Uses the contract's ``cancelReplaceOrder`` function. Fetches the existing order to determine the pair and decimal precision. + .. note:: + ``cancelReplaceOrder`` only carries a new price and quantity, so the + replacement **inherits the original order's** ``type1``, + ``time_in_force`` (``type2``) and ``stp``. This method therefore + exposes no time-in-force / stp parameters. To change those, cancel + the order and place a new one (e.g. via :meth:`cancel_add_list`, + which builds a fresh order tuple). + Args: order_id: Identifier of the order to replace (hex string, plain string, or ``bytes32``). Accepts either internal or client @@ -2124,20 +2223,49 @@ async def _process_replacement( ) side_clean = raw_side.strip().upper() if side_clean == "BUY": - side_enum, req_token, req_amt = 0, pair_data["quote"], rep["price"] * rep["amount"] + side_enum = 0 elif side_clean == "SELL": - side_enum, req_token, req_amt = 1, pair_data["base"], rep["amount"] + side_enum = 1 else: return Result.fail(f"Invalid side '{rep['side']}'. Must be 'BUY' or 'SELL'.") - required_balances[req_token] = required_balances.get(req_token, 0) + req_amt + ot_res = parse_order_type(rep.get("order_type", "LIMIT")) + if not ot_res.success: + return cast(Result[dict], ot_res) + type1_enum = cast(int, ot_res.data) - norm_res = self._normalize_order_amounts(rep["price"], rep["amount"], pair_data) + norm_res = self._normalize_order_amounts(rep.get("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 + mods_res = self._resolve_order_modifiers( + type1_enum, + rep.get("time_in_force", "GTC"), + rep.get("stp", "CANCEL_TAKER"), + has_price=bool(price), + ) + if not mods_res.success: + return cast(Result[dict], mods_res) + assert mods_res.data is not None + type2_enum, stp_enum = mods_res.data + + # Track required balance (informational; cancel_add_list relies on the + # contract revert for funds). Skipped for a MARKET BUY — notional is + # unknown without a price. + if type1_enum == OrderType.MARKET and side_enum == Side.BUY: + pass + elif side_enum == 0: # BUY (non-market) + assert price is not None + required_balances[pair_data["quote"]] = required_balances.get( + pair_data["quote"], 0 + ) + float(price * amount) + else: # SELL + required_balances[pair_data["base"]] = required_balances.get( + pair_data["base"], 0 + ) + float(amount) + if raw_cid := rep.get("client_order_id"): cid_result = validate_order_id_format(raw_cid, "client_order_id") if not cid_result.success: @@ -2149,13 +2277,13 @@ async def _process_replacement( new_order_tuple = ( client_order_id, pair_data["tradePairId"], - self._to_wei(price, pair_data["quote_decimals"]), + self._to_wei(price, pair_data["quote_decimals"]) if price else 0, self._to_wei(amount, pair_data["base_decimals"]), from_addr, side_enum, - 1, # LIMIT - 0, # GTC - 0, # STP + type1_enum, + type2_enum, + stp_enum, ) return Result.ok( { @@ -2181,7 +2309,14 @@ async def cancel_add_list( - ``pair`` (str): Trading pair, e.g. ``"AVAX/USDC"``. - ``side`` (str): ``"BUY"`` or ``"SELL"``. - ``amount`` (float): New quantity in base-token units. - - ``price`` (float): New limit price in quote-token units. + - ``price`` (float): New limit price in quote-token units + (omit / ``None`` for MARKET orders). + - ``order_type`` (str, optional): ``"LIMIT"`` (default) or + ``"MARKET"``. + - ``time_in_force`` (str, optional): ``"GTC"`` (default), + ``"FOK"``, ``"IOC"`` or ``"PO"``. + - ``stp`` (str, optional): self-trade-prevention mode + (default ``"CANCEL_TAKER"``). - ``client_order_id`` (str, optional): 32-byte hex string for the new replacement order. When omitted, a random ID is generated. diff --git a/src/dexalot_sdk/core/order_types.py b/src/dexalot_sdk/core/order_types.py new file mode 100644 index 0000000..34681e8 --- /dev/null +++ b/src/dexalot_sdk/core/order_types.py @@ -0,0 +1,217 @@ +"""CLOB order-type domain model. + +Single source of truth for the on-chain order enums (``side``, ``type1``, +``type2``, ``stp``, order ``status``) and the rules governing valid order-type +combinations. Both the write paths (placing orders) and the read paths +(formatting orders returned by the contract / REST API) route through this +module so the integer<->label mapping cannot drift between them. + +On-chain enum values (from the ``TradePairs`` contract) are authoritative: + +* ``side`` -- 0 BUY, 1 SELL +* ``type1`` -- 0 MARKET, 1 LIMIT (the contract enum has no STOP/STOPLIMIT + members and the ``NewOrder`` struct carries no trigger-price field, so stop + orders are neither defined nor encodable -- see the SDK docs) +* ``type2`` -- 0 GTC, 1 FOK, 2 IOC, 3 PO (time-in-force) +* ``stp`` -- self-trade prevention (see :class:`SelfTradePrevention`) +* ``status`` -- 0 NEW .. 6 KILLED + +.. note:: + The ``stp`` integer<->name mapping and the valid ``type1`` x ``type2`` + matrix encode the SDK's working specification. They are intentionally + centralised here so a single edit corrects every call site if the contract + team confirms different semantics. +""" + +from __future__ import annotations + +from enum import IntEnum + +from ..utils.result import Result + + +class Side(IntEnum): + """Order side (``side`` field).""" + + BUY = 0 + SELL = 1 + + +class OrderType(IntEnum): + """Order type (``type1`` field) the SDK can *place*. + + The contract ``Type1`` enum is ``{MARKET, LIMIT, STOP, STOPLIMIT}``, but + ``STOP``/``STOPLIMIT`` are reserved/unused on-chain (no trigger-price field + in ``NewOrder``, never added to a pair's allowed order types). This + write-side enum therefore intentionally omits them so the SDK never + originates a stop order. Read-side labelling still recognises them — see + :data:`ORDER_TYPE_NAMES`. + """ + + MARKET = 0 + LIMIT = 1 + + +class TimeInForce(IntEnum): + """Time-in-force / execution modifier (``type2`` field).""" + + GTC = 0 # Good-Till-Cancelled + FOK = 1 # Fill-Or-Kill + IOC = 2 # Immediate-Or-Cancel + PO = 3 # Post-Only (maker-only; rejected if it would cross) + + +class SelfTradePrevention(IntEnum): + """Self-trade prevention mode (``stp`` field). + + Integer values confirmed against the contract ``STP`` enum + (``CANCELTAKER, CANCELMAKER, CANCELBOTH, NONE``). Canonical names here use + readable underscored spellings; the bare contract spellings are accepted as + input aliases. + """ + + CANCEL_TAKER = 0 # cancel the incoming (taker) order + CANCEL_MAKER = 1 # cancel the resting (maker) order + CANCEL_BOTH = 2 + CANCEL_NONE = 3 # NONE on-chain: do not cancel; allow the self-trade + + +class OrderStatus(IntEnum): + """Order status as reported by the contract / API.""" + + NEW = 0 + REJECTED = 1 + PARTIAL = 2 + FILLED = 3 + CANCELED = 4 + EXPIRED = 5 + KILLED = 6 + + +# --- int -> canonical label maps (read paths) ------------------------------ + +SIDE_NAMES: dict[int, str] = {m.value: m.name for m in Side} +# Read-side type1 labels mirror the full contract Type1 enum, including the +# reserved STOP/STOPLIMIT members. The SDK never *places* those (the write-side +# OrderType enum omits them), but a read should faithfully reflect any value the +# contract could report rather than mislabel it as UNKNOWN. +ORDER_TYPE_NAMES: dict[int, str] = {0: "MARKET", 1: "LIMIT", 2: "STOP", 3: "STOPLIMIT"} +TIME_IN_FORCE_NAMES: dict[int, str] = {m.value: m.name for m in TimeInForce} +STP_NAMES: dict[int, str] = {m.value: m.name for m in SelfTradePrevention} +ORDER_STATUS_NAMES: dict[int, str] = {m.value: m.name for m in OrderStatus} + + +# --- name/alias -> int maps (write paths) ---------------------------------- + +_SIDE_ALIASES: dict[str, int] = {"B": Side.BUY, "S": Side.SELL} + +_ORDER_TYPE_ALIASES: dict[str, int] = {} + +_TIME_IN_FORCE_ALIASES: dict[str, int] = { + "GOOD_TILL_CANCEL": TimeInForce.GTC, + "GOOD_TILL_CANCELLED": TimeInForce.GTC, + "GOOD_TILL_CANCELED": TimeInForce.GTC, + "FILL_OR_KILL": TimeInForce.FOK, + "IMMEDIATE_OR_CANCEL": TimeInForce.IOC, + "POST_ONLY": TimeInForce.PO, + "POSTONLY": TimeInForce.PO, +} + +_STP_ALIASES: dict[str, int] = { + # Contract spellings (ITradePairs.sol enum STP): no underscores, bare NONE. + "CANCELTAKER": SelfTradePrevention.CANCEL_TAKER, + "CANCELMAKER": SelfTradePrevention.CANCEL_MAKER, + "CANCELBOTH": SelfTradePrevention.CANCEL_BOTH, + # Readable aliases. + "CANCEL_NEWEST": SelfTradePrevention.CANCEL_TAKER, + "CANCEL_OLDEST": SelfTradePrevention.CANCEL_MAKER, + "DO_NOT_CANCEL": SelfTradePrevention.CANCEL_NONE, + "NONE": SelfTradePrevention.CANCEL_NONE, +} + + +def enum_int_to_name(value: object, names: dict[int, str]) -> object: + """Normalize an enum integer from a contract/API read into a string label. + + Integers present in ``names`` map to their canonical label. Integers + absent from ``names`` map to an explicit ``"UNKNOWN()"`` sentinel rather + than a fabricated label, so an unexpected on-chain value is visible (and + signals the SDK needs updating) instead of being silently mislabelled. + Non-integer values (e.g. labels already normalized upstream) pass through + unchanged. + """ + if isinstance(value, bool): # bool is an int subclass; treat as passthrough + return value + if isinstance(value, int): + label = names.get(value) + return label if label is not None else f"UNKNOWN({value})" + return value + + +def _parse_enum( + value: object, + enum_cls: type[IntEnum], + aliases: dict[str, int], + field: str, +) -> Result[int]: + """Resolve ``value`` (enum member, int, or case-insensitive name/alias) to int.""" + if isinstance(value, bool): + return Result.fail(f"Invalid {field}: {value!r}") + if isinstance(value, enum_cls): + return Result.ok(int(value)) + if isinstance(value, int): + try: + return Result.ok(int(enum_cls(value))) + except ValueError: + valid = ", ".join(str(int(m)) for m in enum_cls) + return Result.fail(f"Invalid {field} {value!r}. Must be one of: {valid}.") + if isinstance(value, str): + key = value.strip().upper() + if key in enum_cls.__members__: + return Result.ok(int(enum_cls[key])) + if key in aliases: + return Result.ok(int(aliases[key])) + valid = ", ".join(enum_cls.__members__) + return Result.fail(f"Invalid {field} '{value}'. Must be one of: {valid}.") + return Result.fail(f"Invalid {field}: expected name or int, got {type(value).__name__}.") + + +def parse_side(value: object) -> Result[int]: + """Resolve a side (``"BUY"``/``"SELL"``, alias, int, or :class:`Side`) to int.""" + return _parse_enum(value, Side, _SIDE_ALIASES, "side") + + +def parse_order_type(value: object) -> Result[int]: + """Resolve an order type (``"MARKET"``/``"LIMIT"``, int, or :class:`OrderType`) to int.""" + return _parse_enum(value, OrderType, _ORDER_TYPE_ALIASES, "order type") + + +def parse_time_in_force(value: object) -> Result[int]: + """Resolve a time-in-force (``"GTC"``/``"FOK"``/``"IOC"``/``"PO"``, alias, int) to int.""" + return _parse_enum(value, TimeInForce, _TIME_IN_FORCE_ALIASES, "time_in_force") + + +def parse_stp(value: object) -> Result[int]: + """Resolve a self-trade-prevention mode (name, alias, int, or enum) to int.""" + return _parse_enum(value, SelfTradePrevention, _STP_ALIASES, "stp") + + +def validate_order_combo(type1: int, type2: int, has_price: bool) -> Result[None]: + """Validate a (``type1``, ``type2``, price-presence) combination client-side. + + Only the one constraint the contract itself relies on is enforced here: + + * LIMIT orders require a price. + + Everything else is left to the contract, matching its actual behavior + (verified against ``TradePairs.sol``): MARKET orders ignore ``type2`` and + any supplied price (no revert); per-pair enabled order types, Post-Only and + self-trade rules are enforced on-chain and surface as reverts (``T-IVOT-01`` + for a disabled type, ``T-POOA-01`` for a Post-Only-only pair, ``T-T2PO-01`` + when a Post-Only order would take, ``T-FOKF-01`` for an unfillable FOK). + The SDK does not pre-reject those — doing so was stricter than the contract + and rejected valid orders (e.g. a default MARKET order, which is GTC). + """ + if type1 == OrderType.LIMIT and not has_price: + return Result.fail("LIMIT orders require a price.") + return Result.ok(None) diff --git a/tests/unit/core/test_clob.py b/tests/unit/core/test_clob.py index 51fce08..df0d375 100644 --- a/tests/unit/core/test_clob.py +++ b/tests/unit/core/test_clob.py @@ -562,6 +562,101 @@ async def test_add_order_caller_provided_client_order_id(self, client): ) assert not bad_res.success + @pytest.fixture + def order_client(self, client): + """A client wired with a standard pair and mocked tx/balance for order tests.""" + from dexalot_sdk.utils.result import Result + + client.pairs = { + "AVAX/USDC": { + "pair": "AVAX/USDC", + "base": "AVAX", + "quote": "USDC", + "base_decimals": 18, + "quote_decimals": 6, + "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": 1_000_000.0})) + return client + + async def test_add_order_default_tif_and_stp(self, order_client): + """Default call still encodes GTC (type2=0) and CANCEL_TAKER (stp=0).""" + res = await order_client.add_order("AVAX/USDC", "BUY", 1.0, 10.0) + assert res.success + struct = order_client.trade_pairs_contract.functions.addNewOrder.call_args[0][0] + assert struct["type1"] == 1 # LIMIT + assert struct["type2"] == 0 # GTC + assert struct["stp"] == 0 # CANCEL_TAKER + + @pytest.mark.parametrize( + "tif,expected_type2", + [("GTC", 0), ("FOK", 1), ("IOC", 2), ("PO", 3), ("post_only", 3)], + ) + async def test_add_order_time_in_force(self, order_client, tif, expected_type2): + """Each time-in-force (and alias) reaches the struct as the right int.""" + res = await order_client.add_order("AVAX/USDC", "SELL", 1.0, 10.0, time_in_force=tif) + assert res.success + struct = order_client.trade_pairs_contract.functions.addNewOrder.call_args[0][0] + assert struct["type2"] == expected_type2 + + @pytest.mark.parametrize( + "stp,expected", [("CANCEL_TAKER", 0), ("CANCEL_MAKER", 1), ("CANCEL_BOTH", 2), ("NONE", 3)] + ) + async def test_add_order_stp(self, order_client, stp, expected): + """Self-trade-prevention mode reaches the struct as the right int.""" + res = await order_client.add_order("AVAX/USDC", "SELL", 1.0, 10.0, stp=stp) + assert res.success + struct = order_client.trade_pairs_contract.functions.addNewOrder.call_args[0][0] + assert struct["stp"] == expected + + async def test_add_order_market_ioc_skips_buy_balance_check(self, order_client): + """MARKET BUY: type1=0, price=0, and the pre-flight balance check is skipped.""" + res = await order_client.add_order( + "AVAX/USDC", "BUY", 1.0, None, order_type="MARKET", time_in_force="IOC" + ) + assert res.success + struct = order_client.trade_pairs_contract.functions.addNewOrder.call_args[0][0] + assert struct["type1"] == 0 # MARKET + assert struct["type2"] == 2 # IOC + assert struct["price"] == 0 + order_client.get_portfolio_balance.assert_not_called() + + async def test_add_order_market_sell_still_checks_balance(self, order_client): + """MARKET SELL keeps the pre-flight balance check (base amount is known).""" + res = await order_client.add_order( + "AVAX/USDC", "SELL", 1.0, None, order_type="MARKET", time_in_force="FOK" + ) + assert res.success + order_client.get_portfolio_balance.assert_called() + + async def test_add_order_market_is_permissive(self, order_client): + """MARKET ignores type2/price on-chain, so the SDK no longer pre-rejects.""" + # Default MARKET (GTC) is accepted and sent. + r1 = await order_client.add_order("AVAX/USDC", "BUY", 1.0, None, order_type="MARKET") + assert r1.success + struct = order_client.trade_pairs_contract.functions.addNewOrder.call_args[0][0] + assert struct["type1"] == 0 # MARKET + + # A price on a MARKET order is accepted (contract ignores it). + r2 = await order_client.add_order( + "AVAX/USDC", "BUY", 1.0, 10.0, order_type="MARKET", time_in_force="IOC" + ) + assert r2.success + + async def test_add_order_rejects_unparseable_modifiers(self, order_client): + """Unknown time_in_force / stp are still rejected before any tx is sent.""" + r1 = await order_client.add_order("AVAX/USDC", "BUY", 1.0, 10.0, time_in_force="FAST") + assert not r1.success and "time_in_force" in r1.error + + r2 = await order_client.add_order("AVAX/USDC", "BUY", 1.0, 10.0, stp="BOGUS") + assert not r2.success and "stp" in r2.error + + order_client.trade_pairs_contract.functions.addNewOrder.assert_not_called() + async def test_add_limit_order_list_caller_provided_client_order_id(self, client): """Per-order caller-provided client_order_id is used; invalid hex is rejected.""" client.pairs = { @@ -677,6 +772,233 @@ async def test_cancel_add_list_caller_provided_client_order_id(self, client): bad_res = await client.cancel_add_list(bad_replacements) assert not bad_res.success + async def test_add_order_list_mixed_types_and_tif(self, client): + """Batch path honours per-order order_type/time_in_force/stp.""" + client.pairs = { + "AVAX/USDC": { + "pair": "AVAX/USDC", + "base": "AVAX", + "quote": "USDC", + "base_decimals": 18, + "quote_decimals": 6, + "tradePairId": b"TPID", + } + } + client._send_trade_tx = AsyncMock(return_value=("0xTxHash", MagicMock(status=1))) + client.get_portfolio_balance = AsyncMock(return_value={"available": 1_000.0}) + + orders = [ + { + "pair": "AVAX/USDC", + "side": "BUY", + "amount": 1.0, + "price": 10.0, + "time_in_force": "PO", + }, + { + "pair": "AVAX/USDC", + "side": "SELL", + "amount": 1.0, + "order_type": "MARKET", + "time_in_force": "IOC", + "stp": "CANCEL_BOTH", + }, + ] + # add_order_list is the alias for add_limit_order_list. + res = await client.add_order_list(orders) + assert res.success + + tuples = client.trade_pairs_contract.functions.addOrderList.call_args[0][0] + # tuple layout: (..., side, type1, type2, stp) + limit_po, market_ioc = tuples + assert limit_po[6] == 1 and limit_po[7] == 3 # LIMIT, PO + assert market_ioc[6] == 0 and market_ioc[7] == 2 and market_ioc[8] == 2 # MARKET, IOC, BOTH + assert market_ioc[2] == 0 # market price encoded as 0 + + async def test_add_order_list_rejects_limit_without_price(self, client): + """A LIMIT entry missing a price fails the whole batch before sending.""" + client.pairs = { + "AVAX/USDC": { + "pair": "AVAX/USDC", + "base": "AVAX", + "quote": "USDC", + "base_decimals": 18, + "quote_decimals": 6, + "tradePairId": b"TPID", + } + } + client._send_trade_tx = AsyncMock(return_value=("0xTxHash", MagicMock(status=1))) + client.get_portfolio_balance = AsyncMock(return_value={"available": 1_000.0}) + + orders = [ + {"pair": "AVAX/USDC", "side": "BUY", "amount": 1.0, "price": 10.0}, + # LIMIT without a price is invalid (the one rule still enforced) + {"pair": "AVAX/USDC", "side": "SELL", "amount": 1.0}, + ] + res = await client.add_order_list(orders) + assert not res.success and "LIMIT" in res.error + client.trade_pairs_contract.functions.addOrderList.assert_not_called() + + async def test_cancel_add_list_honours_tif_and_stp(self, client): + """cancel_add_list builds replacement tuples with the requested TIF/stp.""" + client.pairs = { + "AVAX/USDC": { + "pair": "AVAX/USDC", + "base_decimals": 18, + "quote_decimals": 6, + "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))) + + replacements = [ + { + "order_id": "0x01", + "amount": 1.0, + "price": 11.0, + "pair": "AVAX/USDC", + "side": "BUY", + "time_in_force": "IOC", + "stp": "CANCEL_MAKER", + }, + ] + res = await client.cancel_add_list(replacements) + assert res.success + _ids, new_orders = client.trade_pairs_contract.functions.cancelAddList.call_args[0] + tup = new_orders[0] + assert tup[6] == 1 and tup[7] == 2 and tup[8] == 1 # LIMIT, IOC, CANCEL_MAKER + + async def test_add_order_rejects_invalid_stp(self, order_client): + """An invalid stp is rejected before any transaction is sent.""" + res = await order_client.add_order("AVAX/USDC", "BUY", 1.0, 10.0, stp="BOGUS") + assert not res.success and "stp" in res.error + order_client.trade_pairs_contract.functions.addNewOrder.assert_not_called() + + async def test_add_order_list_market_buy_skips_balance(self, client): + """A MARKET BUY in a batch encodes price 0 and skips the balance check.""" + client.pairs = { + "AVAX/USDC": { + "pair": "AVAX/USDC", + "base": "AVAX", + "quote": "USDC", + "base_decimals": 18, + "quote_decimals": 6, + "tradePairId": b"TPID", + } + } + client._send_trade_tx = AsyncMock(return_value=("0xTxHash", MagicMock(status=1))) + client.get_portfolio_balance = AsyncMock(return_value={"available": 1_000.0}) + + orders = [ + { + "pair": "AVAX/USDC", + "side": "BUY", + "amount": 1.0, + "order_type": "MARKET", + "time_in_force": "IOC", + }, + ] + res = await client.add_order_list(orders) + assert res.success + tup = client.trade_pairs_contract.functions.addOrderList.call_args[0][0][0] + assert tup[6] == 0 and tup[2] == 0 # MARKET, price encoded as 0 + client.get_portfolio_balance.assert_not_called() + + async def test_add_order_list_rejects_invalid_order_type(self, client): + """An unknown per-order order_type fails the batch before sending.""" + client.pairs = { + "AVAX/USDC": { + "pair": "AVAX/USDC", + "base": "AVAX", + "quote": "USDC", + "base_decimals": 18, + "quote_decimals": 6, + "tradePairId": b"TPID", + } + } + client._send_trade_tx = AsyncMock(return_value=("0xTxHash", MagicMock(status=1))) + client.get_portfolio_balance = AsyncMock(return_value={"available": 1_000.0}) + + orders = [ + { + "pair": "AVAX/USDC", + "side": "BUY", + "amount": 1.0, + "price": 10.0, + "order_type": "STOP", + }, + ] + res = await client.add_order_list(orders) + assert not res.success and "order type" in res.error + client.trade_pairs_contract.functions.addOrderList.assert_not_called() + + async def test_cancel_add_list_market_buy_and_invalid_modifiers(self, client): + """cancel_add_list: MARKET BUY skips price; invalid type/TIF are rejected.""" + client.pairs = { + "AVAX/USDC": { + "pair": "AVAX/USDC", + "base_decimals": 18, + "quote_decimals": 6, + "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))) + + # MARKET BUY: no price, encoded as 0 + ok = await client.cancel_add_list( + [ + { + "order_id": "0x01", + "amount": 1.0, + "pair": "AVAX/USDC", + "side": "BUY", + "order_type": "MARKET", + "time_in_force": "IOC", + } + ] + ) + assert ok.success + tup = client.trade_pairs_contract.functions.cancelAddList.call_args[0][1][0] + assert tup[6] == 0 and tup[2] == 0 # MARKET, price 0 + + # Invalid order_type + bad_type = await client.cancel_add_list( + [ + { + "order_id": "0x01", + "amount": 1.0, + "price": 11.0, + "pair": "AVAX/USDC", + "side": "BUY", + "order_type": "STOP", + } + ] + ) + assert not bad_type.success and "order type" in bad_type.error + + # Invalid time_in_force + bad_tif = await client.cancel_add_list( + [ + { + "order_id": "0x01", + "amount": 1.0, + "price": 11.0, + "pair": "AVAX/USDC", + "side": "BUY", + "time_in_force": "FAST", + } + ] + ) + assert not bad_tif.success and "time_in_force" in bad_tif.error + async def test_cancel_order(self, client): """Test cancel_order.""" self._stub_resolved_order(client, id_type="internal") @@ -2900,7 +3222,8 @@ async def test_clob_missing_coverage_5(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, 1, order_type="MARKET") + # MARKET orders carry no price and must be IOC/FOK. + await client.add_order(VALID_PAIR, "BUY", 1, None, order_type="MARKET", time_in_force="IOC") call_args = client.trade_pairs_contract.functions.addNewOrder.call_args[0][0] assert call_args["type1"] == 0 diff --git a/tests/unit/core/test_order_types.py b/tests/unit/core/test_order_types.py new file mode 100644 index 0000000..3136faf --- /dev/null +++ b/tests/unit/core/test_order_types.py @@ -0,0 +1,213 @@ +"""Unit tests for the CLOB order-type domain model.""" + +import json +import os + +import pytest + +from dexalot_sdk.core.base import DexalotBaseClient +from dexalot_sdk.core.order_types import ( + ORDER_STATUS_NAMES, + ORDER_TYPE_NAMES, + SIDE_NAMES, + STP_NAMES, + TIME_IN_FORCE_NAMES, + OrderType, + SelfTradePrevention, + Side, + TimeInForce, + enum_int_to_name, + parse_order_type, + parse_side, + parse_stp, + parse_time_in_force, + validate_order_combo, +) +from dexalot_sdk.utils.error_sanitizer import sanitize_error_message + + +class TestEnumValues: + """On-chain integer values must never drift.""" + + def test_side_values(self): + assert (Side.BUY, Side.SELL) == (0, 1) + + def test_write_enum_excludes_stop_but_read_map_includes(self): + # The write-side enum places MARKET/LIMIT only (SDK never originates a + # stop order), but the read map mirrors the full contract Type1 enum. + assert {m.name: m.value for m in OrderType} == {"MARKET": 0, "LIMIT": 1} + assert "STOP" not in OrderType.__members__ + assert "STOPLIMIT" not in OrderType.__members__ + assert ORDER_TYPE_NAMES[2] == "STOP" + assert ORDER_TYPE_NAMES[3] == "STOPLIMIT" + + def test_time_in_force_values(self): + assert {m.name: m.value for m in TimeInForce} == { + "GTC": 0, + "FOK": 1, + "IOC": 2, + "PO": 3, + } + + def test_stp_values(self): + assert {m.name: m.value for m in SelfTradePrevention} == { + "CANCEL_TAKER": 0, + "CANCEL_MAKER": 1, + "CANCEL_BOTH": 2, + "CANCEL_NONE": 3, + } + + +class TestEnumIntToName: + def test_known_values_map_to_labels(self): + assert enum_int_to_name(0, SIDE_NAMES) == "BUY" + assert enum_int_to_name(1, ORDER_TYPE_NAMES) == "LIMIT" + # STOP/STOPLIMIT are real contract Type1 members on the read side. + assert enum_int_to_name(2, ORDER_TYPE_NAMES) == "STOP" + assert enum_int_to_name(3, ORDER_TYPE_NAMES) == "STOPLIMIT" + assert enum_int_to_name(3, TIME_IN_FORCE_NAMES) == "PO" + assert enum_int_to_name(6, ORDER_STATUS_NAMES) == "KILLED" + assert enum_int_to_name(2, STP_NAMES) == "CANCEL_BOTH" + + def test_unknown_int_maps_to_sentinel(self): + # Genuinely-unknown integers map to a visible sentinel, not a guess. + assert enum_int_to_name(7, ORDER_TYPE_NAMES) == "UNKNOWN(7)" + assert enum_int_to_name(99, TIME_IN_FORCE_NAMES) == "UNKNOWN(99)" + + def test_non_int_passthrough(self): + assert enum_int_to_name("LIMIT", ORDER_TYPE_NAMES) == "LIMIT" + assert enum_int_to_name(None, ORDER_TYPE_NAMES) is None + + def test_bool_passthrough(self): + assert enum_int_to_name(True, ORDER_TYPE_NAMES) is True + + +class TestParsers: + @pytest.mark.parametrize( + "value,expected", + [ + ("BUY", 0), + ("sell", 1), + ("b", 0), + ("S", 1), + (Side.BUY, 0), + (1, 1), + ], + ) + def test_parse_side(self, value, expected): + res = parse_side(value) + assert res.success and res.data == expected + + @pytest.mark.parametrize( + "value,expected", + [("MARKET", 0), ("limit", 1), (OrderType.MARKET, 0), (1, 1)], + ) + def test_parse_order_type(self, value, expected): + res = parse_order_type(value) + assert res.success and res.data == expected + + @pytest.mark.parametrize( + "value,expected", + [ + ("GTC", 0), + ("fok", 1), + ("IOC", 2), + ("PO", 3), + ("POST_ONLY", 3), + ("post_only", 3), + ("FILL_OR_KILL", 1), + ("IMMEDIATE_OR_CANCEL", 2), + ("GOOD_TILL_CANCEL", 0), + (TimeInForce.IOC, 2), + (3, 3), + ], + ) + def test_parse_time_in_force_aliases(self, value, expected): + res = parse_time_in_force(value) + assert res.success and res.data == expected + + @pytest.mark.parametrize( + "value,expected", + [ + ("CANCEL_TAKER", 0), + ("CANCEL_NEWEST", 0), + ("CANCEL_OLDEST", 1), + ("DO_NOT_CANCEL", 3), + ("NONE", 3), + # contract spellings (no underscores) + ("CANCELTAKER", 0), + ("CANCELMAKER", 1), + ("CANCELBOTH", 2), + (SelfTradePrevention.CANCEL_BOTH, 2), + (1, 1), + ], + ) + def test_parse_stp_aliases(self, value, expected): + res = parse_stp(value) + assert res.success and res.data == expected + + def test_parse_rejects_unknown_name(self): + assert not parse_time_in_force("FAST").success + assert not parse_order_type("STOP").success + assert not parse_side("HOLD").success + + def test_parse_rejects_out_of_range_int(self): + assert not parse_order_type(2).success + assert not parse_time_in_force(9).success + + def test_parse_rejects_bool_and_wrong_type(self): + assert not parse_side(True).success + assert not parse_time_in_force(1.5).success + + +class TestValidateOrderCombo: + def test_limit_requires_price(self): + assert validate_order_combo(OrderType.LIMIT, TimeInForce.GTC, has_price=True).success + assert not validate_order_combo(OrderType.LIMIT, TimeInForce.GTC, has_price=False).success + + def test_market_is_permissive(self): + # Contract ignores type2/price for MARKET (no revert); SDK matches that. + assert validate_order_combo(OrderType.MARKET, TimeInForce.GTC, has_price=False).success + assert validate_order_combo(OrderType.MARKET, TimeInForce.PO, has_price=False).success + assert validate_order_combo(OrderType.MARKET, TimeInForce.IOC, has_price=True).success + + def test_limit_post_only_allowed(self): + assert validate_order_combo(OrderType.LIMIT, TimeInForce.PO, has_price=True).success + + +class TestOrderTypeRevertCodes: + """Order-type reverts surface a friendly description with the code intact.""" + + @pytest.fixture(scope="class") + def error_codes(self): + path = os.path.join( + os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(__file__)))), + "src", + "dexalot_sdk", + "core", + "errors.json", + ) + with open(path) as f: + return json.load(f) + + @pytest.mark.parametrize( + "code", + ["T-POOA-01", "T-T2PO-01", "T-STPR-01", "T-FOKF-01"], + ) + def test_codes_present_with_descriptions(self, error_codes, code): + assert code in error_codes + assert error_codes[code].strip() + + def test_parse_revert_reason_maps_code(self, error_codes): + # _parse_revert_reason only reads self.error_codes; call it on a stub. + stub = type("Stub", (), {"error_codes": error_codes})() + raw = "execution reverted: T-FOKF-01" + parsed = DexalotBaseClient._parse_revert_reason(stub, raw) + assert parsed.startswith("T-FOKF-01:") + assert "FOK" in parsed + + def test_sanitizer_preserves_code(self): + # Sanitization strips paths/URLs but must keep the T-XXXX-NN code. + msg = "T-STPR-01: TradePairs: order cancled due to self trade prevention" + out = sanitize_error_message(msg, "placing order") + assert "T-STPR-01" in out diff --git a/uv.lock b/uv.lock index 01ee80d..b957b38 100644 --- a/uv.lock +++ b/uv.lock @@ -788,7 +788,7 @@ wheels = [ [[package]] name = "dexalot-sdk" -version = "0.5.16" +version = "0.6.0" source = { editable = "." } dependencies = [ { name = "aiohttp" },