From b23cecdc436393c9c0a5319791e33d90983e2c3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=BCseyin=20Deniz?= Date: Tue, 2 Jun 2026 20:47:13 +0300 Subject: [PATCH 01/10] feat(base): preserve REST error bodies (reason_code + reason) in _api_call Backend reason codes (FQ-, P-, T-, RF-) now surface in Result.fail messages instead of being swallowed as generic HTTP status errors. Mirrors TypeScript SDK commit bbabf19. Co-Authored-By: Claude Opus 4.7 --- src/dexalot_sdk/core/base.py | 40 ++++++++++ tests/unit/core/test_base.py | 138 +++++++++++++++++++++++++++++++++++ 2 files changed, 178 insertions(+) diff --git a/src/dexalot_sdk/core/base.py b/src/dexalot_sdk/core/base.py index deb7062..598dfc3 100644 --- a/src/dexalot_sdk/core/base.py +++ b/src/dexalot_sdk/core/base.py @@ -828,6 +828,46 @@ async def _do_request(): else: return cast(aiohttp.ClientResponse, await _do_request()) + async def _api_call(self, method: str, url: str, **kwargs: Any) -> Any: + """Make a REST API call and return its parsed JSON body. + + On failure, lifts backend ``reasonCode`` + ``reason`` from the + response body (when present) into the raised exception's message. + The Dexalot REST API encodes failures as + ``{"reasonCode": "FQ-015", "reason": "..."}`` (also tolerates the + snake_case ``reason_code`` and the ``message`` alias that some + endpoints emit). Without this lift the raw aiohttp message + collapses everything to ``"Request failed with status code N"``, + which swallows the backend-level reason and makes user-visible + ``Result.fail`` strings useless for diagnosis. + + Network-level failures (no response) and non-HTTP exceptions are + propagated unchanged. + """ + async with await self._make_http_request(method, url, **kwargs) as response: + if response.status >= 400: + body: Any = None + try: + body = await response.json(content_type=None) + except Exception: + body = None + if isinstance(body, dict): + reason_code = body.get("reasonCode") or body.get("reason_code") + reason = body.get("reason") or body.get("message") + if isinstance(reason_code, str): + tail = ( + reason + if isinstance(reason, str) + else f"Request failed with status code {response.status}" + ) + raise RuntimeError(f"{reason_code}: {tail}") + if isinstance(reason, str): + raise RuntimeError(reason) + # Empty body, non-dict body, or parse failure — fall through + # to the generic aiohttp error. + response.raise_for_status() + return await response.json(content_type=None) + def _find_chain_for_provider(self, w3: AsyncWeb3) -> str | None: """ Find which chain a provider belongs to (for backwards compatibility). diff --git a/tests/unit/core/test_base.py b/tests/unit/core/test_base.py index f7499d7..ac37132 100644 --- a/tests/unit/core/test_base.py +++ b/tests/unit/core/test_base.py @@ -2871,3 +2871,141 @@ async def test_rpc_call_with_failover_no_provider_manager_raises(self, client): client._provider_manager = None with pytest.raises(RuntimeError, match="Provider manager is required"): await client._rpc_call_with_failover("Avalanche", "eth.get_block", "latest") + + # -------------------------------------------------------------------- # + # _api_call — REST error body preservation # + # -------------------------------------------------------------------- # + # The Dexalot REST API encodes failures as + # ``{"reasonCode": "...", "reason": "..."}`` (also tolerates the + # snake_case ``reason_code`` and the ``message`` alias that some + # endpoints emit). ``_api_call`` must lift these into the raised + # exception so callers' ``Result.fail`` strings surface the backend + # reason instead of collapsing to ``"... HTTP status N"``. + + @staticmethod + def _http_error_response(status: int, body): + """Build a context-manager mock that yields a response with the given body.""" + + async def _json(content_type=None): + if isinstance(body, Exception): + raise body + return body + + mock_resp = AsyncMock() + mock_resp.status = status + mock_resp.json = _json + + if status >= 400: + import aiohttp + + def _raise(): + raise aiohttp.ClientResponseError( + request_info=MagicMock(), + history=(), + status=status, + message=f"Request failed with status code {status}", + ) + + mock_resp.raise_for_status = MagicMock(side_effect=_raise) + else: + mock_resp.raise_for_status = MagicMock() + + mock_cm = AsyncMock() + mock_cm.__aenter__.return_value = mock_resp + mock_cm.__aexit__.return_value = None + return mock_cm + + async def test_api_call_lifts_reason_code_and_reason(self, client): + """_api_call surfaces backend reasonCode + reason from response body.""" + cm = self._http_error_response( + 400, {"reasonCode": "FQ-015", "reason": "insufficient liquidity"} + ) + with patch.object(client, "_make_http_request", AsyncMock(return_value=cm)): + with pytest.raises(RuntimeError, match=r"^FQ-015: insufficient liquidity$"): + await client._api_call("get", "https://api/x") + + async def test_api_call_lifts_snake_case_aliases(self, client): + """``reason_code`` + ``message`` aliases are also lifted.""" + cm = self._http_error_response( + 400, {"reason_code": "T-TMDQ-01", "message": "amount too small"} + ) + with patch.object(client, "_make_http_request", AsyncMock(return_value=cm)): + with pytest.raises(RuntimeError, match=r"^T-TMDQ-01: amount too small$"): + await client._api_call("get", "https://api/x") + + async def test_api_call_reason_code_without_reason_uses_generic_tail(self, client): + """When reasonCode is set but reason is missing, fall back to a generic tail.""" + cm = self._http_error_response(500, {"reasonCode": "P-OK01"}) + with patch.object(client, "_make_http_request", AsyncMock(return_value=cm)): + with pytest.raises(RuntimeError, match=r"^P-OK01: Request failed with status code 500$"): + await client._api_call("get", "https://api/x") + + async def test_api_call_reason_alone_without_reason_code(self, client): + """Reason alone (no reasonCode) is used as the full message.""" + cm = self._http_error_response(400, {"reason": "something else"}) + with patch.object(client, "_make_http_request", AsyncMock(return_value=cm)): + with pytest.raises(RuntimeError, match=r"^something else$"): + await client._api_call("get", "https://api/x") + + async def test_api_call_message_alias_alone(self, client): + """``message`` alias alone (no reasonCode) is used as the full message.""" + cm = self._http_error_response(400, {"message": "plain reason from message field"}) + with patch.object(client, "_make_http_request", AsyncMock(return_value=cm)): + with pytest.raises(RuntimeError, match=r"^plain reason from message field$"): + await client._api_call("get", "https://api/x") + + async def test_api_call_empty_body_falls_through_to_aiohttp_error(self, client): + """Empty ``{}`` body falls through to the generic aiohttp error.""" + import aiohttp + + cm = self._http_error_response(500, {}) + with patch.object(client, "_make_http_request", AsyncMock(return_value=cm)): + with pytest.raises(aiohttp.ClientResponseError): + await client._api_call("get", "https://api/x") + + async def test_api_call_non_dict_body_falls_through(self, client): + """HTML / non-JSON-object body falls through to the generic aiohttp error.""" + import aiohttp + + cm = self._http_error_response(502, "502 Bad Gateway") + with patch.object(client, "_make_http_request", AsyncMock(return_value=cm)): + with pytest.raises(aiohttp.ClientResponseError): + await client._api_call("get", "https://api/x") + + async def test_api_call_body_parse_failure_falls_through(self, client): + """When body parsing raises, fall through to the generic aiohttp error.""" + import aiohttp + + cm = self._http_error_response(502, ValueError("not json")) + with patch.object(client, "_make_http_request", AsyncMock(return_value=cm)): + with pytest.raises(aiohttp.ClientResponseError): + await client._api_call("get", "https://api/x") + + async def test_api_call_network_error_propagates(self, client): + """Network-level failure (no response) propagates unchanged.""" + import aiohttp + + with patch.object( + client, + "_make_http_request", + AsyncMock(side_effect=aiohttp.ClientConnectionError("Network Error")), + ): + with pytest.raises(aiohttp.ClientConnectionError, match="Network Error"): + await client._api_call("get", "https://api/x") + + async def test_api_call_non_http_error_propagates(self, client): + """Non-HTTP exceptions raised inside the request propagate unchanged.""" + with patch.object( + client, + "_make_http_request", + AsyncMock(side_effect=RuntimeError("generic non-http failure")), + ): + with pytest.raises(RuntimeError, match="generic non-http failure"): + await client._api_call("get", "https://api/x") + + async def test_api_call_success_returns_json(self, client): + """Happy path: returns parsed JSON body.""" + cm = self._http_error_response(200, [{"id": 1}]) + with patch.object(client, "_make_http_request", AsyncMock(return_value=cm)): + data = await client._api_call("get", "https://api/x") + assert data == [{"id": 1}] From 63721d52f4dfac50198dee7322110f669625b360 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=BCseyin=20Deniz?= Date: Tue, 2 Jun 2026 20:55:01 +0300 Subject: [PATCH 02/10] feat(base): extend get_deployment with env/contract_type/return_abi filters Backward-compatible. No-args call still resolves with defaults. Cache key includes filter params so variants don't collide. Mirrors TypeScript SDK commit 14265ad. Co-Authored-By: Claude Opus 4.7 --- src/dexalot_sdk/core/base.py | 138 ++++++--------- tests/unit/core/test_base.py | 335 ++++++++++++++--------------------- 2 files changed, 194 insertions(+), 279 deletions(-) diff --git a/src/dexalot_sdk/core/base.py b/src/dexalot_sdk/core/base.py index 598dfc3..3cece33 100644 --- a/src/dexalot_sdk/core/base.py +++ b/src/dexalot_sdk/core/base.py @@ -1,5 +1,4 @@ import asyncio -import copy import json import logging import os @@ -1715,94 +1714,73 @@ async def get_tokens(self) -> Result[list]: return Result.fail(error_msg) @async_ttl_cached(_STATIC_CACHE) - async def get_deployment(self) -> Result[dict]: - """Fetch contract deployment configuration (addresses and ABIs) from the API. - - Populates and returns ``self.deployments`` with entries for - ``TradePairs``, ``PortfolioMain``, ``PortfolioSub``, and ``MainnetRFQ``. - Also wires up on-chain contract instances (``trade_pairs_contract``, - ``portfolio_sub_contract``, ``portfolio_main_avax_contract``). + async def get_deployment( + self, + *, + env: str | None = None, + contract_type: str = "All", + return_abi: bool = True, + ) -> Result[list[Any]]: + """Fetch deployment configuration from the REST API. + + Backward-compatible: no-args call still resolves successfully and + uses defaults (``env=config.parent_env``, ``contract_type='All'``, + ``return_abi=True``). The backend expects lowercase query param + names (``contracttype``, ``returnabi``). + + Cached for 1 hour (static cache tier). The cache key includes + all three resolved params so filter variants do not collide on + the same cache slot. Note: - Cached for 1 hour (static cache tier). + This method no longer mutates ``self.deployments`` or the + contract handles. Those are populated by + ``initialize_client`` / ``reinitialize`` via the internal + ``_fetch_deployments`` helper. Mirrors TypeScript SDK + commit 14265ad. + + Args: + env: Parent environment (e.g. ``'fuji-multi'``). Defaults to + ``self.parent_env``. + contract_type: One of ``'All'``, ``'Portfolio'``, + ``'TradePairs'``, ``'MainnetRFQ'``, ``'PortfolioMain'``, + ``'PortfolioSub'``, ``'OrderBooks'``. Defaults to ``'All'``. + return_abi: Whether the backend should include ABI payloads. + Defaults to ``True``. Returns: - Result containing the ``deployments`` dictionary on success, or an - error message on failure. + ``Result.ok(list)`` of raw deployment entries on success, + ``Result.fail(msg)`` on REST or network failure. """ + resolved_env = env if env is not None else self.parent_env + if not self._cache_enabled: - # Bypass cache by clearing it for this call - key: tuple[Any, ...] = ("get_deployment", (self,), frozenset()) - _STATIC_CACHE._store.pop(key, None) + # Bypass cache by clearing this call's slot + cache_key: tuple[Any, ...] = ( + "get_deployment", + self.api_base_url, + (), + frozenset( + { + "env": resolved_env, + "contract_type": contract_type, + "return_abi": return_abi, + }.items() + ), + ) + _STATIC_CACHE._store.pop(cache_key, None) try: - # Ensure environments are fetched first (needed for w3_l1, w3_connected_chain) - if not self.chain_config: - envs_result = await self.get_environments() - if not envs_result.success: - return Result.fail(f"Failed to fetch environments: {envs_result.error}") - - # Always fetch fresh from API (cache decorator handles TTL) - # Rebuild deployments dict from API - deploy_url = f"{self.api_base_url}{ENDPOINT_TRADING_DEPLOYMENT}" - - # Initialize deployments structure if not already initialized - if not self.deployments: - self.deployments = { - "TradePairs": {}, - "PortfolioMain": {}, - "PortfolioSub": {}, - "MainnetRFQ": {}, - } - else: - # Clear existing deployments to rebuild fresh - self.deployments = { - "TradePairs": {}, - "PortfolioMain": {}, - "PortfolioSub": {}, - "MainnetRFQ": {}, - } - - # Fetch all contract types in parallel - await asyncio.gather( - self._fetch_contract_deployment(deploy_url, "TradePairs"), - self._fetch_contract_deployment(deploy_url, "Portfolio"), - self._fetch_contract_deployment(deploy_url, "MainnetRFQ"), + data = await self._api_call( + "get", + f"{self.api_base_url}{ENDPOINT_TRADING_DEPLOYMENT}", + params={ + "env": resolved_env, + "contracttype": contract_type, + "returnabi": "true" if return_abi else "false", + }, ) - - return Result.ok(self.deployments) + return Result.ok(data) except Exception as e: error_msg = self._sanitize_error(e, "getting deployment") return Result.fail(error_msg) - - def _apply_deployment_state(self, deployments: dict[str, Any]) -> None: - """Restore deployment mappings and contract handles from cached data.""" - self.deployments = copy.deepcopy(deployments) - if self.w3_l1 and self.deployments.get("TradePairs"): - trade_pairs = self.deployments["TradePairs"] - if trade_pairs.get("address") and trade_pairs.get("abi") is not None: - self.trade_pairs_contract = self.w3_l1.eth.contract( - address=trade_pairs["address"], abi=trade_pairs["abi"] - ) - if self.w3_l1 and self.deployments.get("PortfolioSub"): - portfolio_sub = self.deployments["PortfolioSub"] - if portfolio_sub.get("address") and portfolio_sub.get("abi") is not None: - self.portfolio_sub_contract = self.w3_l1.eth.contract( - address=portfolio_sub["address"], abi=portfolio_sub["abi"] - ) - if self.w3_connected_chain and self.deployments.get("PortfolioMain", {}).get("Avalanche"): - portfolio_main = self.deployments["PortfolioMain"]["Avalanche"] - if portfolio_main.get("address") and portfolio_main.get("abi") is not None: - self.portfolio_main_avax_contract = self.w3_connected_chain.eth.contract( - address=portfolio_main["address"], abi=portfolio_main["abi"] - ) - - async def _rehydrate_cached_get_deployment(self, cached: Result[dict]) -> None: - """Restore deployment state when ``get_deployment`` is served from cache.""" - if not cached.success or cached.data is None: - return - if not self.chain_config or not self.w3_l1: - envs_result = await self.get_environments() - if not envs_result.success: - return - self._apply_deployment_state(cached.data) diff --git a/tests/unit/core/test_base.py b/tests/unit/core/test_base.py index ac37132..252bce5 100644 --- a/tests/unit/core/test_base.py +++ b/tests/unit/core/test_base.py @@ -638,71 +638,47 @@ async def test_get_chains_error(self, client): assert "getting chains" in result.error.lower() or "test error" in result.error.lower() async def test_get_deployment(self, client): - """Test get_deployment.""" - # Mock environments call (needed for get_deployment) - mock_env_resp = [ - { - "env": "fuji-multi-subnet", - "chainid": 432204, - "rpc": "https://subnet.example.com", - } + """No-args ``get_deployment`` returns the raw REST list and uses defaults.""" + mock_deploy_resp = [ + {"env": "fuji-multi-subnet", "contracttype": "TradePairs", "address": "0xTP"}, + {"env": "fuji-multi-subnet", "contracttype": "PortfolioSub", "address": "0xPS"}, + {"env": "fuji-multi-avax", "contracttype": "MainnetRFQ", "address": "0xRFQ"}, ] - mock_deploy_resp_tp = [{"env": "fuji-multi-subnet", "address": "0xTP", "abi": {"abi": []}}] - mock_deploy_resp_port = [ - {"env": "fuji-multi-subnet", "address": "0xPS", "abi": {"abi": []}} - ] - mock_deploy_resp_rfq = [{"env": "fuji-multi-avax", "address": "0xRFQ", "abi": {"abi": []}}] def side_effect(url, params=None, **kwargs): mock_resp = AsyncMock() + mock_resp.status = 200 mock_resp.raise_for_status = MagicMock() - if "environments" in url: - mock_resp.json.return_value = mock_env_resp - elif "deployment" in url: - ctype = params.get("contracttype") - if ctype == "TradePairs": - mock_resp.json.return_value = mock_deploy_resp_tp - elif ctype == "Portfolio": - mock_resp.json.return_value = mock_deploy_resp_port - elif ctype == "MainnetRFQ": - mock_resp.json.return_value = mock_deploy_resp_rfq - + mock_resp.json.return_value = mock_deploy_resp mock_cm = AsyncMock() mock_cm.__aenter__.return_value = mock_resp return mock_cm client._mock_session.get.side_effect = side_effect - client.w3_l1 = MagicMock() - client.w3_l1.eth.contract = MagicMock(return_value=MagicMock()) res = await client.get_deployment() assert res.success - assert "TradePairs" in res.data - assert "PortfolioSub" in res.data - assert "MainnetRFQ" in res.data + assert res.data == mock_deploy_resp + # Default filters propagated to query string + call_kwargs = client._mock_session.get.call_args.kwargs + assert call_kwargs["params"] == { + "env": client.parent_env, + "contracttype": "All", + "returnabi": "true", + } async def test_get_deployment_error(self, client): - """Test get_deployment error handling.""" - # Mock environments call to succeed - mock_env_resp = [ - { - "env": "fuji-multi-subnet", - "chainid": 432204, - "rpc": "https://subnet.example.com", - } - ] + """REST failures bubble through as ``Result.fail``.""" def side_effect(url, params=None, **kwargs): mock_resp = AsyncMock() - if "environments" in url: - mock_resp.json.return_value = mock_env_resp - mock_resp.raise_for_status = MagicMock() - elif "deployment" in url: - # Make deployment fetch raise an exception - def raise_error(): - raise Exception("Test error fetching deployments") + mock_resp.status = 500 + mock_resp.json.return_value = {} # empty body — falls through + + def raise_error(): + raise Exception("Test error fetching deployments") - mock_resp.raise_for_status = raise_error + mock_resp.raise_for_status = raise_error mock_cm = AsyncMock() mock_cm.__aenter__.return_value = mock_resp return mock_cm @@ -2611,46 +2587,37 @@ async def failing_get_environments(): assert "failed to fetch environments" in result.error.lower() async def test_get_deployment_cache_disabled(self, client): - """Test get_deployment with cache disabled.""" + """``get_deployment`` clears its own cache slot when caching is disabled.""" from dexalot_sdk.core.base import _STATIC_CACHE # Disable cache client._cache_enabled = False - # Add something to cache - key = ("get_deployment", (client,), frozenset()) + # Pre-seed the cache slot for the no-args call so we can verify it gets cleared. + # Cache-key shape mirrors the one built inside ``get_deployment``. + key = ( + "get_deployment", + client.api_base_url, + (), + frozenset( + { + "env": client.parent_env, + "contract_type": "All", + "return_abi": True, + }.items() + ), + ) _STATIC_CACHE._store[key] = "cached_data" - # Mock API responses - mock_env_resp = [ - { - "env": "fuji-multi-subnet", - "chainid": 432204, - "rpc": "https://subnet.example.com", - } - ] - mock_deploy_resp_tp = [{"env": "fuji-multi-subnet", "address": "0xTP", "abi": {"abi": []}}] - mock_deploy_resp_port = [ - {"env": "fuji-multi-subnet", "address": "0xPS", "abi": {"abi": []}} - ] - mock_deploy_resp_rfq = [{"env": "fuji-multi-avax", "address": "0xRFQ", "abi": {"abi": []}}] - + # NB: with _cache_enabled=False, the decorator bypasses the cache + # entirely (no read, no write). The body of ``get_deployment`` + # still pops the resolved key for defence-in-depth, so the + # sentinel must disappear. def side_effect(url, params=None, **kwargs): mock_resp = AsyncMock() + mock_resp.status = 200 mock_resp.raise_for_status = MagicMock() - if "environments" in url: - mock_resp.json.return_value = mock_env_resp - elif "deployment" in url: - if params and params.get("contracttype") == "TradePairs": - mock_resp.json.return_value = mock_deploy_resp_tp - elif params and params.get("contracttype") == "PortfolioMain": - mock_resp.json.return_value = mock_deploy_resp_port - elif params and params.get("contracttype") == "MainnetRFQ": - mock_resp.json.return_value = mock_deploy_resp_rfq - else: - mock_resp.json.return_value = [] - else: - mock_resp.json.return_value = [] + mock_resp.json.return_value = [] mock_cm = AsyncMock() mock_cm.__aenter__.return_value = mock_resp return mock_cm @@ -2659,58 +2626,10 @@ def side_effect(url, params=None, **kwargs): result = await client.get_deployment() - # Verify cache was cleared + # Verify the sentinel cache entry was cleared assert key not in _STATIC_CACHE._store assert result.success - async def test_get_deployment_empty_deployments_init(self, client): - """Test get_deployment when self.deployments is empty/None to verify initialization.""" - # Set deployments to empty - client.deployments = None - - # Mock API responses - mock_env_resp = [ - { - "env": "fuji-multi-subnet", - "chainid": 432204, - "rpc": "https://subnet.example.com", - } - ] - mock_deploy_resp_tp = [{"env": "fuji-multi-subnet", "address": "0xTP", "abi": {"abi": []}}] - mock_deploy_resp_port = [ - {"env": "fuji-multi-subnet", "address": "0xPS", "abi": {"abi": []}} - ] - mock_deploy_resp_rfq = [{"env": "fuji-multi-avax", "address": "0xRFQ", "abi": {"abi": []}}] - - def side_effect(url, params=None, **kwargs): - mock_resp = AsyncMock() - mock_resp.raise_for_status = MagicMock() - if "environments" in url: - mock_resp.json.return_value = mock_env_resp - elif "deployment" in url: - if params and params.get("contracttype") == "TradePairs": - mock_resp.json.return_value = mock_deploy_resp_tp - elif params and params.get("contracttype") == "PortfolioMain": - mock_resp.json.return_value = mock_deploy_resp_port - elif params and params.get("contracttype") == "MainnetRFQ": - mock_resp.json.return_value = mock_deploy_resp_rfq - else: - mock_resp.json.return_value = [] - else: - mock_resp.json.return_value = [] - mock_cm = AsyncMock() - mock_cm.__aenter__.return_value = mock_resp - return mock_cm - - client._mock_session.get.side_effect = side_effect - - result = await client.get_deployment() - - # Verify deployments was initialized - assert client.deployments is not None - assert "TradePairs" in client.deployments - assert result.success - async def test_rehydrate_cached_get_environments_ignores_failed_or_empty_results(self, client): """Cached environment rehydration should skip failed and empty results.""" from dexalot_sdk.utils.result import Result @@ -2723,69 +2642,8 @@ async def test_rehydrate_cached_get_environments_ignores_failed_or_empty_results await client._rehydrate_cached_get_environments(Result.ok(None)) assert client.chain_config == {"keep": {"chain_id": 1}} - def test_apply_deployment_state_restores_contract_handles(self, client): - """Deployment rehydration should rebuild all contract handles when providers exist.""" - client.w3_l1 = MagicMock() - client.w3_connected_chain = MagicMock() - trade_pairs_contract = MagicMock() - portfolio_sub_contract = MagicMock() - portfolio_main_contract = MagicMock() - client.w3_l1.eth.contract.side_effect = [trade_pairs_contract, portfolio_sub_contract] - client.w3_connected_chain.eth.contract.return_value = portfolio_main_contract - - deployments = { - "TradePairs": {"address": "0xTP", "abi": []}, - "PortfolioSub": {"address": "0xPS", "abi": []}, - "PortfolioMain": {"Avalanche": {"address": "0xPM", "abi": []}}, - } - - client._apply_deployment_state(deployments) - - assert client.deployments == deployments - assert client.trade_pairs_contract is trade_pairs_contract - assert client.portfolio_sub_contract is portfolio_sub_contract - assert client.portfolio_main_avax_contract is portfolio_main_contract - - async def test_rehydrate_cached_get_deployment_fetches_environments_first(self, client): - """Deployment rehydration should bootstrap env state before rebuilding contracts.""" - from dexalot_sdk.utils.result import Result - - client.chain_config = {} - client.w3_l1 = None - - with ( - patch.object( - client, "get_environments", new=AsyncMock(return_value=Result.ok([])) - ) as envs, - patch.object(client, "_apply_deployment_state") as apply_state, - ): - await client._rehydrate_cached_get_deployment(Result.ok({"TradePairs": {}})) - - envs.assert_awaited_once() - apply_state.assert_called_once_with({"TradePairs": {}}) - - async def test_rehydrate_cached_get_deployment_skips_apply_when_env_bootstrap_fails( - self, client - ): - """Deployment rehydration should bail out if env restoration fails.""" - from dexalot_sdk.utils.result import Result - - client.chain_config = {} - client.w3_l1 = None - - with ( - patch.object( - client, "get_environments", new=AsyncMock(return_value=Result.fail("env down")) - ) as envs, - patch.object(client, "_apply_deployment_state") as apply_state, - ): - await client._rehydrate_cached_get_deployment(Result.ok({"TradePairs": {}})) - - envs.assert_awaited_once() - apply_state.assert_not_called() - # ------------------------------------------------------------------ - # camelCase transform fallbacks + get_chains / get_deployments env-fail + # camelCase transform fallbacks + get_chains env-fail # ------------------------------------------------------------------ def test_transform_environment_camelcase_chain_id_and_env_type(self, client): @@ -2840,18 +2698,6 @@ async def test_get_chains_env_fail_propagates(self, client): assert not result.success assert "env error" in result.error - async def test_get_deployments_env_fail_propagates(self, client): - """get_deployment returns Result.fail when get_environments fails (chain_config empty).""" - client.chain_config = {} # ensure get_environments is called - with patch.object( - client, - "get_environments", - new=AsyncMock(return_value=MagicMock(success=False, error="env down")), - ): - result = await client.get_deployment() - assert not result.success - assert "env down" in result.error - async def test_connect_python314_connector_has_no_enable_cleanup_closed(self, client, mock_env): """On Python >= 3.14 the TCPConnector is created without enable_cleanup_closed.""" client._session = None # force a new session to be created @@ -3009,3 +2855,94 @@ async def test_api_call_success_returns_json(self, client): with patch.object(client, "_make_http_request", AsyncMock(return_value=cm)): data = await client._api_call("get", "https://api/x") assert data == [{"id": 1}] + + # -------------------------------------------------------------------- # + # get_deployment — env / contract_type / return_abi filters # + # -------------------------------------------------------------------- # + # The Dexalot REST deployment endpoint takes optional + # env / contracttype / returnabi filters. The SDK accepts them via + # keyword-only args and resolves defaults (env=parent_env, + # contract_type='All', return_abi=True). The cache key includes all + # three so filter variants do not collide on the same static-cache + # slot. + + async def test_get_deployment_defaults_call_rest_endpoint(self, client): + """No-args ``get_deployment`` calls REST with default filters.""" + api_spy = AsyncMock(return_value=[{"env": "fuji-multi", "contracttype": "All"}]) + with patch.object(client, "_api_call", api_spy): + result = await client.get_deployment() + + assert result.success + assert result.data == [{"env": "fuji-multi", "contracttype": "All"}] + api_spy.assert_awaited_once() + _, kwargs = api_spy.call_args + assert kwargs["params"] == { + "env": client.parent_env, + "contracttype": "All", + "returnabi": "true", + } + + async def test_get_deployment_partial_opts_default_remaining(self, client): + """Partial opts (only ``env``) fall back to defaults for the rest.""" + api_spy = AsyncMock(return_value=[]) + with patch.object(client, "_api_call", api_spy): + await client.get_deployment(env="fuji-multi-subnet") + + _, kwargs = api_spy.call_args + assert kwargs["params"] == { + "env": "fuji-multi-subnet", + "contracttype": "All", + "returnabi": "true", + } + + async def test_get_deployment_full_opts_propagate(self, client): + """All three opts are forwarded to the REST query string.""" + api_spy = AsyncMock(return_value=[]) + with patch.object(client, "_api_call", api_spy): + result = await client.get_deployment( + env="fuji-multi-avax", + contract_type="Portfolio", + return_abi=False, + ) + + assert result.success + assert result.data == [] + _, kwargs = api_spy.call_args + assert kwargs["params"] == { + "env": "fuji-multi-avax", + "contracttype": "Portfolio", + "returnabi": "false", + } + + async def test_get_deployment_distinct_cache_slots_per_filter_combo(self, client): + """Distinct (env, contract_type, return_abi) combos use distinct cache slots.""" + api_spy = AsyncMock(return_value=[]) + with patch.object(client, "_api_call", api_spy): + await client.get_deployment(env="fuji-multi-avax") + await client.get_deployment(env="production-multi-avax") + await client.get_deployment(env="fuji-multi-avax", contract_type="Portfolio") + await client.get_deployment(env="fuji-multi-avax", return_abi=False) + + # Four distinct calls — no cache collision + assert api_spy.await_count == 4 + + async def test_get_deployment_repeated_identical_call_is_cached(self, client): + """Repeated identical calls hit the static cache and skip REST.""" + api_spy = AsyncMock(return_value=[]) + with patch.object(client, "_api_call", api_spy): + await client.get_deployment(env="fuji-multi-avax") + await client.get_deployment(env="fuji-multi-avax") + + assert api_spy.await_count == 1 + + async def test_get_deployment_rest_failure_returns_sanitized_fail(self, client): + """REST failures are caught and returned as Result.fail.""" + with patch.object( + client, + "_api_call", + AsyncMock(side_effect=RuntimeError("FQ-015: insufficient liquidity")), + ): + result = await client.get_deployment(env="fuji-multi-avax") + + assert not result.success + assert "FQ-015" in result.error or "insufficient liquidity" in result.error.lower() From f4345231b2b2b10351dc6c2a68aea405db7de3d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=BCseyin=20Deniz?= Date: Tue, 2 Jun 2026 21:23:24 +0300 Subject: [PATCH 03/10] feat(transfer): add get_token_usd_prices for portfolio USD valuation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Returns token-symbol → USD-price map from the public /info/usd-prices endpoint. Cached at Semi-Static tier (15m). No auth required. The backend currently emits a flat ``dict[str, str]`` map (string prices, including scientific notation for very small values); the SDK coerces to ``float`` and silently drops entries it cannot interpret. An array-of- objects fallback (``[{"symbol", "price"}, ...]``) is also accepted in case the backend shape ever changes. Cache key is namespaced by ``env`` so testnet and mainnet clients in the same process never collide. The ``env`` query parameter is forwarded for parity with the TypeScript SDK; the backend determines the network from the API host today and ignores it, but the SDK is forward-compatible. Also fixes a latent cache-instance-identity bug in ``_configure_caches``: the function was reassigning the module-level cache globals when a custom TTL was supplied, but subscriber modules (``transfer.py`` / ``swap.py`` / ``clob.py``) capture those globals by reference at module load time. Reassigning would leave the cache the decorator writes to disconnected from the cache test fixtures clear, producing order-dependent test failures. ``_configure_caches`` now mutates the existing ``MemoryCache.ttl`` in place. Mirrors TypeScript SDK commit ea9f4a4. Co-Authored-By: Claude Opus 4.7 --- src/dexalot_sdk/constants.py | 4 + src/dexalot_sdk/core/base.py | 25 +++-- src/dexalot_sdk/core/transfer.py | 118 ++++++++++++++++++++ tests/unit/core/test_transfer.py | 179 +++++++++++++++++++++++++++++++ 4 files changed, 316 insertions(+), 10 deletions(-) diff --git a/src/dexalot_sdk/constants.py b/src/dexalot_sdk/constants.py index 3b50092..18cb30e 100644 --- a/src/dexalot_sdk/constants.py +++ b/src/dexalot_sdk/constants.py @@ -47,6 +47,10 @@ def ws_api_url_for_rest_base(rest_api_base_url: str | None) -> str: # /api/ tree on the backend (not duplicated under /privapi/). ENDPOINT_TRADING_CANDLE_CHUNK = "/api/trading/candle-chunk" ENDPOINT_STATS_MARKET_SNAPSHOT = "/api/stats/market-snapshot" +# Public info tree (no auth, no `env` query at the backend — the host +# determines the network — but the SDK still forwards `env` for parity +# with the TypeScript SDK and cache-key namespacing on the client). +ENDPOINT_INFO_USD_PRICES = "/api/info/usd-prices" # Default Values DEFAULT_DECIMALS = 18 diff --git a/src/dexalot_sdk/core/base.py b/src/dexalot_sdk/core/base.py index 3cece33..3bb9400 100644 --- a/src/dexalot_sdk/core/base.py +++ b/src/dexalot_sdk/core/base.py @@ -289,24 +289,29 @@ def _initialize_data_structures(self): self.portfolio_sub_contract = None def _configure_caches(self): - """Configure module-level caches with custom TTL if provided.""" + """Configure module-level caches with custom TTL if provided. + + Mutates the existing ``MemoryCache`` instances in place rather + than rebinding the globals. Subscriber modules (``transfer.py``, + ``swap.py``, ``clob.py``) import these by name at module-load + time, so the decorators they apply capture stale references if + the globals are reassigned later — leaving the cache the + decorator writes to disconnected from the cache test fixtures + clear. In-place mutation preserves identity across all + importers. + """ self._cache_enabled = self.config.enable_cache if not self._cache_enabled: return - global _STATIC_CACHE, _SEMI_STATIC_CACHE, _BALANCE_CACHE, _ORDERBOOK_CACHE if self.config.cache_ttl_static != 3600: - _STATIC_CACHE = MemoryCache(ttl_seconds=self.config.cache_ttl_static, max_size=128) + _STATIC_CACHE.ttl = self.config.cache_ttl_static if self.config.cache_ttl_semi_static != 900: - _SEMI_STATIC_CACHE = MemoryCache( - ttl_seconds=self.config.cache_ttl_semi_static, max_size=256 - ) + _SEMI_STATIC_CACHE.ttl = self.config.cache_ttl_semi_static if self.config.cache_ttl_balance != 10: - _BALANCE_CACHE = MemoryCache(ttl_seconds=self.config.cache_ttl_balance, max_size=512) + _BALANCE_CACHE.ttl = self.config.cache_ttl_balance if self.config.cache_ttl_orderbook != 1: - _ORDERBOOK_CACHE = MemoryCache( - ttl_seconds=self.config.cache_ttl_orderbook, max_size=256 - ) + _ORDERBOOK_CACHE.ttl = self.config.cache_ttl_orderbook def _setup_rate_limiters(self): """Set up rate limiters if enabled.""" diff --git a/src/dexalot_sdk/core/transfer.py b/src/dexalot_sdk/core/transfer.py index 8cef738..4695fbd 100644 --- a/src/dexalot_sdk/core/transfer.py +++ b/src/dexalot_sdk/core/transfer.py @@ -1,9 +1,11 @@ import asyncio +import math from typing import Any, cast from ..constants import ( BRIDGE_ID_ICM, BRIDGE_ID_LZ, + ENDPOINT_INFO_USD_PRICES, ENDPOINT_TRADING_TOKENS, GAS_BUFFER, ICM_CHAINS, @@ -1636,3 +1638,119 @@ async def _estimate_gas(): return w3.to_hex(tx_hash) return w3.to_hex(tx_hash) + + # ---------------------------------------------------------------------- + # USD price endpoints (public /api/info/...) + # ---------------------------------------------------------------------- + + @staticmethod + def _coerce_usd_price(raw: Any) -> float | None: + """Coerce a single raw price value into a finite non-negative float. + + Returns ``None`` for anything we cannot confidently interpret as a + price (non-string/non-number, empty string, NaN, ±Infinity, + negative). The backend currently emits decimal strings (including + scientific notation e.g. ``"1.04662e-7"``), so :func:`float` is + the right primitive — but we tolerate plain numbers too in case + the shape ever flips. + """ + if isinstance(raw, bool): + # bool is a subclass of int — disallow explicitly to avoid + # treating ``True``/``False`` as 1/0 prices. + return None + if isinstance(raw, int | float): + n = float(raw) + if not math.isfinite(n) or n < 0: + return None + return n + if not isinstance(raw, str): + return None + trimmed = raw.strip() + if trimmed == "": + return None + try: + n = float(trimmed) + except (ValueError, TypeError): + return None + if not math.isfinite(n) or n < 0: + return None + return n + + @track_method("transfer") + async def get_token_usd_prices(self, env: str | None = None) -> Result[dict[str, float]]: + """Fetch current USD prices for every Dexalot-listed token. + + Public endpoint, no signed auth required. Cached for 15 minutes + (semi-static cache tier). + + The backend currently emits the price map as a flat + ``dict[str, str]`` (string prices, including scientific notation + for very small values); the SDK coerces to ``float`` and silently + drops entries it cannot interpret. An array-of-objects fallback + (``[{"symbol", "price"}, ...]``) is also accepted in case the + backend shape ever changes. + + The ``env`` query parameter is forwarded for parity with the + TypeScript SDK and to namespace the cache key per network; the + backend itself currently determines the network from the API host + and does not consult the parameter. + + Args: + env: Optional environment label override. Defaults to + ``self.parent_env``. + + Returns: + ``Result.ok({symbol: price})`` on success, + ``Result.fail(msg)`` on network failure or unexpected response + shape. + """ + target_env = env if env is not None else self.parent_env + return cast( + Result[dict[str, float]], + await self._get_token_usd_prices_cached(target_env), + ) + + @async_ttl_cached(_SEMI_STATIC_CACHE) + async def _get_token_usd_prices_cached(self, env: str) -> Result[dict[str, float]]: + """Internal cached implementation of get_token_usd_prices.""" + try: + data = await self._api_call( + "get", + f"{self.api_base_url}{ENDPOINT_INFO_USD_PRICES}", + params={"env": env}, + ) + out: dict[str, float] = {} + if isinstance(data, list): + # Forward-compat: array-of-objects shape. + for row in data: + if not isinstance(row, dict): + continue + raw_symbol = row.get("symbol") + if not isinstance(raw_symbol, str): + continue + symbol = raw_symbol.strip() + if not symbol: + continue + price = self._coerce_usd_price(row.get("price")) + if price is None: + continue + out[symbol] = price + return Result.ok(out) + if isinstance(data, dict): + # Current shape: flat ``Record`` map. + for symbol, raw in data.items(): + if not isinstance(symbol, str): + continue + trimmed_sym = symbol.strip() + if not trimmed_sym: + continue + price = self._coerce_usd_price(raw) + if price is None: + continue + out[trimmed_sym] = price + return Result.ok(out) + return Result.fail( + f"Unexpected USD prices response shape: expected object or array, got {type(data).__name__}." + ) + except Exception as e: + return Result.fail(self._sanitize_error(e, "fetching token USD prices")) diff --git a/tests/unit/core/test_transfer.py b/tests/unit/core/test_transfer.py index 623ad71..a013511 100644 --- a/tests/unit/core/test_transfer.py +++ b/tests/unit/core/test_transfer.py @@ -2213,3 +2213,182 @@ async def test_ensure_allowance_no_account(self, client): w3 = self.create_w3() with pytest.raises(ValueError, match="Account is required"): await client._ensure_allowance(w3, "0xToken", "0xSpender", 1000) + + # ---------------------------------------------------------------------- + # get_token_usd_prices — public REST endpoint /api/info/usd-prices + # ---------------------------------------------------------------------- + # Mirrors TypeScript SDK commit ea9f4a4. Backend returns a flat + # ``dict[str, str]`` map (prices are numeric strings, scientific notation + # supported). SDK coerces to floats and silently drops malformed rows. + # An array-of-objects shape is also tolerated as forward-compat. + + async def test_get_token_usd_prices_flat_map_success(self, client): + """get_token_usd_prices parses flat ``dict[str, str]`` map into floats.""" + api_spy = AsyncMock( + return_value={ + "ETH": "1976.908750955006", + "ALOT": "0.041165219801", + "COQ": "1.04662e-7", + } + ) + with patch.object(client, "_api_call", api_spy): + result = await client.get_token_usd_prices() + + assert result.success + assert result.data == { + "ETH": 1976.908750955006, + "ALOT": 0.041165219801, + "COQ": 1.04662e-7, + } + + async def test_get_token_usd_prices_forwards_env_query(self, client): + """``env`` arg overrides default and is forwarded as a query param.""" + api_spy = AsyncMock(return_value={"ETH": "1.0"}) + with patch.object(client, "_api_call", api_spy): + await client.get_token_usd_prices(env="production-multi") + + args, kwargs = api_spy.call_args + assert kwargs["params"] == {"env": "production-multi"} + # Endpoint path must be the hyphenated /api/info/usd-prices + assert "/api/info/usd-prices" in args[1] + + async def test_get_token_usd_prices_defaults_env_to_parent_env(self, client): + """When ``env`` is None, fall back to ``self.parent_env``.""" + api_spy = AsyncMock(return_value={}) + with patch.object(client, "_api_call", api_spy): + await client.get_token_usd_prices() + + _, kwargs = api_spy.call_args + assert kwargs["params"] == {"env": client.parent_env} + + async def test_get_token_usd_prices_array_of_objects_fallback(self, client): + """Array-of-objects shape ``[{symbol, price}, ...]`` is also accepted.""" + api_spy = AsyncMock( + return_value=[ + {"symbol": "ETH", "price": "1976.91"}, + {"symbol": "ALOT", "price": 0.04}, + ] + ) + with patch.object(client, "_api_call", api_spy): + result = await client.get_token_usd_prices() + + assert result.success + assert result.data == {"ETH": 1976.91, "ALOT": 0.04} + + async def test_get_token_usd_prices_drops_malformed_rows(self, client): + """Empty symbol, missing symbol, NaN, negative, non-numeric — silently dropped.""" + api_spy = AsyncMock( + return_value={ + "ETH": "1.0", + "": "5.0", # empty symbol + " ": "9.0", # whitespace symbol + "BAD": "not-a-number", + "NEG": "-1.5", + "NAN": "NaN", + "INF": "Infinity", + "NONE_VAL": None, + "EMPTY": "", + "WHITESPACE": " ", + "GOOD": "2.5", + } + ) + with patch.object(client, "_api_call", api_spy): + result = await client.get_token_usd_prices() + + assert result.success + assert result.data == {"ETH": 1.0, "GOOD": 2.5} + + async def test_get_token_usd_prices_array_drops_malformed_rows(self, client): + """Array shape: missing/empty symbol or bad price silently dropped.""" + api_spy = AsyncMock( + return_value=[ + {"symbol": "ETH", "price": "1.0"}, + {"symbol": "", "price": "5.0"}, # empty symbol + {"price": "9.0"}, # missing symbol + {"symbol": "BAD"}, # missing price + {"symbol": "BAD2", "price": "abc"}, + None, + "not-a-dict", + {"symbol": "GOOD", "price": 2.5}, + ] + ) + with patch.object(client, "_api_call", api_spy): + result = await client.get_token_usd_prices() + + assert result.success + assert result.data == {"ETH": 1.0, "GOOD": 2.5} + + async def test_get_token_usd_prices_accepts_numeric_price(self, client): + """Pure numeric prices (already coerced) are accepted.""" + api_spy = AsyncMock(return_value={"ETH": 1.5, "ALOT": 0.04}) + with patch.object(client, "_api_call", api_spy): + result = await client.get_token_usd_prices() + assert result.success + assert result.data == {"ETH": 1.5, "ALOT": 0.04} + + async def test_get_token_usd_prices_numeric_nan_dropped(self, client): + """Numeric NaN/Infinity/negative values are silently dropped.""" + api_spy = AsyncMock( + return_value={ + "GOOD": 1.5, + "NAN": float("nan"), + "INF": float("inf"), + "NEG_INF": float("-inf"), + "NEG": -1.0, + } + ) + with patch.object(client, "_api_call", api_spy): + result = await client.get_token_usd_prices() + assert result.success + assert result.data == {"GOOD": 1.5} + + async def test_get_token_usd_prices_unexpected_shape_fails(self, client): + """Non-dict, non-array response returns ``Result.fail``.""" + api_spy = AsyncMock(return_value="unexpected-string-body") + with patch.object(client, "_api_call", api_spy): + result = await client.get_token_usd_prices() + assert not result.success + assert "USD prices response shape" in result.error + + async def test_get_token_usd_prices_api_error(self, client): + """REST failure is caught and surfaced as Result.fail.""" + api_spy = AsyncMock(side_effect=RuntimeError("network down")) + with patch.object(client, "_api_call", api_spy): + result = await client.get_token_usd_prices() + assert not result.success + + async def test_get_token_usd_prices_cached_per_env(self, client): + """Cache key namespaced by env so testnet/mainnet do not collide.""" + client._cache_enabled = True + api_spy = AsyncMock(return_value={"ETH": "1.0"}) + with patch.object(client, "_api_call", api_spy): + await client.get_token_usd_prices(env="fuji-multi") + await client.get_token_usd_prices(env="fuji-multi") # cache hit + await client.get_token_usd_prices(env="production-multi") # distinct slot + + assert api_spy.await_count == 2 + + async def test_get_token_usd_prices_cache_bypass_when_disabled(self, client): + """When _cache_enabled is False, every call hits REST.""" + client._cache_enabled = False + api_spy = AsyncMock(return_value={"ETH": "1.0"}) + with patch.object(client, "_api_call", api_spy): + await client.get_token_usd_prices(env="fuji-multi") + await client.get_token_usd_prices(env="fuji-multi") + assert api_spy.await_count == 2 + + def test_coerce_usd_price_rejects_bool(self): + """``True``/``False`` must not be coerced to 1/0 prices.""" + from dexalot_sdk.core.transfer import TransferClient + + assert TransferClient._coerce_usd_price(True) is None + assert TransferClient._coerce_usd_price(False) is None + + async def test_get_token_usd_prices_drops_non_string_keys(self, client): + """Flat-map keys that are not strings are skipped.""" + # Backend never emits these but be defensive against pathological proxies. + api_spy = AsyncMock(return_value={123: "1.0", "GOOD": "2.0"}) + with patch.object(client, "_api_call", api_spy): + result = await client.get_token_usd_prices() + assert result.success + assert result.data == {"GOOD": 2.0} From 7e533402eef7aba549861a825051000296577815 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=BCseyin=20Deniz?= Date: Tue, 2 Jun 2026 21:26:15 +0300 Subject: [PATCH 04/10] feat(transfer): add price history methods (daily + hourly) get_token_price_history and get_token_hourly_price_history return ascending-time-ordered ``list[PricePoint]`` from the public ``/api/info/token-usd-price-history`` and ``/api/info/token-usd-price-history-hourly`` endpoints. Static-tier cached (1 h TTL) with path-namespaced keys so daily and hourly never collide; past prices don't change. Both methods delegate to a shared ``_fetch_price_history`` helper that normalizes the raw ``{date: ISO-8601-string, price: stringified-decimal}`` rows (descending by date on the wire) into ascending unix-seconds + numeric ``PricePoint`` list, tolerates ``ts``/``timestamp``/``time`` numeric aliases (values ``>= 1e12`` treated as milliseconds), and silently drops malformed rows. Optional ``from_ts``/``to_ts`` window is forwarded to the backend (ignored today, forward-compat) and additionally applied client-side so the caller's range contract holds. The Python signature uses keyword-only ``from_ts``/``to_ts`` instead of the TypeScript ``opts: {from, to}`` shape to avoid the ``from`` keyword conflict in Python. New ``PricePoint`` frozen dataclass exported from ``dexalot_sdk.core.transfer`` (kept in the module that produces it, matching the existing ``ResolvedChain`` convention in ``base.py``). Mirrors TypeScript SDK commit 63b2e61. Co-Authored-By: Claude Opus 4.7 --- src/dexalot_sdk/constants.py | 8 ++ src/dexalot_sdk/core/transfer.py | 202 ++++++++++++++++++++++++++++++- tests/unit/core/test_transfer.py | 186 ++++++++++++++++++++++++++++ 3 files changed, 395 insertions(+), 1 deletion(-) diff --git a/src/dexalot_sdk/constants.py b/src/dexalot_sdk/constants.py index 18cb30e..e99f78e 100644 --- a/src/dexalot_sdk/constants.py +++ b/src/dexalot_sdk/constants.py @@ -51,6 +51,14 @@ def ws_api_url_for_rest_base(rest_api_base_url: str | None) -> str: # determines the network — but the SDK still forwards `env` for parity # with the TypeScript SDK and cache-key namespacing on the client). ENDPOINT_INFO_USD_PRICES = "/api/info/usd-prices" +# Daily and hourly USD price history per token. Backend ignores +# `from`/`to`/`env` query params today (host determines network, range +# is fixed window) but the SDK forwards them for forward-compat and +# cache-key namespacing; if a caller supplies `from_ts`/`to_ts` the +# response is additionally filtered client-side so the contract remains +# useful when the backend gains range support. +ENDPOINT_INFO_PRICE_HISTORY = "/api/info/token-usd-price-history" +ENDPOINT_INFO_HOURLY_PRICE_HISTORY = "/api/info/token-usd-price-history-hourly" # Default Values DEFAULT_DECIMALS = 18 diff --git a/src/dexalot_sdk/core/transfer.py b/src/dexalot_sdk/core/transfer.py index 4695fbd..6e7ecd3 100644 --- a/src/dexalot_sdk/core/transfer.py +++ b/src/dexalot_sdk/core/transfer.py @@ -1,10 +1,14 @@ import asyncio import math +from dataclasses import dataclass +from datetime import datetime from typing import Any, cast from ..constants import ( BRIDGE_ID_ICM, BRIDGE_ID_LZ, + ENDPOINT_INFO_HOURLY_PRICE_HISTORY, + ENDPOINT_INFO_PRICE_HISTORY, ENDPOINT_INFO_USD_PRICES, ENDPOINT_TRADING_TOKENS, GAS_BUFFER, @@ -20,7 +24,23 @@ ) from ..utils.observability import track_method from ..utils.result import Result -from .base import _BALANCE_CACHE, _SEMI_STATIC_CACHE, DexalotBaseClient +from .base import _BALANCE_CACHE, _SEMI_STATIC_CACHE, _STATIC_CACHE, DexalotBaseClient + + +@dataclass(frozen=True) +class PricePoint: + """One USD price observation in a price-history series. + + Returned by :meth:`TransferClient.get_token_price_history` (daily) and + :meth:`TransferClient.get_token_hourly_price_history` (hourly). The + backend ships rows as ``{date: ISO-8601-string, price: stringified-decimal}`` + sorted descending by date; the SDK normalizes to ascending unix-seconds + + numeric price so callers can chart, filter, or interpolate without + re-parsing. + """ + + timestamp: int # unix seconds (UTC) + price: float class TransferClient(DexalotBaseClient): @@ -1754,3 +1774,183 @@ async def _get_token_usd_prices_cached(self, env: str) -> Result[dict[str, float ) except Exception as e: return Result.fail(self._sanitize_error(e, "fetching token USD prices")) + + # ---------------------------------------------------------------------- + # USD price history (daily + hourly, public /api/info/...) + # ---------------------------------------------------------------------- + + @staticmethod + def _coerce_timestamp_seconds(raw: Any) -> int | None: + """Coerce a raw numeric / numeric-string timestamp to unix seconds. + + Values ``>= 1e12`` are treated as milliseconds and divided by 1000 + (a 32-bit second-precision unix epoch maxes out at ``2^31 ≈ 2.1e9``, + so 1e12 is a safe boundary). + """ + if isinstance(raw, bool): + return None + if isinstance(raw, int | float): + n = float(raw) + elif isinstance(raw, str): + trimmed = raw.strip() + if trimmed == "": + return None + try: + n = float(trimmed) + except ValueError: + return None + else: + return None + if not math.isfinite(n) or n < 0: + return None + if n >= 1e12: + n = math.floor(n / 1000) + return int(math.floor(n)) + + @staticmethod + def _extract_history_timestamp(row: dict[str, Any]) -> int | None: + """Pull a timestamp out of one raw price-history row. + + Backend ships ``date`` as ISO-8601; we additionally accept the + numeric aliases ``ts`` / ``timestamp`` / ``time`` (value treated + as milliseconds when ``>= 1e12``) so the contract is stable if + the shape ever flips. Returns unix seconds (UTC) or ``None``. + """ + raw_date = row.get("date") + if isinstance(raw_date, str) and raw_date.strip(): + try: + # Accept ``...Z`` and timezone-naive ISO strings. Python + # 3.11+ ``fromisoformat`` handles ``Z`` directly via the + # explicit ``+00:00`` replacement. + dt = datetime.fromisoformat(raw_date.replace("Z", "+00:00")) + except ValueError: + dt = None + if dt is not None: + return int(dt.timestamp()) + for key in ("ts", "timestamp", "time"): + if key in row: + coerced = TransferClient._coerce_timestamp_seconds(row[key]) + if coerced is not None: + return coerced + return None + + @track_method("transfer") + async def get_token_price_history( + self, + token: str, + *, + from_ts: int | None = None, + to_ts: int | None = None, + ) -> Result[list[PricePoint]]: + """Daily USD price history for one token. + + Returns an ascending-time ordered ``list[PricePoint]`` from the + public ``/api/info/token-usd-price-history`` endpoint. Past + prices do not change, so results are cached in the static tier + (1 hour TTL); the cache key is path-namespaced so daily and + hourly never collide. + + The optional ``from_ts`` / ``to_ts`` window is in unix seconds. + The backend currently ignores it (the host fixes the network and + the lookback) but the SDK forwards both for forward-compat and + additionally filters the returned series client-side so the + caller's range contract holds regardless of backend behavior. + + No authentication required (public endpoint). + """ + return await self._fetch_price_history( + ENDPOINT_INFO_PRICE_HISTORY, token, from_ts, to_ts + ) + + @track_method("transfer") + async def get_token_hourly_price_history( + self, + token: str, + *, + from_ts: int | None = None, + to_ts: int | None = None, + ) -> Result[list[PricePoint]]: + """Hourly USD price history for one token. + + Same contract as :meth:`get_token_price_history` (ascending-time + ``list[PricePoint]``, static-tier 1 h cache, optional + ``from_ts``/``to_ts`` window applied client-side) but routes + through the hourly endpoint. Useful when more granular series + is needed than the daily variant — backend currently returns the + trailing ~24 h at 3-hour granularity. + + No authentication required (public endpoint). + """ + return await self._fetch_price_history( + ENDPOINT_INFO_HOURLY_PRICE_HISTORY, token, from_ts, to_ts + ) + + async def _fetch_price_history( + self, + path: str, + token: str, + from_ts: int | None, + to_ts: int | None, + ) -> Result[list[PricePoint]]: + """Shared validation + cache delegation for daily / hourly history. + + Validates the token symbol up-front (avoids polluting the cache + with validation failures) then delegates to the cached helper. + """ + token_result = validate_token_symbol(token, "token") + if not token_result.success: + return cast(Result[list[PricePoint]], token_result) + sym = self._normalize_user_token(token) + return cast( + Result[list[PricePoint]], + await self._fetch_price_history_cached(path, sym, from_ts, to_ts), + ) + + @async_ttl_cached(_STATIC_CACHE) + async def _fetch_price_history_cached( + self, + path: str, + token: str, + from_ts: int | None, + to_ts: int | None, + ) -> Result[list[PricePoint]]: + """Internal cached implementation of price-history fetch. + + Cache key includes ``(path, token, from_ts, to_ts)`` so daily + and hourly never collide on the same slot even with identical + ``(token, from_ts, to_ts)`` tuples. + """ + try: + params: dict[str, Any] = {"token": token} + if from_ts is not None: + params["from"] = from_ts + if to_ts is not None: + params["to"] = to_ts + data = await self._api_call( + "get", + f"{self.api_base_url}{path}", + params=params, + ) + if not isinstance(data, list): + return Result.fail( + f"Unexpected price history response shape: expected array, got {type(data).__name__}." + ) + points: list[PricePoint] = [] + for row in data: + if not isinstance(row, dict): + continue + ts = self._extract_history_timestamp(row) + if ts is None: + continue + price = self._coerce_usd_price(row.get("price")) + if price is None: + continue + if from_ts is not None and ts < from_ts: + continue + if to_ts is not None and ts > to_ts: + continue + points.append(PricePoint(timestamp=ts, price=price)) + points.sort(key=lambda p: p.timestamp) + return Result.ok(points) + except Exception as e: + return Result.fail(self._sanitize_error(e, "fetching token price history")) diff --git a/tests/unit/core/test_transfer.py b/tests/unit/core/test_transfer.py index a013511..38e775e 100644 --- a/tests/unit/core/test_transfer.py +++ b/tests/unit/core/test_transfer.py @@ -2392,3 +2392,189 @@ async def test_get_token_usd_prices_drops_non_string_keys(self, client): result = await client.get_token_usd_prices() assert result.success assert result.data == {"GOOD": 2.0} + + # ---------------------------------------------------------------------- + # get_token_price_history / get_token_hourly_price_history + # ---------------------------------------------------------------------- + # Mirrors TypeScript SDK commit 63b2e61. + + async def test_get_token_price_history_normalizes_and_sorts(self, client): + """ISO dates → unix seconds, prices → float, sorted ascending.""" + from dexalot_sdk.core.transfer import PricePoint + + api_spy = AsyncMock( + return_value=[ + {"date": "2026-06-02T00:00:00Z", "price": "10.5"}, + {"date": "2026-06-01T00:00:00Z", "price": "9.0"}, + {"date": "2026-05-31T00:00:00Z", "price": "8.25"}, + ] + ) + with patch.object(client, "_api_call", api_spy): + result = await client.get_token_price_history("ALOT") + + assert result.success + assert isinstance(result.data[0], PricePoint) + # Ascending by timestamp + assert [p.timestamp for p in result.data] == sorted( + p.timestamp for p in result.data + ) + assert result.data[0].price == 8.25 + assert result.data[-1].price == 10.5 + + async def test_get_token_price_history_endpoint_path(self, client): + """Daily method hits the daily endpoint with ``token`` param.""" + api_spy = AsyncMock(return_value=[]) + with patch.object(client, "_api_call", api_spy): + await client.get_token_price_history("ALOT") + args, kwargs = api_spy.call_args + assert "/api/info/token-usd-price-history" in args[1] + assert "hourly" not in args[1] + assert kwargs["params"]["token"] == "ALOT" + + async def test_get_token_hourly_price_history_endpoint_path(self, client): + """Hourly method hits the hourly endpoint with ``token`` param.""" + api_spy = AsyncMock(return_value=[]) + with patch.object(client, "_api_call", api_spy): + await client.get_token_hourly_price_history("ALOT") + args, kwargs = api_spy.call_args + assert args[1].endswith("/api/info/token-usd-price-history-hourly") + assert kwargs["params"]["token"] == "ALOT" + + async def test_get_token_price_history_forwards_from_and_to(self, client): + """``from_ts``/``to_ts`` forwarded as ``from``/``to`` query params.""" + api_spy = AsyncMock(return_value=[]) + with patch.object(client, "_api_call", api_spy): + await client.get_token_price_history( + "ALOT", from_ts=1700000000, to_ts=1700864000 + ) + _, kwargs = api_spy.call_args + assert kwargs["params"] == { + "token": "ALOT", + "from": 1700000000, + "to": 1700864000, + } + + async def test_get_token_price_history_client_side_filter(self, client): + """Backend ignores ``from``/``to``; SDK filters client-side.""" + # 2026-06-01 00:00 UTC = 1780272000 + # 2026-06-02 00:00 UTC = 1780358400 + # 2026-06-03 00:00 UTC = 1780444800 + api_spy = AsyncMock( + return_value=[ + {"date": "2026-06-01T00:00:00Z", "price": "9.0"}, + {"date": "2026-06-02T00:00:00Z", "price": "10.0"}, + {"date": "2026-06-03T00:00:00Z", "price": "11.0"}, + ] + ) + with patch.object(client, "_api_call", api_spy): + result = await client.get_token_price_history( + "ALOT", from_ts=1780358400, to_ts=1780358400 + ) + assert result.success + # Only the middle row falls inside [from, to] inclusively + assert len(result.data) == 1 + assert result.data[0].price == 10.0 + + async def test_get_token_price_history_drops_malformed_rows(self, client): + """Invalid date / NaN price / non-dict rows silently dropped.""" + api_spy = AsyncMock( + return_value=[ + {"date": "2026-06-01T00:00:00Z", "price": "9.0"}, + {"date": "not-a-date", "price": "10.0"}, + {"date": "2026-06-02T00:00:00Z", "price": "abc"}, + {"date": "2026-06-03T00:00:00Z", "price": "-1.0"}, + {"price": "5.0"}, # missing date + {"date": "2026-06-04T00:00:00Z"}, # missing price + None, + "not-a-dict", + ] + ) + with patch.object(client, "_api_call", api_spy): + result = await client.get_token_price_history("ALOT") + assert result.success + assert len(result.data) == 1 + assert result.data[0].price == 9.0 + + async def test_get_token_price_history_rejects_non_array_response(self, client): + """A non-list response surfaces as Result.fail with a shape hint.""" + api_spy = AsyncMock(return_value={"unexpected": "object"}) + with patch.object(client, "_api_call", api_spy): + result = await client.get_token_price_history("ALOT") + assert not result.success + assert "price history response shape" in result.error + + async def test_get_token_price_history_invalid_token_returns_fail(self, client): + """Invalid token symbol fails validation before any REST call.""" + api_spy = AsyncMock(return_value=[]) + with patch.object(client, "_api_call", api_spy): + result = await client.get_token_price_history("") + assert not result.success + api_spy.assert_not_called() + + async def test_get_token_price_history_api_error(self, client): + """REST failure is caught and surfaced as Result.fail.""" + api_spy = AsyncMock(side_effect=RuntimeError("network boom")) + with patch.object(client, "_api_call", api_spy): + result = await client.get_token_price_history("ALOT") + assert not result.success + + async def test_price_history_cache_key_distinguishes_daily_vs_hourly(self, client): + """Daily and hourly methods do not share the same cache slot.""" + client._cache_enabled = True + api_spy = AsyncMock(return_value=[]) + with patch.object(client, "_api_call", api_spy): + await client.get_token_price_history("ALOT") + await client.get_token_price_history("ALOT") # cache hit + await client.get_token_hourly_price_history("ALOT") # distinct slot + assert api_spy.await_count == 2 + + async def test_price_history_cache_key_distinguishes_opts(self, client): + """Different (token, from_ts, to_ts) tuples use distinct cache slots.""" + client._cache_enabled = True + api_spy = AsyncMock(return_value=[]) + with patch.object(client, "_api_call", api_spy): + await client.get_token_price_history("ALOT") + await client.get_token_price_history("ALOT", from_ts=100) + await client.get_token_price_history("ALOT", from_ts=100, to_ts=200) + await client.get_token_price_history("ETH") + assert api_spy.await_count == 4 + + async def test_get_token_price_history_accepts_ts_numeric_alias(self, client): + """A row with no ``date`` but a numeric ``ts`` is accepted.""" + api_spy = AsyncMock( + return_value=[ + {"ts": 1700000000, "price": "5.0"}, + {"timestamp": "1700000100", "price": "6.0"}, + {"time": 1700000200000, "price": "7.0"}, # milliseconds + ] + ) + with patch.object(client, "_api_call", api_spy): + result = await client.get_token_price_history("ALOT") + assert result.success + assert len(result.data) == 3 + assert result.data[0].timestamp == 1700000000 + assert result.data[1].timestamp == 1700000100 + assert result.data[2].timestamp == 1700000200 # 1.7e12 / 1000 + + def test_coerce_timestamp_seconds_edge_cases(self): + """Direct coverage for the timestamp coercer's branches.""" + from dexalot_sdk.core.transfer import TransferClient + + # bool → None + assert TransferClient._coerce_timestamp_seconds(True) is None + # whitespace-only string → None + assert TransferClient._coerce_timestamp_seconds(" ") is None + # non-numeric string → None + assert TransferClient._coerce_timestamp_seconds("not-a-number") is None + # non-string/non-number → None + assert TransferClient._coerce_timestamp_seconds([1, 2]) is None + # negative → None + assert TransferClient._coerce_timestamp_seconds(-1) is None + # NaN → None + assert TransferClient._coerce_timestamp_seconds(float("nan")) is None + # milliseconds boundary + assert TransferClient._coerce_timestamp_seconds(1700000000000) == 1700000000 + # numeric string is accepted + assert TransferClient._coerce_timestamp_seconds("1700000000") == 1700000000 + # plain number passthrough + assert TransferClient._coerce_timestamp_seconds(1700000000) == 1700000000 From f37c0ac6a751f9fd2b65791259ad210e72d2e69f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=BCseyin=20Deniz?= Date: Tue, 2 Jun 2026 21:31:36 +0300 Subject: [PATCH 05/10] feat(transfer): add get_combined_transfers for unified history MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed GET returning canonical ``list[Transfer]`` (snake_case fields, status/action_type/bridge lifted from numeric enums) with kind/from_ts/to_ts/limit/offset filters from the signed REST endpoint ``/api/trading/signed/transferscombined`` (NOT under ``/privapi/`` — empirically confirmed via OPTIONS preflight: 404 vs 204). Balance-tier cached (10 s TTL) per ``(address, kind, from_ts, to_ts, limit, offset)``. Backend pagination uses ``itemsperpage`` / ``pageno``; the SDK exposes the more conventional ``limit`` / ``offset`` signature and translates internally (``pageno = (offset // limit) + 1``). ``kind`` is forwarded to the backend as ``symbol``; ``from_ts`` / ``to_ts`` as ``periodfrom`` / ``periodto``. Response shape is ``{count, rows}`` with a bare-list fallback for forward-compat. ``quantity`` / ``fee`` arrive as already-display-decimal numeric strings from the backend (the official frontend reads them straight through Big.js — no decimals divide); the SDK coerces to ``float`` via the shared ``_coerce_usd_price`` helper. Unknown ``action_type`` / ``status`` enums and rows missing required fields are silently dropped so a single bad row does not poison the page. Unknown ``bridge`` enum falls back to ``"NATIVE"`` to match the frontend's display-only treatment. To support a signed call outside the CLOB surface, ``_get_auth_headers`` is lifted from ``CLOBClient`` to ``DexalotBaseClient``. The CLOB call sites are unchanged; the duplicate body is removed and a pointer comment kept. The lift also matches the TypeScript SDK structure where ``_getAuthHeaders`` lives on a shared base. New ``Transfer`` / ``TransferStatus`` / ``TransferActionType`` / ``TransferBridge`` types exported from ``dexalot_sdk.core.transfer``. Mirrors TypeScript SDK commit f7a9945. Co-Authored-By: Claude Opus 4.7 --- src/dexalot_sdk/constants.py | 6 + src/dexalot_sdk/core/base.py | 35 +++ src/dexalot_sdk/core/clob.py | 34 +-- src/dexalot_sdk/core/transfer.py | 341 ++++++++++++++++++++++++++++- tests/unit/core/test_transfer.py | 354 +++++++++++++++++++++++++++++++ 5 files changed, 740 insertions(+), 30 deletions(-) diff --git a/src/dexalot_sdk/constants.py b/src/dexalot_sdk/constants.py index e99f78e..93d6356 100644 --- a/src/dexalot_sdk/constants.py +++ b/src/dexalot_sdk/constants.py @@ -59,6 +59,12 @@ def ws_api_url_for_rest_base(rest_api_base_url: str | None) -> str: # useful when the backend gains range support. ENDPOINT_INFO_PRICE_HISTORY = "/api/info/token-usd-price-history" ENDPOINT_INFO_HOURLY_PRICE_HISTORY = "/api/info/token-usd-price-history-hourly" +# Unified transfer history (deposits + withdrawals + p2p + gas) under the +# `/api/trading/signed/` mountpoint. Requires the `x-signature` header. +# The backend route does not exist under `/privapi/...` — confirmed +# empirically (404 on /privapi/trading/signed/transferscombined, +# 204 OPTIONS on /api/trading/signed/transferscombined). +ENDPOINT_TRADING_COMBINED_TRANSFERS = "/api/trading/signed/transferscombined" # Default Values DEFAULT_DECIMALS = 18 diff --git a/src/dexalot_sdk/core/base.py b/src/dexalot_sdk/core/base.py index 3bb9400..3ba8e69 100644 --- a/src/dexalot_sdk/core/base.py +++ b/src/dexalot_sdk/core/base.py @@ -4,6 +4,7 @@ import os import re import sys +import time from dataclasses import dataclass from functools import lru_cache from typing import Any, cast @@ -683,6 +684,40 @@ def _normalize_user_pair(self, pair: str) -> str: return normalize_trading_pair_for_sdk(pair) + def _get_auth_headers(self) -> dict[str, str]: + """Generate authentication headers for signed endpoints. + + Lifted from ``CLOBClient`` so non-CLOB surfaces (transfer history, + future signed endpoints) can use the same helper without depending + on the CLOB mixin. + + When ``config.timestamped_auth`` is True, the signed message is + ``f"dexalot{ts}"`` (millisecond timestamp) and an ``x-timestamp`` + header is included alongside ``x-signature``. This prevents + replay attacks but requires backend support — default is + ``False`` until the backend confirms timestamp window validation. + See ``docs/python-sdk-remediation-plan.md`` C-2. + """ + if not self.account: + raise Exception("Private key not configured.") + + from eth_account.messages import encode_defunct + + addr = cast(str, cast(Any, self.account).address) + + if self.config.timestamped_auth: + ts = int(time.time() * 1000) + message = encode_defunct(text=f"dexalot{ts}") + signature = self.account.sign_message(message).signature.hex() + return { + "x-signature": f"{addr}:0x{signature}", + "x-timestamp": str(ts), + } + + message = encode_defunct(text="dexalot") + signature = self.account.sign_message(message).signature.hex() + return {"x-signature": f"{addr}:0x{signature}"} + def resolve_chain_reference( self, chain_reference: str | int, include_dexalot_l1: bool = False ) -> Result[ResolvedChain]: diff --git a/src/dexalot_sdk/core/clob.py b/src/dexalot_sdk/core/clob.py index a82a94a..64bc645 100644 --- a/src/dexalot_sdk/core/clob.py +++ b/src/dexalot_sdk/core/clob.py @@ -1,5 +1,4 @@ import asyncio -import time from collections.abc import Callable from typing import Any, SupportsInt, cast @@ -819,34 +818,11 @@ async def cancel_all_orders(self) -> Result[dict]: return cast(Result[dict], await self.cancel_list_orders(order_ids)) - def _get_auth_headers(self) -> dict[str, str]: - """Generates authentication headers for signed endpoints. - - When config.timestamped_auth is True, the signed message is f"dexalot{ts}" - (millisecond timestamp) and an x-timestamp header is included alongside - x-signature. This prevents replay attacks but requires backend support — - default is False until the backend confirms timestamp window validation. - See docs/python-sdk-remediation-plan.md C-2. - """ - if not self.account: - raise Exception("Private key not configured.") - - from eth_account.messages import encode_defunct - - addr = cast(str, cast(Any, self.account).address) - - if self.config.timestamped_auth: - ts = int(time.time() * 1000) - message = encode_defunct(text=f"dexalot{ts}") - signature = self.account.sign_message(message).signature.hex() - return { - "x-signature": f"{addr}:0x{signature}", - "x-timestamp": str(ts), - } - - message = encode_defunct(text="dexalot") - signature = self.account.sign_message(message).signature.hex() - return {"x-signature": f"{addr}:0x{signature}"} + # ``_get_auth_headers`` lives on ``DexalotBaseClient`` so non-CLOB + # signed endpoints (transfer history) can share the same helper. + # See ``DexalotBaseClient._get_auth_headers`` for the canonical + # implementation, including the timestamped-auth path gated on + # ``config.timestamped_auth``. def _coerce_order_numeric(self, value: object) -> float | None: """Convert order numeric fields to ``float`` when possible.""" diff --git a/src/dexalot_sdk/core/transfer.py b/src/dexalot_sdk/core/transfer.py index 6e7ecd3..d61648e 100644 --- a/src/dexalot_sdk/core/transfer.py +++ b/src/dexalot_sdk/core/transfer.py @@ -2,7 +2,7 @@ import math from dataclasses import dataclass from datetime import datetime -from typing import Any, cast +from typing import Any, Literal, cast from ..constants import ( BRIDGE_ID_ICM, @@ -10,6 +10,7 @@ ENDPOINT_INFO_HOURLY_PRICE_HISTORY, ENDPOINT_INFO_PRICE_HISTORY, ENDPOINT_INFO_USD_PRICES, + ENDPOINT_TRADING_COMBINED_TRANSFERS, ENDPOINT_TRADING_TOKENS, GAS_BUFFER, ICM_CHAINS, @@ -26,6 +27,30 @@ from ..utils.result import Result from .base import _BALANCE_CACHE, _SEMI_STATIC_CACHE, _STATIC_CACHE, DexalotBaseClient +# --------------------------------------------------------------------------- +# Shared types returned by transfer client methods +# --------------------------------------------------------------------------- +# Kept in this module (next to the methods that produce them) to follow the +# existing SDK convention — e.g. ``ResolvedChain`` lives next to the chain +# resolver in ``base.py``. + +TransferStatus = Literal["COMPLETED", "INFLIGHT", "DELAYED"] + +TransferActionType = Literal[ + "WITHDRAWN", + "DEPOSITED", + "SENT", + "RECEIVED", + "RECOVERED", + "ADD_GAS", + "REMOVE_GAS", + "AUTO_FILL", + "WITHDRAW_PENDING", + "DEPOSIT_PENDING", +] + +TransferBridge = Literal["NATIVE", "LAYER0", "CELER", "ICM"] + @dataclass(frozen=True) class PricePoint: @@ -43,6 +68,71 @@ class PricePoint: price: float +@dataclass(frozen=True) +class Transfer: + """One row of unified transfer history returned by ``get_combined_transfers``. + + Each row aggregates a deposit / withdrawal / gas top-up / portfolio + P2P send-or-receive / bridge recovery involving the connected wallet. + The backend ships rows as ``DBTransfer`` (snake_case + numeric enums); + the SDK lifts the numeric ``status`` / ``action_type`` / ``bridge`` + enums to human-readable strings and parses ``quantity`` / ``fee`` Big + decimal strings to ``float``. ``quantity`` / ``fee`` are already + display-decimal at the backend — there is no wei → human conversion + to apply. + """ + + action_type: TransferActionType + status: TransferStatus + symbol: str + quantity: float + fee: float + trader_address: str + bridge: TransferBridge + bridge_url: str + nonce: int + source_env: str + source_chain_id: int + source_tx: str + source_ts: int + target_env: str + target_chain_id: int + target_tx: str + target_ts: int + + +# Numeric enum → human-readable label lookup tables for the +# ``transferscombined`` REST response. Mirror the +# ``TRANSFER_ACTION_TYPE`` / ``TRANSFER_STATUS`` / ``BRIDGES`` enums +# the official Dexalot frontend uses to render the same rows; keeping +# them in lockstep avoids drift if the backend ever adds new variants. +_TRANSFER_ACTION_TYPE_LABELS: dict[int, TransferActionType] = { + 0: "WITHDRAWN", + 1: "DEPOSITED", + 5: "SENT", + 6: "RECEIVED", + 7: "RECOVERED", + 8: "ADD_GAS", + 9: "REMOVE_GAS", + 10: "AUTO_FILL", + 11: "WITHDRAW_PENDING", + 12: "DEPOSIT_PENDING", +} + +_TRANSFER_STATUS_LABELS: dict[int, TransferStatus] = { + 0: "COMPLETED", + 1: "INFLIGHT", + 2: "DELAYED", +} + +_TRANSFER_BRIDGE_LABELS: dict[int, TransferBridge] = { + -1: "NATIVE", + 0: "LAYER0", + 1: "CELER", + 2: "ICM", +} + + class TransferClient(DexalotBaseClient): async def _get_provider_for_chain(self, chain: str): """ @@ -1954,3 +2044,252 @@ async def _fetch_price_history_cached( return Result.ok(points) except Exception as e: return Result.fail(self._sanitize_error(e, "fetching token price history")) + + # ---------------------------------------------------------------------- + # Combined transfer history (signed /api/trading/signed/transferscombined) + # ---------------------------------------------------------------------- + + def _normalize_transfer(self, raw: Any) -> Transfer | None: + """Normalize one raw ``DBTransfer``-shaped row into a ``Transfer``. + + Returns ``None`` for any row that is missing required fields or + carries an unknown ``action_type`` / ``status`` enum — the + public method silently drops these so a single bad row does not + poison the page. + + ``bridge`` falls back to ``"NATIVE"`` for unknown enum values + because the frontend treats unrecognised bridges the same way + (display-only label, no behavior depends on the precise id), + and the row is otherwise valid. + """ + if not isinstance(raw, dict): + return None + + action_raw = raw.get("action_type") + if not isinstance(action_raw, int) or isinstance(action_raw, bool): + return None + action_type = _TRANSFER_ACTION_TYPE_LABELS.get(action_raw) + if action_type is None: + return None + + status_raw = raw.get("status") + if not isinstance(status_raw, int) or isinstance(status_raw, bool): + return None + status = _TRANSFER_STATUS_LABELS.get(status_raw) + if status is None: + return None + + symbol_raw = raw.get("symbol") + if not isinstance(symbol_raw, str) or not symbol_raw: + return None + + quantity = self._coerce_usd_price(raw.get("quantity")) + if quantity is None: + return None + + # Fee defaults to 0 if missing or unparseable — many native / + # portfolio-internal legs report ``"0"`` and some omit the field. + fee = self._coerce_usd_price(raw.get("fee")) + if fee is None: + fee = 0.0 + + trader_address_raw = raw.get("traderaddress") + trader_address = trader_address_raw if isinstance(trader_address_raw, str) else "" + + bridge_raw = raw.get("bridge") + if isinstance(bridge_raw, int) and not isinstance(bridge_raw, bool): + bridge = _TRANSFER_BRIDGE_LABELS.get(bridge_raw, "NATIVE") + else: + bridge = "NATIVE" + + bridge_url_raw = raw.get("bridge_url") + bridge_url = bridge_url_raw if isinstance(bridge_url_raw, str) else "" + + nonce_raw = raw.get("nonce") + nonce = nonce_raw if isinstance(nonce_raw, int) and not isinstance(nonce_raw, bool) else -1 + + source_env_raw = raw.get("source_env") + source_env = source_env_raw if isinstance(source_env_raw, str) else "" + + source_chain_id_raw = raw.get("source_chain_id") + source_chain_id = ( + source_chain_id_raw + if isinstance(source_chain_id_raw, int) and not isinstance(source_chain_id_raw, bool) + else 0 + ) + + source_tx_raw = raw.get("source_tx") + source_tx = source_tx_raw if isinstance(source_tx_raw, str) else "" + + source_ts = self._coerce_timestamp_seconds(raw.get("source_ts")) or 0 + + target_env_raw = raw.get("target_env") + target_env = target_env_raw if isinstance(target_env_raw, str) else "" + + target_chain_id_raw = raw.get("target_chain_id") + target_chain_id = ( + target_chain_id_raw + if isinstance(target_chain_id_raw, int) and not isinstance(target_chain_id_raw, bool) + else 0 + ) + + target_tx_raw = raw.get("target_tx") + target_tx = target_tx_raw if isinstance(target_tx_raw, str) else "" + + target_ts = self._coerce_timestamp_seconds(raw.get("target_ts")) or 0 + + return Transfer( + action_type=action_type, + status=status, + symbol=symbol_raw, + quantity=quantity, + fee=fee, + trader_address=trader_address, + bridge=bridge, + bridge_url=bridge_url, + nonce=nonce, + source_env=source_env, + source_chain_id=source_chain_id, + source_tx=source_tx, + source_ts=source_ts, + target_env=target_env, + target_chain_id=target_chain_id, + target_tx=target_tx, + target_ts=target_ts, + ) + + @track_method("transfer") + async def get_combined_transfers( + self, + *, + kind: str | None = None, + from_ts: int | None = None, + to_ts: int | None = None, + limit: int = 100, + offset: int = 0, + ) -> Result[list[Transfer]]: + """Paginated history of every deposit, withdrawal, gas top-up, + portfolio P2P send/receive, and bridge recovery involving the + connected wallet. + + Routes through the signed REST endpoint + ``/api/trading/signed/transferscombined``; ``x-signature`` is + attached via :meth:`_get_auth_headers`. Returns canonical + :class:`Transfer` rows with snake_case fields and human-readable + ``action_type`` / ``status`` / ``bridge`` labels lifted from the + backend's numeric enums. + + Backend pagination uses ``itemsperpage`` / ``pageno`` (NOT + ``limit`` / ``offset``); the SDK accepts the more conventional + ``limit`` / ``offset`` signature and translates internally + (``pageno = (offset // limit) + 1``). + + Cached for 10 seconds (balance tier) per + ``(address, kind, from_ts, to_ts, limit, offset)`` tuple — + distinct signers and distinct filter combinations never share a + cache slot. Returned ``quantity`` / ``fee`` are already display- + decimal — no wei→human conversion is applied because the backend + has already done it. + + Args: + kind: Optional token symbol filter (forwarded to backend as + ``symbol``). Normalised through :meth:`_normalize_user_token`. + from_ts: Optional unix-seconds lower bound (forwarded as + ``periodfrom``). + to_ts: Optional unix-seconds upper bound (forwarded as + ``periodto``). + limit: Page size (forwarded as ``itemsperpage``, default 100). + offset: Row offset (translated to ``pageno`` = ``offset // limit + 1``, + default 0 → ``pageno=1``). + """ + if not self.account: + return Result.fail("get_combined_transfers requires a configured wallet") + + try: + address = cast(str, cast(Any, self.account).address) + except Exception as e: + return Result.fail(self._sanitize_error(e, "resolving wallet address")) + + normalized_symbol: str | None = None + if kind is not None: + normalized_symbol = self._normalize_user_token(kind) + + # Translate (limit, offset) → (itemsperpage, pageno). The backend + # uses 1-indexed pages; offset 0 → page 1. Defensive ``max(1, ...)`` + # on items-per-page avoids a ZeroDivisionError on a caller-supplied + # ``limit=0`` while still surfacing it as a likely-empty page. + items_per_page = max(1, int(limit)) + page_no = (int(offset) // items_per_page) + 1 + + return cast( + Result[list[Transfer]], + await self._get_combined_transfers_cached( + address, + normalized_symbol, + from_ts, + to_ts, + items_per_page, + page_no, + ), + ) + + @async_ttl_cached(_BALANCE_CACHE) + async def _get_combined_transfers_cached( + self, + address: str, + symbol: str | None, + period_from: int | None, + period_to: int | None, + items_per_page: int, + page_no: int, + ) -> Result[list[Transfer]]: + """Internal cached implementation of get_combined_transfers. + + Cache key is namespaced by resolved address so signer swaps within + a single client never collide on the same slot, and by every + translated opt so different filter combinations are distinct. + """ + try: + headers = self._get_auth_headers() + except Exception as e: + return Result.fail(self._sanitize_error(e, "fetching combined transfers")) + + params: dict[str, Any] = { + "itemsperpage": items_per_page, + "pageno": page_no, + } + if symbol is not None: + params["symbol"] = symbol + if period_from is not None: + params["periodfrom"] = period_from + if period_to is not None: + params["periodto"] = period_to + + try: + data = await self._api_call( + "get", + f"{self.api_base_url}{ENDPOINT_TRADING_COMBINED_TRANSFERS}", + headers=headers, + params=params, + ) + except Exception as e: + return Result.fail(self._sanitize_error(e, "fetching combined transfers")) + + # Backend ships ``{count, rows}``; we tolerate a bare array as a + # forward-compat fallback. + if isinstance(data, list): + rows: list[Any] = data + elif isinstance(data, dict): + envelope_rows = data.get("rows") + rows = envelope_rows if isinstance(envelope_rows, list) else [] + else: + return Result.fail( + f"Unexpected transfers response shape: expected object or array, got {type(data).__name__}." + ) + + transfers: list[Transfer] = [] + for row in rows: + normalized = self._normalize_transfer(row) + if normalized is not None: + transfers.append(normalized) + return Result.ok(transfers) diff --git a/tests/unit/core/test_transfer.py b/tests/unit/core/test_transfer.py index 38e775e..c43838e 100644 --- a/tests/unit/core/test_transfer.py +++ b/tests/unit/core/test_transfer.py @@ -2578,3 +2578,357 @@ def test_coerce_timestamp_seconds_edge_cases(self): assert TransferClient._coerce_timestamp_seconds("1700000000") == 1700000000 # plain number passthrough assert TransferClient._coerce_timestamp_seconds(1700000000) == 1700000000 + + # ---------------------------------------------------------------------- + # get_combined_transfers — signed REST /api/trading/signed/transferscombined + # ---------------------------------------------------------------------- + # Mirrors TypeScript SDK commit f7a9945. + + async def test_get_combined_transfers_no_wallet_returns_fail(self, client): + """No configured account → Result.fail without any REST call.""" + client.account = None + api_spy = AsyncMock(return_value={"count": 0, "rows": []}) + with patch.object(client, "_api_call", api_spy): + result = await client.get_combined_transfers() + assert not result.success + assert "wallet" in result.error.lower() + api_spy.assert_not_called() + + async def test_get_combined_transfers_basic_envelope(self, client): + """Envelope ``{count, rows}`` parsed, each row normalized to Transfer.""" + from dexalot_sdk.core.transfer import Transfer + + api_spy = AsyncMock( + return_value={ + "count": 1, + "rows": [ + { + "action_type": 1, + "status": 0, + "symbol": "USDC", + "quantity": "100.5", + "fee": "0.25", + "traderaddress": VALID_ADDRESS, + "bridge": 2, + "bridge_url": "https://bridge.example/x", + "nonce": 7, + "source_env": "fuji-multi-avax", + "source_chain_id": 43113, + "source_tx": "0xabc", + "source_ts": 1700000000, + "target_env": "fuji-multi-subnet", + "target_chain_id": 12345, + "target_tx": "0xdef", + "target_ts": 1700000010, + } + ], + } + ) + with patch.object(client, "_api_call", api_spy): + result = await client.get_combined_transfers() + assert result.success + assert len(result.data) == 1 + t = result.data[0] + assert isinstance(t, Transfer) + assert t.action_type == "DEPOSITED" + assert t.status == "COMPLETED" + assert t.bridge == "ICM" + assert t.symbol == "USDC" + assert t.quantity == 100.5 + assert t.fee == 0.25 + assert t.nonce == 7 + assert t.source_chain_id == 43113 + assert t.target_chain_id == 12345 + + async def test_get_combined_transfers_bare_array_fallback(self, client): + """Bare list response also accepted as forward-compat.""" + api_spy = AsyncMock( + return_value=[ + { + "action_type": 0, + "status": 1, + "symbol": "ALOT", + "quantity": "1.0", + "fee": "0", + "traderaddress": VALID_ADDRESS, + "bridge": -1, + "source_chain_id": 12345, + } + ] + ) + with patch.object(client, "_api_call", api_spy): + result = await client.get_combined_transfers() + assert result.success + assert len(result.data) == 1 + assert result.data[0].action_type == "WITHDRAWN" + assert result.data[0].status == "INFLIGHT" + assert result.data[0].bridge == "NATIVE" + + async def test_get_combined_transfers_unexpected_shape_fails(self, client): + """Non-object, non-array response surfaces Result.fail.""" + api_spy = AsyncMock(return_value="bad") + with patch.object(client, "_api_call", api_spy): + result = await client.get_combined_transfers() + assert not result.success + assert "transfers response shape" in result.error + + async def test_get_combined_transfers_empty_rows(self, client): + """Empty rows list returns Result.ok([]).""" + api_spy = AsyncMock(return_value={"count": 0, "rows": []}) + with patch.object(client, "_api_call", api_spy): + result = await client.get_combined_transfers() + assert result.success + assert result.data == [] + + async def test_get_combined_transfers_envelope_missing_rows(self, client): + """``{count}`` without ``rows`` is tolerated as empty list.""" + api_spy = AsyncMock(return_value={"count": 0}) + with patch.object(client, "_api_call", api_spy): + result = await client.get_combined_transfers() + assert result.success + assert result.data == [] + + async def test_get_combined_transfers_drops_unknown_enums(self, client): + """Rows with unknown numeric action_type / status are silently dropped.""" + api_spy = AsyncMock( + return_value={ + "count": 4, + "rows": [ + { + "action_type": 99, + "status": 0, + "symbol": "X", + "quantity": "1", + "traderaddress": VALID_ADDRESS, + }, + { + "action_type": 1, + "status": 99, + "symbol": "X", + "quantity": "1", + "traderaddress": VALID_ADDRESS, + }, + { + "action_type": 1, + "status": 0, + "symbol": "GOOD", + "quantity": "5", + "traderaddress": VALID_ADDRESS, + "bridge": 999, # Unknown bridge → falls back to NATIVE + }, + None, + ], + } + ) + with patch.object(client, "_api_call", api_spy): + result = await client.get_combined_transfers() + assert result.success + assert len(result.data) == 1 + assert result.data[0].symbol == "GOOD" + assert result.data[0].bridge == "NATIVE" + + async def test_get_combined_transfers_drops_missing_required_fields(self, client): + """Rows missing action_type / status / symbol / quantity dropped.""" + api_spy = AsyncMock( + return_value=[ + {"status": 0, "symbol": "X", "quantity": "1"}, # missing action_type + {"action_type": 1, "symbol": "X", "quantity": "1"}, # missing status + {"action_type": 1, "status": 0, "quantity": "1"}, # missing symbol + {"action_type": 1, "status": 0, "symbol": "X"}, # missing quantity + { + "action_type": 1, + "status": 0, + "symbol": "X", + "quantity": "abc", # non-numeric quantity + }, + { + "action_type": "1", # action_type as string (not numeric) + "status": 0, + "symbol": "X", + "quantity": "1", + }, + { + "action_type": 1, + "status": "0", # status as string (not numeric) + "symbol": "X", + "quantity": "1", + }, + { + "action_type": 1, + "status": 0, + "symbol": 42, # symbol not a string + "quantity": "1", + }, + { + "action_type": 1, + "status": 0, + "symbol": "GOOD", + "quantity": "1", + }, + ] + ) + with patch.object(client, "_api_call", api_spy): + result = await client.get_combined_transfers() + assert result.success + assert len(result.data) == 1 + assert result.data[0].symbol == "GOOD" + + async def test_get_combined_transfers_quantity_and_fee_numeric_or_string(self, client): + """quantity/fee accepted as either numeric string or numeric.""" + api_spy = AsyncMock( + return_value=[ + { + "action_type": 1, + "status": 0, + "symbol": "X", + "quantity": 12.5, + "fee": 0.01, + "traderaddress": VALID_ADDRESS, + } + ] + ) + with patch.object(client, "_api_call", api_spy): + result = await client.get_combined_transfers() + assert result.success + assert result.data[0].quantity == 12.5 + assert result.data[0].fee == 0.01 + + async def test_get_combined_transfers_fee_missing_defaults_zero(self, client): + """fee absent → defaults to 0.0.""" + api_spy = AsyncMock( + return_value=[ + { + "action_type": 1, + "status": 0, + "symbol": "X", + "quantity": "10", + "traderaddress": VALID_ADDRESS, + } + ] + ) + with patch.object(client, "_api_call", api_spy): + result = await client.get_combined_transfers() + assert result.success + assert result.data[0].fee == 0.0 + + async def test_get_combined_transfers_default_pagination(self, client): + """Defaults: limit=100 → itemsperpage=100, offset=0 → pageno=1.""" + api_spy = AsyncMock(return_value={"count": 0, "rows": []}) + with patch.object(client, "_api_call", api_spy): + await client.get_combined_transfers() + _, kwargs = api_spy.call_args + assert kwargs["params"]["itemsperpage"] == 100 + assert kwargs["params"]["pageno"] == 1 + + async def test_get_combined_transfers_custom_pagination_translation(self, client): + """limit → itemsperpage, offset → pageno (offset/limit = page index, 1-indexed).""" + api_spy = AsyncMock(return_value={"count": 0, "rows": []}) + with patch.object(client, "_api_call", api_spy): + await client.get_combined_transfers(limit=50, offset=100) + _, kwargs = api_spy.call_args + # offset 100 with limit 50 → page 3 (1-indexed) + assert kwargs["params"]["itemsperpage"] == 50 + assert kwargs["params"]["pageno"] == 3 + + async def test_get_combined_transfers_forwards_kind_and_period(self, client): + """kind → symbol; from_ts → periodfrom; to_ts → periodto.""" + api_spy = AsyncMock(return_value={"count": 0, "rows": []}) + with patch.object(client, "_api_call", api_spy): + await client.get_combined_transfers( + kind="ALOT", from_ts=1700000000, to_ts=1700864000 + ) + _, kwargs = api_spy.call_args + assert kwargs["params"]["symbol"] == "ALOT" + assert kwargs["params"]["periodfrom"] == 1700000000 + assert kwargs["params"]["periodto"] == 1700864000 + + async def test_get_combined_transfers_attaches_signature_header(self, client): + """``x-signature`` header attached via _get_auth_headers.""" + api_spy = AsyncMock(return_value={"count": 0, "rows": []}) + with patch.object(client, "_api_call", api_spy): + with patch.object( + client, "_get_auth_headers", return_value={"x-signature": "sig"} + ): + await client.get_combined_transfers() + _, kwargs = api_spy.call_args + assert kwargs["headers"] == {"x-signature": "sig"} + + async def test_get_combined_transfers_endpoint_path(self, client): + """Path is /api/trading/signed/transferscombined (NOT under /privapi/).""" + api_spy = AsyncMock(return_value={"count": 0, "rows": []}) + with patch.object(client, "_api_call", api_spy): + await client.get_combined_transfers() + args, _ = api_spy.call_args + assert "/api/trading/signed/transferscombined" in args[1] + assert "/privapi/" not in args[1] + + async def test_get_combined_transfers_signer_rejection_returns_fail(self, client): + """Auth header generation failure surfaces as Result.fail.""" + api_spy = AsyncMock(return_value={"count": 0, "rows": []}) + with patch.object(client, "_api_call", api_spy): + with patch.object( + client, + "_get_auth_headers", + side_effect=Exception("Private key not configured."), + ): + result = await client.get_combined_transfers() + assert not result.success + + async def test_get_combined_transfers_api_error(self, client): + """REST failure surfaced as Result.fail.""" + api_spy = AsyncMock(side_effect=RuntimeError("network boom")) + with patch.object(client, "_api_call", api_spy): + result = await client.get_combined_transfers() + assert not result.success + + async def test_get_combined_transfers_cache_distinct_per_address_and_opts(self, client): + """Cache key includes (address, all opts); distinct combos do not collide.""" + client._cache_enabled = True + api_spy = AsyncMock(return_value={"count": 0, "rows": []}) + with patch.object(client, "_api_call", api_spy): + await client.get_combined_transfers() + await client.get_combined_transfers() # cache hit (same addr + opts) + await client.get_combined_transfers(kind="ALOT") # distinct slot + await client.get_combined_transfers(limit=50) # distinct slot + # Change address → distinct slot + other_addr = "0x" + "b" * 40 + client.account.address = other_addr + await client.get_combined_transfers() + + assert api_spy.await_count == 4 + + async def test_get_combined_transfers_cache_bypass_when_disabled(self, client): + """When _cache_enabled is False every call hits REST.""" + client._cache_enabled = False + api_spy = AsyncMock(return_value={"count": 0, "rows": []}) + with patch.object(client, "_api_call", api_spy): + await client.get_combined_transfers() + await client.get_combined_transfers() + assert api_spy.await_count == 2 + + async def test_get_combined_transfers_normalizes_user_symbol(self, client): + """``kind`` is run through _normalize_user_token before being forwarded.""" + api_spy = AsyncMock(return_value={"count": 0, "rows": []}) + with patch.object( + client, "_normalize_user_token", return_value="USDC" + ) as norm_spy: + with patch.object(client, "_api_call", api_spy): + await client.get_combined_transfers(kind="usdc") + norm_spy.assert_called_once_with("usdc") + _, kwargs = api_spy.call_args + assert kwargs["params"]["symbol"] == "USDC" + + async def test_get_combined_transfers_address_lookup_failure(self, client): + """A signer that raises when address is read surfaces Result.fail.""" + + class BrokenAccount: + @property + def address(self): # type: ignore[no-untyped-def] + raise RuntimeError("signer is broken") + + client.account = BrokenAccount() + api_spy = AsyncMock(return_value={"count": 0, "rows": []}) + with patch.object(client, "_api_call", api_spy): + result = await client.get_combined_transfers() + assert not result.success + api_spy.assert_not_called() From b3bf21982b6bc16bba98c912255f50b192dcc08c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=BCseyin=20Deniz?= Date: Tue, 2 Jun 2026 21:40:04 +0300 Subject: [PATCH 06/10] feat(clob): add get_order_history for paginated per-account history Signed GET; returns canonical Order list (same shape as get_open_orders). Accepts pair/status/limit/offset filters and optional explicit account argument. Balance-tier cached (10s). Reuses _transform_order_from_api and _get_auth_headers. Mirrors TypeScript SDK commit ece5dcd. Co-Authored-By: Claude Opus 4.7 --- src/dexalot_sdk/constants.py | 11 ++ src/dexalot_sdk/core/clob.py | 152 ++++++++++++++++- tests/unit/core/test_clob.py | 312 +++++++++++++++++++++++++++++++++++ 3 files changed, 474 insertions(+), 1 deletion(-) diff --git a/src/dexalot_sdk/constants.py b/src/dexalot_sdk/constants.py index 93d6356..f199ec7 100644 --- a/src/dexalot_sdk/constants.py +++ b/src/dexalot_sdk/constants.py @@ -39,6 +39,17 @@ def ws_api_url_for_rest_base(rest_api_base_url: str | None) -> str: ENDPOINT_TRADING_TOKENS = "/privapi/trading/tokens" ENDPOINT_TRADING_DEPLOYMENT = "/privapi/trading/deployment" ENDPOINT_SIGNED_ORDERS = "/privapi/signed/orders" +# Paginated per-account order history (any status) under the +# `/api/trading/signed/` mountpoint. Requires the `x-signature` header. +# Distinct from `ENDPOINT_SIGNED_ORDERS` above — `/privapi/signed/orders` +# returns the currently-open orders for the connected wallet (used by +# `get_open_orders`), while `/api/trading/signed/orders` returns the +# full historical order list (any status, paginated, supports +# `pair` / `status` / `limit` / `offset` filters and an explicit +# `traderaddress`). The trade-kit's `clob_get_orders_by_account` tool +# hits this path via its `signedGet("orders", ...)` helper which mounts +# at `${baseUrl}/trading/signed/`. +ENDPOINT_TRADING_SIGNED_ORDERS_HISTORY = "/api/trading/signed/orders" ENDPOINT_RFQ_PAIRS = "/api/rfq/pairs" ENDPOINT_RFQ_FIRM_QUOTE = "/api/rfq/firmQuote" ENDPOINT_RFQ_PAIR_PRICE = "/api/rfq/pairprice" diff --git a/src/dexalot_sdk/core/clob.py b/src/dexalot_sdk/core/clob.py index 64bc645..69c59b5 100644 --- a/src/dexalot_sdk/core/clob.py +++ b/src/dexalot_sdk/core/clob.py @@ -7,6 +7,7 @@ ENDPOINT_STATS_MARKET_SNAPSHOT, ENDPOINT_TRADING_CANDLE_CHUNK, ENDPOINT_TRADING_PAIRS, + ENDPOINT_TRADING_SIGNED_ORDERS_HISTORY, ENV_FUJI_MULTI_SUBNET, ENV_PROD_MULTI_SUBNET, ws_api_url_for_rest_base, @@ -22,7 +23,7 @@ from ..utils.result import Result from ..utils.retry import async_retry from ..utils.websocket_manager import WebSocketManager -from .base import _ORDERBOOK_CACHE, _SEMI_STATIC_CACHE, DexalotBaseClient +from .base import _BALANCE_CACHE, _ORDERBOOK_CACHE, _SEMI_STATIC_CACHE, DexalotBaseClient _CANDLE_INTERVALS: dict[str, tuple[int, str]] = { "1m": (1, "minute"), @@ -1109,6 +1110,155 @@ async def get_open_orders(self, pair: str | None = None) -> Result[list]: error_msg = self._sanitize_error(e, "fetching open orders") return Result.fail(error_msg) + @track_method("clob") + async def get_order_history( + self, + account: str | None = None, + *, + pair: str | None = None, + status: str | None = None, + limit: int = 100, + offset: int = 0, + ) -> Result[list]: + """Paginated per-account order history (any status: ``NEW``, + ``REJECTED``, ``PARTIAL``, ``FILLED``, ``CANCELED``, ``EXPIRED``, + ``KILLED``). + + Returns canonical order dicts in the same shape as + :meth:`get_open_orders` — rows pass through the shared + :meth:`_transform_order_from_api` helper so callers can mix the + two result sets without re-normalising. + + Routes through the signed REST endpoint + ``/api/trading/signed/orders`` (distinct from ``get_open_orders``' + ``/privapi/signed/orders`` which only returns currently-open + rows). The trade-kit's ``clob_get_orders_by_account`` tool calls + the same endpoint via ``signedGet("orders", ...)`` and provided + the empirical verification for the path + query shape used here. + + Cached for 10 seconds (balance tier) per ``(address, opts)`` + tuple. The resolved address is either the explicit ``account`` + argument or the connected wallet address; distinct addresses + and distinct filter combinations never share a cache slot. + + When no wallet is configured AND no explicit ``account`` is + passed, returns ``Result.fail`` — the call has no addressee. + When a wallet IS configured, the ``x-signature`` header is + attached via the shared :meth:`_get_auth_headers` helper; when + only an explicit account is supplied (no signer), the auth + header is omitted and the backend may reject — this lets + read-only callers query by address without forcing a key, + mirroring the SDK's general read-only ergonomics. + + Args: + account: Optional trader address; defaults to the connected + wallet's address. At least one must be available. + pair: Optional pair filter, e.g. ``"AVAX/USDC"``. + status: Optional status filter (one of ``"NEW"``, + ``"REJECTED"``, ``"PARTIAL"``, ``"FILLED"``, + ``"CANCELED"``, ``"EXPIRED"``, ``"KILLED"``); any other + string is forwarded verbatim for forward-compat. + limit: Page size (default 100). + offset: Row offset (default 0). + """ + if account is not None: + address = account + elif self.account: + try: + address = cast(str, cast(Any, self.account).address) + except Exception as e: + return Result.fail(self._sanitize_error(e, "resolving wallet address")) + else: + return Result.fail( + "get_order_history requires either an account argument or a configured wallet" + ) + + if pair is not None: + pair_result = validate_pair_format(pair, "pair") + if not pair_result.success: + return cast(Result[list[Any]], pair_result) + pair = self._normalize_user_pair(pair) + + return cast( + Result[list], + await self._get_order_history_cached(address, pair, status, limit, offset), + ) + + @async_ttl_cached(_BALANCE_CACHE) + async def _get_order_history_cached( + self, + address: str, + pair: str | None, + status: str | None, + limit: int, + offset: int, + ) -> Result[list]: + """Internal cached implementation of :meth:`get_order_history`. + + Cache key is namespaced by resolved address so signer swaps within + a single client never collide on the same slot, and by every opt + so different filter combinations are distinct. + """ + headers: dict[str, str] = {} + if self.account: + try: + headers = self._get_auth_headers() + except Exception as e: + return Result.fail(self._sanitize_error(e, "fetching order history")) + + params: dict[str, Any] = { + "traderaddress": address, + "limit": limit, + "offset": offset, + } + if pair is not None: + params["pair"] = pair + if status is not None: + params["status"] = status + + try: + data = await self._api_call( + "get", + f"{self.api_base_url}{ENDPOINT_TRADING_SIGNED_ORDERS_HISTORY}", + headers=headers, + params=params, + ) + except Exception as e: + return Result.fail(self._sanitize_error(e, "fetching order history")) + + # Backend ships either a bare list or ``{count, rows}``; we + # tolerate both, and additionally wrap a single non-list dict + # response as a one-row result for forward-compat parity with + # ``get_open_orders``. + orders: list[Any] + if isinstance(data, list): + orders = data + elif isinstance(data, dict): + envelope_rows = data.get("rows") + if isinstance(envelope_rows, list): + orders = envelope_rows + else: + orders = [data] + else: + return Result.fail( + "Unexpected order history response shape: expected object or array, " + f"got {type(data).__name__}." + ) + + # If any row is missing pair info, hydrate the pairs cache so + # :meth:`_resolve_pair_from_order` / :meth:`_resolve_trade_pair_id_from_pair` + # can fill the canonical fields. Mirrors ``get_open_orders``. + if orders and any( + (o.get("trade_pair_id") or o.get("tradePairId") or o.get("tradepairid")) is None + for o in orders + ): + pairs_result = await self.get_clob_pairs() + if not pairs_result.success: + return Result.fail(pairs_result.error or "failed to hydrate pairs") + + transformed = [self._transform_order_from_api(order) for order in orders] + return Result.ok(transformed) + @track_method("clob") async def get_order(self, order_id: str | bytes) -> Result[dict]: """Fetch the details of an order by its Internal ID or Client Order ID. diff --git a/tests/unit/core/test_clob.py b/tests/unit/core/test_clob.py index ee1bb11..420c2c2 100644 --- a/tests/unit/core/test_clob.py +++ b/tests/unit/core/test_clob.py @@ -1057,6 +1057,318 @@ async def test_transform_order_from_api_unconvertible_number(self, client): assert transformed["quantity"] is None assert transformed["total_amount"] is None + # ---------------------------------------------------------------------- + # get_order_history — signed REST /api/trading/signed/orders + # ---------------------------------------------------------------------- + # Mirrors TypeScript SDK commit ece5dcd. Distinct from + # /privapi/signed/orders (get_open_orders, open-only). Same canonical + # Order shape via the shared _transform_order_from_api helper. + + def _make_api_order_row(self, **overrides): + """Build a raw REST-API order row (lowercase aliases + numeric enums).""" + base = { + "id": "0x" + "a" * 64, + "clientordid": "0x" + "b" * 64, + "tradepairid": "0xPairId_AVAX_USDC", + "price": "100", + "quantity": "1.5", + "quantityfilled": "0.5", + "status": 4, # CANCELED + "side": 1, # SELL + "type1": 1, # LIMIT + "type2": 0, # GTC + "pair": "AVAX/USDC", + "totalamount": "150", + "totalfee": "0.1", + "traderaddress": VALID_ADDRESS, + "createBlock": 100, + "updateBlock": 101, + "timestamp": "2024-01-01T00:00:00.000Z", + "update_ts": "2024-01-01T00:01:00.000Z", + "tx": "0xfeed", + } + base.update(overrides) + return base + + async def test_get_order_history_no_wallet_and_no_account_returns_fail(self, client): + """No configured account and no explicit account → Result.fail without any REST call.""" + client.account = None + api_spy = AsyncMock(return_value={"count": 0, "rows": []}) + with patch.object(client, "_api_call", api_spy): + result = await client.get_order_history() + assert not result.success + assert "get_order_history" in result.error + api_spy.assert_not_called() + + async def test_get_order_history_envelope_returns_canonical_orders(self, client): + """``{count, rows}`` envelope parsed; each row normalized to canonical Order.""" + api_spy = AsyncMock( + return_value={"count": 1, "rows": [self._make_api_order_row()]} + ) + with patch.object(client, "_api_call", api_spy): + result = await client.get_order_history() + assert result.success + assert len(result.data) == 1 + order = result.data[0] + assert order["internal_order_id"] == "0x" + "a" * 64 + assert order["client_order_id"] == "0x" + "b" * 64 + assert order["trade_pair_id"] == "0xPairId_AVAX_USDC" + assert order["pair"] == "AVAX/USDC" + assert order["price"] == 100.0 + assert order["total_amount"] == 150.0 + assert order["quantity"] == 1.5 + assert order["quantity_filled"] == 0.5 + assert order["total_fee"] == 0.1 + assert order["trader_address"] == VALID_ADDRESS + assert order["side"] == "SELL" + assert order["type1"] == "LIMIT" + assert order["type2"] == "GTC" + assert order["status"] == "CANCELED" + assert order["create_block"] == 100 + assert order["update_block"] == 101 + assert order["create_ts"] == "2024-01-01T00:00:00.000Z" + assert order["update_ts"] == "2024-01-01T00:01:00.000Z" + assert order["tx"] == "0xfeed" + # raw aliases stripped + assert "id" not in order + assert "clientordid" not in order + assert "tradepairid" not in order + + async def test_get_order_history_bare_list_response(self, client): + """Bare list response also accepted as forward-compat.""" + api_spy = AsyncMock(return_value=[self._make_api_order_row()]) + with patch.object(client, "_api_call", api_spy): + result = await client.get_order_history() + assert result.success + assert len(result.data) == 1 + assert result.data[0]["pair"] == "AVAX/USDC" + + async def test_get_order_history_empty_rows(self, client): + """Empty rows list returns Result.ok([]).""" + api_spy = AsyncMock(return_value={"count": 0, "rows": []}) + with patch.object(client, "_api_call", api_spy): + result = await client.get_order_history() + assert result.success + assert result.data == [] + + async def test_get_order_history_single_dict_wrapped(self, client): + """A single non-array object response is wrapped as a one-row result.""" + api_spy = AsyncMock(return_value=self._make_api_order_row()) + with patch.object(client, "_api_call", api_spy): + result = await client.get_order_history() + assert result.success + assert len(result.data) == 1 + assert result.data[0]["pair"] == "AVAX/USDC" + + async def test_get_order_history_unexpected_shape_fails(self, client): + """Non-object, non-array response surfaces Result.fail.""" + api_spy = AsyncMock(return_value="just a string") + with patch.object(client, "_api_call", api_spy): + result = await client.get_order_history() + assert not result.success + assert "order history response shape" in result.error + + async def test_get_order_history_explicit_account_overrides_wallet(self, client): + """Explicit account argument overrides the connected wallet's address.""" + other_addr = "0x" + "c" * 40 + api_spy = AsyncMock(return_value={"count": 0, "rows": []}) + with patch.object(client, "_api_call", api_spy): + await client.get_order_history(other_addr) + _, kwargs = api_spy.call_args + assert kwargs["params"]["traderaddress"] == other_addr + + async def test_get_order_history_no_wallet_but_explicit_account(self, client): + """Without a signer but with an explicit account, the call still works + (auth header is omitted; backend may reject — SDK doesn't guard).""" + client.account = None + api_spy = AsyncMock(return_value={"count": 0, "rows": []}) + with patch.object(client, "_api_call", api_spy): + result = await client.get_order_history("0x" + "d" * 40) + assert result.success + assert result.data == [] + _, kwargs = api_spy.call_args + # No auth header when no signer + assert kwargs["headers"] == {} + assert kwargs["params"]["traderaddress"] == "0x" + "d" * 40 + + async def test_get_order_history_forwards_filters(self, client): + """pair / status / limit / offset all forwarded as query params.""" + api_spy = AsyncMock(return_value={"count": 0, "rows": []}) + with patch.object(client, "_api_call", api_spy): + await client.get_order_history( + pair="AVAX/USDC", status="FILLED", limit=25, offset=50 + ) + _, kwargs = api_spy.call_args + params = kwargs["params"] + assert params["traderaddress"] == VALID_ADDRESS + assert params["pair"] == "AVAX/USDC" + assert params["status"] == "FILLED" + assert params["limit"] == 25 + assert params["offset"] == 50 + + async def test_get_order_history_default_pagination(self, client): + """Defaults: limit=100, offset=0.""" + api_spy = AsyncMock(return_value={"count": 0, "rows": []}) + with patch.object(client, "_api_call", api_spy): + await client.get_order_history() + _, kwargs = api_spy.call_args + assert kwargs["params"]["limit"] == 100 + assert kwargs["params"]["offset"] == 0 + + async def test_get_order_history_endpoint_path(self, client): + """Path is /api/trading/signed/orders (NOT /privapi/signed/orders).""" + api_spy = AsyncMock(return_value={"count": 0, "rows": []}) + with patch.object(client, "_api_call", api_spy): + await client.get_order_history() + args, _ = api_spy.call_args + assert "/api/trading/signed/orders" in args[1] + assert "/privapi/" not in args[1] + + async def test_get_order_history_attaches_auth_header_when_signer_present(self, client): + """x-signature header attached via _get_auth_headers when a signer exists.""" + api_spy = AsyncMock(return_value={"count": 0, "rows": []}) + with patch.object(client, "_api_call", api_spy): + with patch.object( + client, "_get_auth_headers", return_value={"x-signature": "sig"} + ): + await client.get_order_history() + _, kwargs = api_spy.call_args + assert kwargs["headers"] == {"x-signature": "sig"} + + async def test_get_order_history_signer_rejection_returns_fail(self, client): + """Auth header generation failure surfaces as Result.fail.""" + api_spy = AsyncMock(return_value={"count": 0, "rows": []}) + with patch.object(client, "_api_call", api_spy): + with patch.object( + client, + "_get_auth_headers", + side_effect=Exception("Private key not configured."), + ): + result = await client.get_order_history() + assert not result.success + api_spy.assert_not_called() + + async def test_get_order_history_api_error_preserves_reason_code(self, client): + """REST failure (with backend reason code) surfaced as Result.fail.""" + api_spy = AsyncMock(side_effect=RuntimeError("FQ-123: upstream down")) + with patch.object(client, "_api_call", api_spy): + result = await client.get_order_history() + assert not result.success + assert "FQ-123" in result.error + + async def test_get_order_history_address_lookup_failure(self, client): + """A signer that raises when address is read surfaces Result.fail.""" + + class BrokenAccount: + @property + def address(self): + raise RuntimeError("signer is broken") + + client.account = BrokenAccount() + api_spy = AsyncMock(return_value={"count": 0, "rows": []}) + with patch.object(client, "_api_call", api_spy): + result = await client.get_order_history() + assert not result.success + api_spy.assert_not_called() + + async def test_get_order_history_invalid_pair_returns_fail(self, client): + """Malformed pair filter rejected before any REST call.""" + api_spy = AsyncMock(return_value={"count": 0, "rows": []}) + with patch.object(client, "_api_call", api_spy): + result = await client.get_order_history(pair="INVALID") + assert not result.success + assert "pair" in result.error + api_spy.assert_not_called() + + async def test_get_order_history_hydrates_pairs_when_row_missing_pair_info(self, client): + """Row without trade_pair_id triggers a get_clob_pairs() hydration.""" + from dexalot_sdk.utils.result import Result + + client.pairs = {} + get_pairs_spy = AsyncMock(return_value=Result.ok([{"pair": "AVAX/USDC"}])) + api_spy = AsyncMock( + return_value={ + "count": 1, + "rows": [ + { + # No tradePairId — forces hydration + "id": "0x" + "a" * 64, + "pair": "AVAX/USDC", + "price": "1", + "quantity": "1", + "side": 0, + "type1": 1, + "type2": 0, + "status": 3, + "createBlock": 1, + "updateBlock": 2, + } + ], + } + ) + with patch.object(client, "_api_call", api_spy): + with patch.object(client, "get_clob_pairs", get_pairs_spy): + result = await client.get_order_history() + assert result.success + assert len(result.data) == 1 + get_pairs_spy.assert_awaited_once() + + async def test_get_order_history_pair_hydration_failure_propagates(self, client): + """If hydration of pairs fails, error is propagated.""" + from dexalot_sdk.utils.result import Result + + client.pairs = {} + get_pairs_spy = AsyncMock(return_value=Result.fail("pair fetch down")) + api_spy = AsyncMock( + return_value={ + "count": 1, + "rows": [ + { + # No tradePairId — forces hydration + "id": "0x" + "a" * 64, + "price": "1", + "quantity": "1", + "side": 0, + "type1": 1, + "type2": 0, + "status": 3, + "createBlock": 1, + "updateBlock": 2, + } + ], + } + ) + with patch.object(client, "_api_call", api_spy): + with patch.object(client, "get_clob_pairs", get_pairs_spy): + result = await client.get_order_history() + assert not result.success + assert "pair fetch down" in result.error + + async def test_get_order_history_caches_per_address_and_opts(self, client): + """Cache key includes (address, all opts); distinct combos do not collide.""" + client._cache_enabled = True + api_spy = AsyncMock(return_value={"count": 0, "rows": []}) + with patch.object(client, "_api_call", api_spy): + await client.get_order_history() + await client.get_order_history() # cache hit (same addr + opts) + await client.get_order_history(pair="AVAX/USDC") # distinct slot + await client.get_order_history(status="FILLED") # distinct slot + await client.get_order_history(limit=50) # distinct slot + await client.get_order_history(offset=10) # distinct slot + # Change address → distinct slot + other_addr = "0x" + "e" * 40 + await client.get_order_history(other_addr) + assert api_spy.await_count == 6 + + async def test_get_order_history_cache_bypass_when_disabled(self, client): + """When _cache_enabled is False every call hits REST.""" + client._cache_enabled = False + api_spy = AsyncMock(return_value={"count": 0, "rows": []}) + with patch.object(client, "_api_call", api_spy): + await client.get_order_history() + await client.get_order_history() + assert api_spy.await_count == 2 + async def test_cancel_all_orders(self, client): """Test cancel_all_orders.""" # Mock get_open_orders From 493527d8cb8a6b2aa299c02ac3d5440b88fb6853 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=BCseyin=20Deniz?= Date: Tue, 2 Jun 2026 21:41:02 +0300 Subject: [PATCH 07/10] docs(readme): document 5 new methods + get_deployment filters + error preservation Adds new methods to Cached Methods lists, type-normalization entries for PricePoint + Transfer, an Error Handling note on preserved backend reason codes, and a get_order_history usage snippet. Mirrors TypeScript SDK commit fa4959c. Co-Authored-By: Claude Opus 4.7 --- README.md | 46 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 94d9cf4..eb7e38a 100644 --- a/README.md +++ b/README.md @@ -158,6 +158,20 @@ else: ``` See `examples/error_handling.py` for comprehensive error handling patterns. + +### Order History + +```python +result = await client.get_order_history( + pair="ALOT/USDC", + status="FILLED", + limit=50, +) +if result.success: + for order in result.data: + print(f"{order['pair']} {order['side']} {order['quantity']} @ {order['price']}") +``` + ## Dependencies - `web3>=6.0.0`: Multi-chain blockchain interactions (AsyncWeb3 for async operations) @@ -300,12 +314,15 @@ client.invalidate_cache(level="balance") # Options: static, semi_static, balanc **Static Data (1 hour):** - `get_environments()` - `get_chains()` -- `get_deployment()` +- `get_deployment()` (also caches per `(env, contract_type, return_abi)` filter combination) +- `get_token_price_history(token, *, from_ts=None, to_ts=None)` +- `get_token_hourly_price_history(token, *, from_ts=None, to_ts=None)` **Semi-Static Data (15 minutes):** - `get_tokens()` - `get_clob_pairs()` - `get_swap_pairs(chain_identifier)` +- `get_token_usd_prices(env=None)` **Balance Data (10 seconds):** - `get_portfolio_balance(token, address=None)` @@ -314,6 +331,8 @@ client.invalidate_cache(level="balance") # Options: static, semi_static, balanc - `get_chain_wallet_balances(chain, address=None)` - `get_chain_token_balances(chain, address=None, tokens=...)` - `get_all_chain_wallet_balances(address=None)` +- `get_order_history(account=None, *, pair=None, status=None, limit=100, offset=0)` +- `get_combined_transfers(*, kind=None, from_ts=None, to_ts=None, limit=100, offset=0)` **Orderbook Data (1 second):** - `get_orderbook(pair)` @@ -787,6 +806,17 @@ Error messages are automatically sanitized to prevent information leakage: - Stack traces are removed - User-friendly messages are provided +### Backend reason codes + +Errors from the Dexalot REST API include structured `reasonCode` (e.g. `FQ-015`, `P-AFNE-02`, `T-TMDQ-01`, `RF-IMV-01`) and human `reason` fields. These are preserved verbatim in `Result.fail()` messages — you'll see `"FQ-015: insufficient liquidity"` rather than the generic `"Request failed with status code 400"`. Pattern-match on the code prefix to react programmatically: + +```python +result = await client.get_swap_firm_quote("USDC", "AVAX", 100) +if not result.success and result.error.startswith("FQ-"): + # RFQ backend rejected the quote — see the reason code prefix for why + ... +``` + ### Best Practices 1. **Always check `result.success`** before accessing `result.data` @@ -988,6 +1018,20 @@ Orders are normalized into one canonical SDK shape regardless of whether the sou **Deployment API:** - `env`, `address`, `abi` (handles variations like `Env`, `Address`, `Abi`) +**Price History API (`get_token_price_history`, `get_token_hourly_price_history`):** +- Returns `list[PricePoint]` with `timestamp` (unix seconds, UTC) and `price` (`float`). +- `timestamp` is normalized from `date` ISO-8601, `ts`, `timestamp`, or `time` aliases — millisecond magnitudes are auto-detected and divided down to seconds. +- `price` is coerced from a string decimal to `float`; scientific notation is supported. +- Rows are returned sorted ascending by `timestamp`. + +**Combined Transfers API (`get_combined_transfers`):** +- Returns `list[Transfer]` — a frozen dataclass with snake_case fields normalized from the backend's `DBTransfer` shape: `action_type`, `status`, `symbol`, `quantity`, `fee`, `trader_address`, `bridge`, `bridge_url`, `nonce`, `source_env`, `source_chain_id`, `source_tx`, `source_ts`, `target_env`, `target_chain_id`, `target_tx`, `target_ts`. +- Numeric enums are mapped to string `Literal` labels: `status` (`COMPLETED`/`INFLIGHT`/`DELAYED`), `action_type` (10 labels including `WITHDRAWN`/`DEPOSITED`/`SENT`/`RECEIVED`/`RECOVERED`/`ADD_GAS`/`REMOVE_GAS`/`AUTO_FILL`/`WITHDRAW_PENDING`/`DEPOSIT_PENDING`), `bridge` (`NATIVE`/`LAYER0`/`CELER`/`ICM`). +- `quantity` and `fee` arrive as display-decimal strings — no wei→human conversion is needed (parsed via `float()`). + +**Order History API (`get_order_history`):** +- Same canonical order dict and field aliases as `get_open_orders` — see the "Orders API" entry above. + ### Benefits - **Consistent interface**: Field names are exposed in snake_case in Python consistently. From 26c083330862bf9d29919d0a1bd96ba6d5e909c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=BCseyin=20Deniz?= Date: Tue, 2 Jun 2026 22:01:45 +0300 Subject: [PATCH 08/10] =?UTF-8?q?chore(release):=20bump=20to=20v0.5.16=20(?= =?UTF-8?q?skip=200.5.15=20=E2=80=94=20already=20on=20PyPI)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six new features in this PR (order history, USD valuation x3, combined transfers, get_deployment filters) plus reason_code/reason error preservation. Local was at 0.5.14; PyPI has 0.5.15 already, so skip to 0.5.16 to avoid version collision. Co-Authored-By: Claude Opus 4.7 --- VERSION | 2 +- pyproject.toml | 2 +- src/dexalot_sdk/__init__.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/VERSION b/VERSION index 83ac1cc..3afb327 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.5.14 +0.5.16 diff --git a/pyproject.toml b/pyproject.toml index 51906a2..06e9675 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ license = "MIT" license-files = [ "LICENSE.txt" ] -version = "0.5.14" +version = "0.5.16" 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 d4431a3..5fa290a 100644 --- a/src/dexalot_sdk/__init__.py +++ b/src/dexalot_sdk/__init__.py @@ -12,7 +12,7 @@ secrets_vault_set, ) -__version__ = "0.5.14" +__version__ = "0.5.16" def get_version() -> str: From 012bd80637039006f995740076c7b0b87d8405fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=BCseyin=20Deniz?= Date: Thu, 4 Jun 2026 17:56:07 +0300 Subject: [PATCH 09/10] chore: bump dexalot-sdk version to 0.5.16 --- uv.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/uv.lock b/uv.lock index c2eeecf..44ceb63 100644 --- a/uv.lock +++ b/uv.lock @@ -733,7 +733,7 @@ wheels = [ [[package]] name = "dexalot-sdk" -version = "0.5.14" +version = "0.5.16" source = { editable = "." } dependencies = [ { name = "aiohttp" }, From 6f665694e1dc98e719fdaca1e91144ec62639a43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?H=C3=BCseyin=20Deniz?= Date: Thu, 4 Jun 2026 18:22:40 +0300 Subject: [PATCH 10/10] style: ruff format pass on 4 files CI's `ruff format --check` caught formatting drift in: - src/dexalot_sdk/core/transfer.py - tests/unit/core/{test_base,test_clob,test_transfer}.py My local Makefile target `make lint` only runs `ruff check` (lint), not `ruff format --check`. The CI runs both. Auto-applied with `ruff format .`. Co-Authored-By: Claude Opus 4.7 --- src/dexalot_sdk/core/transfer.py | 4 +--- tests/unit/core/test_base.py | 4 +++- tests/unit/core/test_clob.py | 12 +++--------- tests/unit/core/test_transfer.py | 20 +++++--------------- 4 files changed, 12 insertions(+), 28 deletions(-) diff --git a/src/dexalot_sdk/core/transfer.py b/src/dexalot_sdk/core/transfer.py index ba3805b..bfd5432 100644 --- a/src/dexalot_sdk/core/transfer.py +++ b/src/dexalot_sdk/core/transfer.py @@ -1948,9 +1948,7 @@ async def get_token_price_history( No authentication required (public endpoint). """ - return await self._fetch_price_history( - ENDPOINT_INFO_PRICE_HISTORY, token, from_ts, to_ts - ) + return await self._fetch_price_history(ENDPOINT_INFO_PRICE_HISTORY, token, from_ts, to_ts) @track_method("transfer") async def get_token_hourly_price_history( diff --git a/tests/unit/core/test_base.py b/tests/unit/core/test_base.py index 252bce5..cd45b0f 100644 --- a/tests/unit/core/test_base.py +++ b/tests/unit/core/test_base.py @@ -2783,7 +2783,9 @@ async def test_api_call_reason_code_without_reason_uses_generic_tail(self, clien """When reasonCode is set but reason is missing, fall back to a generic tail.""" cm = self._http_error_response(500, {"reasonCode": "P-OK01"}) with patch.object(client, "_make_http_request", AsyncMock(return_value=cm)): - with pytest.raises(RuntimeError, match=r"^P-OK01: Request failed with status code 500$"): + with pytest.raises( + RuntimeError, match=r"^P-OK01: Request failed with status code 500$" + ): await client._api_call("get", "https://api/x") async def test_api_call_reason_alone_without_reason_code(self, client): diff --git a/tests/unit/core/test_clob.py b/tests/unit/core/test_clob.py index 2ba1258..51fce08 100644 --- a/tests/unit/core/test_clob.py +++ b/tests/unit/core/test_clob.py @@ -1209,9 +1209,7 @@ async def test_get_order_history_no_wallet_and_no_account_returns_fail(self, cli async def test_get_order_history_envelope_returns_canonical_orders(self, client): """``{count, rows}`` envelope parsed; each row normalized to canonical Order.""" - api_spy = AsyncMock( - return_value={"count": 1, "rows": [self._make_api_order_row()]} - ) + api_spy = AsyncMock(return_value={"count": 1, "rows": [self._make_api_order_row()]}) with patch.object(client, "_api_call", api_spy): result = await client.get_order_history() assert result.success @@ -1302,9 +1300,7 @@ async def test_get_order_history_forwards_filters(self, client): """pair / status / limit / offset all forwarded as query params.""" api_spy = AsyncMock(return_value={"count": 0, "rows": []}) with patch.object(client, "_api_call", api_spy): - await client.get_order_history( - pair="AVAX/USDC", status="FILLED", limit=25, offset=50 - ) + await client.get_order_history(pair="AVAX/USDC", status="FILLED", limit=25, offset=50) _, kwargs = api_spy.call_args params = kwargs["params"] assert params["traderaddress"] == VALID_ADDRESS @@ -1335,9 +1331,7 @@ async def test_get_order_history_attaches_auth_header_when_signer_present(self, """x-signature header attached via _get_auth_headers when a signer exists.""" api_spy = AsyncMock(return_value={"count": 0, "rows": []}) with patch.object(client, "_api_call", api_spy): - with patch.object( - client, "_get_auth_headers", return_value={"x-signature": "sig"} - ): + with patch.object(client, "_get_auth_headers", return_value={"x-signature": "sig"}): await client.get_order_history() _, kwargs = api_spy.call_args assert kwargs["headers"] == {"x-signature": "sig"} diff --git a/tests/unit/core/test_transfer.py b/tests/unit/core/test_transfer.py index 766d6c2..2ef2a78 100644 --- a/tests/unit/core/test_transfer.py +++ b/tests/unit/core/test_transfer.py @@ -2595,9 +2595,7 @@ async def test_get_token_price_history_normalizes_and_sorts(self, client): assert result.success assert isinstance(result.data[0], PricePoint) # Ascending by timestamp - assert [p.timestamp for p in result.data] == sorted( - p.timestamp for p in result.data - ) + assert [p.timestamp for p in result.data] == sorted(p.timestamp for p in result.data) assert result.data[0].price == 8.25 assert result.data[-1].price == 10.5 @@ -2624,9 +2622,7 @@ async def test_get_token_price_history_forwards_from_and_to(self, client): """``from_ts``/``to_ts`` forwarded as ``from``/``to`` query params.""" api_spy = AsyncMock(return_value=[]) with patch.object(client, "_api_call", api_spy): - await client.get_token_price_history( - "ALOT", from_ts=1700000000, to_ts=1700864000 - ) + await client.get_token_price_history("ALOT", from_ts=1700000000, to_ts=1700864000) _, kwargs = api_spy.call_args assert kwargs["params"] == { "token": "ALOT", @@ -3014,9 +3010,7 @@ async def test_get_combined_transfers_forwards_kind_and_period(self, client): """kind → symbol; from_ts → periodfrom; to_ts → periodto.""" api_spy = AsyncMock(return_value={"count": 0, "rows": []}) with patch.object(client, "_api_call", api_spy): - await client.get_combined_transfers( - kind="ALOT", from_ts=1700000000, to_ts=1700864000 - ) + await client.get_combined_transfers(kind="ALOT", from_ts=1700000000, to_ts=1700864000) _, kwargs = api_spy.call_args assert kwargs["params"]["symbol"] == "ALOT" assert kwargs["params"]["periodfrom"] == 1700000000 @@ -3026,9 +3020,7 @@ async def test_get_combined_transfers_attaches_signature_header(self, client): """``x-signature`` header attached via _get_auth_headers.""" api_spy = AsyncMock(return_value={"count": 0, "rows": []}) with patch.object(client, "_api_call", api_spy): - with patch.object( - client, "_get_auth_headers", return_value={"x-signature": "sig"} - ): + with patch.object(client, "_get_auth_headers", return_value={"x-signature": "sig"}): await client.get_combined_transfers() _, kwargs = api_spy.call_args assert kwargs["headers"] == {"x-signature": "sig"} @@ -3089,9 +3081,7 @@ async def test_get_combined_transfers_cache_bypass_when_disabled(self, client): async def test_get_combined_transfers_normalizes_user_symbol(self, client): """``kind`` is run through _normalize_user_token before being forwarded.""" api_spy = AsyncMock(return_value={"count": 0, "rows": []}) - with patch.object( - client, "_normalize_user_token", return_value="USDC" - ) as norm_spy: + with patch.object(client, "_normalize_user_token", return_value="USDC") as norm_spy: with patch.object(client, "_api_call", api_spy): await client.get_combined_transfers(kind="usdc") norm_spy.assert_called_once_with("usdc")