diff --git a/README.md b/README.md index c91ea37..94d9cf4 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ ## Disclaimer -Here is our first public release of Dexalot SDK for Python. It is in alpha testing right now. Fork it, contribute to it and use it to integrate with Dexalot and let us know how we can improve it. +Here is our public release of Dexalot SDK for Python. It is in beta testing right now. Fork it, contribute to it and use it to integrate with Dexalot and let us know how we can improve it. **Please Note**: The public interface may undergo breaking changes. @@ -979,10 +979,11 @@ Orders are normalized into one canonical SDK shape regardless of whether the sou - `min_trade_amount`, `max_trade_amount` **RFQ Quotes API:** -- `chainId` (from `chainid`, `chain_id`) -- `secureQuote` (from `securequote`, `secure_quote`) -- `quoteId` (from `quoteid`, `quote_id`) -- Nested order data: `nonceAndMeta`, `makerAsset`, `takerAsset`, `makerAmount`, `takerAmount` +- HTTP envelope `{"success": true, "quote": {...}}` is unwrapped so callers see only the inner executable quote. Envelope-layer failures (`success: false`) become `Result.fail(...)` at the HTTP layer using the API's `reason`/`error` field. +- `chain_id` (from `chainid`, `chainId`) +- `quote_id` (from `quoteid`, `quoteId`) +- Top-level fields preserved as-is: `signature`, `order`, `tx`, `expiry`, `nonceAndMeta`, `pair`, `price`, `side`, `minOutputAmount`, `maxSlippageBps`, `bridgeFee`, `usdAmount`, `baseAddress`, `quoteAddress`, `baseAmount`, `quoteAmount` +- Inner `order` data normalized to snake_case: `nonce_and_meta`, `maker_asset`, `taker_asset`, `maker_amount`, `taker_amount` (camelCase originals retained) **Deployment API:** - `env`, `address`, `abi` (handles variations like `Env`, `Address`, `Abi`) diff --git a/VERSION b/VERSION index 964783a..83ac1cc 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.5.13 +0.5.14 diff --git a/pyproject.toml b/pyproject.toml index 51cdc98..51906a2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ license = "MIT" license-files = [ "LICENSE.txt" ] -version = "0.5.13" +version = "0.5.14" description = "Dexalot Python SDK - Core library for Dexalot interaction" readme = "README.md" requires-python = ">=3.12,<3.15" @@ -17,7 +17,7 @@ authors = [ ] keywords = ["dexalot", "dex", "defi", "web3", "trading", "avalanche"] classifiers = [ - "Development Status :: 3 - Alpha", + "Development Status :: 4 - Beta", "Intended Audience :: Developers", "Operating System :: OS Independent", "Programming Language :: Python :: 3", diff --git a/src/dexalot_sdk/__init__.py b/src/dexalot_sdk/__init__.py index 78660a1..d4431a3 100644 --- a/src/dexalot_sdk/__init__.py +++ b/src/dexalot_sdk/__init__.py @@ -12,7 +12,7 @@ secrets_vault_set, ) -__version__ = "0.5.13" +__version__ = "0.5.14" def get_version() -> str: diff --git a/src/dexalot_sdk/core/base.py b/src/dexalot_sdk/core/base.py index 43af820..deb7062 100644 --- a/src/dexalot_sdk/core/base.py +++ b/src/dexalot_sdk/core/base.py @@ -1271,12 +1271,21 @@ async def fetch_one(cid, chain_name): async with await self._make_http_request( "get", rfq_url, params={"chainid": cid} ) as response: - response.raise_for_status() - self.rfq_pairs[cid] = await response.json() + if response.status == 200: + self.rfq_pairs[cid] = await response.json() + return + log_event( + self.logger, + "debug", + "rfq_pairs_unavailable", + chain_id=cid, + chain_name=chain_name, + status=response.status, + ) except Exception as e: log_event( self.logger, - "warning", + "debug", "rfq_pairs_fetch_failed", chain_id=cid, chain_name=chain_name, diff --git a/src/dexalot_sdk/core/swap.py b/src/dexalot_sdk/core/swap.py index 5585df5..aebe9d4 100644 --- a/src/dexalot_sdk/core/swap.py +++ b/src/dexalot_sdk/core/swap.py @@ -1,5 +1,7 @@ from typing import Any, cast +from web3 import Web3 + from ..constants import ( DEFAULT_TAKER_ADDRESS, ENDPOINT_RFQ_FIRM_QUOTE, @@ -15,6 +17,11 @@ from ..utils.result import Result from .base import _SEMI_STATIC_CACHE, DexalotBaseClient +# MainnetRFQ uses the zero address to mean "the chain's native coin" (e.g. AVAX +# on 43114). When the taker is selling native, ``msg.value`` must equal +# ``takerAmount``; for ERC20 takers it must be 0. +NATIVE_ZERO_ADDRESS = "0x" + "0" * 40 + class SwapClient(DexalotBaseClient): async def _get_w3_l1(self): @@ -106,70 +113,52 @@ def _rehydrate_cached_get_swap_pairs( self.rfq_pairs[chain_id] = cached.data def _transform_quote_from_api(self, quote: dict) -> dict: - """Transform API quote response to match standardized field names (snake_case). + """Normalize a Dexalot firm-quote response to snake_case keys. + + The HTTP response wraps the executable firm quote inside + ``{"success": true, "quote": {...}}``. Unwrap so downstream code + operates on the inner dict, then apply snake_case aliases for + top-level identifiers and normalize the inner ``order`` dict. + + After unwrapping, this helper: - Maps lowercase/camelCase API fields to snake_case SDK fields to match - Python naming conventions. + * Adds ``chain_id`` and ``quote_id`` snake_case aliases for the camelCase + (or lowercase) identifiers the API may emit. + * Runs ``_transform_order_data_from_api`` over ``quote["order"]`` so nested + fields like ``nonceAndMeta``/``makerAsset``/``takerAmount`` gain + snake_case aliases. + + Original keys are preserved; nothing is popped or renamed. Args: - quote: Raw quote dict from API response + quote: Raw quote dict from API response. May be the full envelope + ``{"success": true, "quote": {...}}`` or the already-inner dict. Returns: - Transformed quote dict with standardized field names + Transformed inner-quote dict with snake_case aliases added. """ - transformed = dict(quote) # Start with all original fields + if isinstance(quote, dict) and "quote" in quote and isinstance(quote["quote"], dict): + quote = quote["quote"] + + transformed = dict(quote) - # Map chain_id: prefer existing snake_case, fallback to lowercase/camelCase + # Map chain_id: prefer existing snake_case, fallback to lowercase/camelCase. if "chain_id" not in transformed: if "chainid" in quote: transformed["chain_id"] = quote["chainid"] elif "chainId" in quote: transformed["chain_id"] = quote["chainId"] - # Map secure_quote: prefer existing snake_case, fallback to lowercase/camelCase - if "secure_quote" not in transformed: - if "securequote" in quote: - transformed["secure_quote"] = self._transform_secure_quote_from_api( - quote["securequote"] - ) - elif "secureQuote" in quote: - transformed["secure_quote"] = self._transform_secure_quote_from_api( - quote["secureQuote"] - ) - else: - # Already exists, but ensure nested fields are transformed - transformed["secure_quote"] = self._transform_secure_quote_from_api( - transformed["secure_quote"] - ) - - # Map quote_id: prefer existing snake_case, fallback to lowercase/camelCase + # Map quote_id: prefer existing snake_case, fallback to lowercase/camelCase. if "quote_id" not in transformed: if "quoteid" in quote: transformed["quote_id"] = quote["quoteid"] elif "quoteId" in quote: transformed["quote_id"] = quote["quoteId"] - return transformed - - def _transform_secure_quote_from_api(self, secure_quote: dict) -> dict: - """Transform secureQuote object fields to snake_case. - - Args: - secure_quote: Raw secureQuote dict from API response - - Returns: - Transformed secureQuote dict with standardized field names - """ - if not secure_quote: - return secure_quote - - transformed = dict(secure_quote) - - # Transform data/order object if present - if "data" in secure_quote: - transformed["data"] = self._transform_order_data_from_api(secure_quote["data"]) - if "order" in secure_quote: - transformed["order"] = self._transform_order_data_from_api(secure_quote["order"]) + # Normalize the inner order dict so downstream code can read snake_case keys. + if "order" in transformed: + transformed["order"] = self._transform_order_data_from_api(transformed["order"]) return transformed @@ -286,6 +275,17 @@ async def _get_swap_quote_base( async with await self._make_http_request("get", url, params=params) as response: if response.status == 200: quote_data = await response.json() + # Envelope-layer failure: Dexalot RFQ returns + # ``{"success": false, "reason": "..."}`` on logical failure + # even with HTTP 200. Surface that as a Result.fail before + # handing the payload to the shape-mapping transform. + if isinstance(quote_data, dict) and quote_data.get("success") is False: + reason = ( + quote_data.get("reason") + or quote_data.get("error") + or "Quote API returned success=false" + ) + return Result.fail(f"Cannot execute failed quote: {reason}") transformed_quote = self._transform_quote_from_api(quote_data) return Result.ok(transformed_quote) else: @@ -379,8 +379,9 @@ async def get_swap_firm_quote( during ``initialize_client()``). Returns: - Result containing a firm quote dict (including ``secure_quote`` and - ``quote_id``) on success, or an error message on failure. + Result containing a firm quote dict (with top-level ``signature``, + ``order``, ``tx``, and ``quote_id`` fields) on success, or an error + message on failure. """ # Validate swap parameters swap_params_result = validate_swap_params(from_token, to_token, amount) @@ -473,35 +474,31 @@ async def execute_rfq_swap(self, quote: dict, wait_for_receipt: bool = True) -> return Result.fail("Invalid quote: empty data") quote = quote.data - # Transform quote to ensure standardized field names + # Transform quote to ensure standardized field names. This also + # unwraps the ``{"success": true, "quote": {...}}`` envelope when the + # caller passed in the raw API payload. Envelope-layer failure + # (``success: false``) is handled by ``_get_swap_quote_base`` before + # the transform runs, so it never reaches us here. quote_typed: dict[Any, Any] = self._transform_quote_from_api(quote) quote = quote_typed - # Check if quote has error - if "success" in quote and not quote["success"]: - return Result.fail( - f"Cannot execute failed quote: {quote.get('reason', 'Unknown reason')}" - ) - - # Extract secure quote data - if "secure_quote" not in quote: - return Result.fail("Invalid quote format: 'secure_quote' missing.") - - secure_quote = quote["secure_quote"] - signature = secure_quote.get("signature") - order_data = secure_quote.get("data") or secure_quote.get("order") - + # Extract signature and order data from the firm-quote dict. + signature = quote.get("signature") + order_data = quote.get("order") if not signature or not order_data: - return Result.fail("Invalid secure quote data: missing signature or order data") + return Result.fail("Invalid firm quote: missing 'signature' or 'order' field.") - # Resolve contract and w3 - w3, contract = await self._get_rfq_contract() + # Resolve contract and w3 for the connected chain carried in the quote. + w3, contract = await self._get_rfq_contract(quote.get("chain_id")) if not w3 or not contract: return Result.fail("RFQ Contract not found or W3 not initialized.") try: # Construct Order tuple order_tuple = self._construct_rfq_order(order_data) + # MainnetRFQ requires msg.value == takerAmount for native sells + # (takerAsset == zero address), 0 otherwise. + msg_value = self._compute_msg_value(order_data) # Convert signature to bytes if isinstance(signature, str): @@ -515,7 +512,9 @@ async def execute_rfq_swap(self, quote: dict, wait_for_receipt: bool = True) -> nonce = await self._get_nonce(w3) # Estimate gas - gas_estimate = await self._estimate_swap_gas(contract, order_tuple, signature_bytes) + gas_estimate = await self._estimate_swap_gas( + contract, order_tuple, signature_bytes, msg_value=msg_value + ) gas_price = await self._rpc_call(w3, "eth.gas_price") @@ -527,6 +526,7 @@ async def execute_rfq_swap(self, quote: dict, wait_for_receipt: bool = True) -> "nonce": nonce, "gas": int(gas_estimate * 1.2), "gasPrice": gas_price, + "value": msg_value, } ) @@ -536,6 +536,8 @@ async def execute_rfq_swap(self, quote: dict, wait_for_receipt: bool = True) -> w3, "eth.send_raw_transaction", signed_tx.raw_transaction ) + tx_hex = w3.to_hex(tx_hash) + if wait_for_receipt: receipt = await self._rpc_call(w3, "eth.wait_for_transaction_receipt", tx_hash) receipt_status = ( @@ -546,42 +548,120 @@ async def execute_rfq_swap(self, quote: dict, wait_for_receipt: bool = True) -> else 1 ) if receipt_status != 1: - return Result.fail("Transaction reverted") - return Result.ok({"tx_hash": w3.to_hex(tx_hash), "operation": "execute_rfq_swap"}) + revert_reason = await self._extract_revert_reason(w3, tx, receipt) + block_number = ( + receipt.get("blockNumber") + if isinstance(receipt, dict) + else getattr(receipt, "blockNumber", None) + ) + detail_parts = [f"tx={tx_hex}"] + if block_number is not None: + detail_parts.append(f"block={block_number}") + if revert_reason: + detail_parts.append(f"reason={revert_reason}") + return Result.fail(f"Transaction reverted: {', '.join(detail_parts)}") + return Result.ok({"tx_hash": tx_hex, "operation": "execute_rfq_swap"}) - return Result.ok({"tx_hash": w3.to_hex(tx_hash), "operation": "execute_rfq_swap"}) + return Result.ok({"tx_hash": tx_hex, "operation": "execute_rfq_swap"}) except Exception as e: error_msg = self._sanitize_error(e, "executing swap") return Result.fail(error_msg) - async def _get_rfq_contract(self): - """Resolve MainnetRFQ contract and W3 instance.""" + async def _extract_revert_reason(self, w3: Any, tx: dict, receipt: Any) -> str | None: + """Best-effort extraction of the on-chain revert reason for a failed tx. + + Re-runs the original transaction as ``eth_call`` against the block in + which it reverted; the node returns the revert message (e.g. + ``execution reverted: RF-EXP-01``) which is otherwise dropped from + the receipt. Returns ``None`` if the call cannot be replayed or the + node refuses to surface a reason. + """ + try: + block_number = ( + receipt.get("blockNumber") + if isinstance(receipt, dict) + else getattr(receipt, "blockNumber", None) + ) + call_tx = { + "from": tx.get("from"), + "to": tx.get("to"), + "data": tx.get("data"), + "value": tx.get("value", 0), + "gas": tx.get("gas"), + } + call_tx = {k: v for k, v in call_tx.items() if v is not None} + try: + await w3.eth.call(call_tx, block_identifier=block_number) + except Exception as call_exc: + msg = str(call_exc) + marker = "execution reverted" + if marker in msg: + after = msg.split(marker, 1)[1].lstrip(" :") + after = after.strip().strip("'").strip('"') + return after or marker + return msg[:200] or None + return None + except Exception: + return None + + async def _get_rfq_contract(self, chain_id: int | None = None): + """Resolve MainnetRFQ contract and W3 instance for the target chain. + + RFQ SimpleSwap executes on the connected chain (e.g. Avalanche + C-Chain for ``chain_id`` 43114), not on Dexalot L1. The ``chain_id`` + argument selects which connected chain to route to; when ``None``, + falls back to ``self.chain_id``. + """ if "MainnetRFQ" not in self.deployments: return None, None - # For now, grab the first available deployment of MainnetRFQ + target_chain_id = chain_id if chain_id is not None else self.chain_id + if target_chain_id is None: + return None, None + rfq_deployments = self.deployments["MainnetRFQ"] - contract_address = None - w3 = await self._get_w3_l1() # Default to L1 - for _key, dep in rfq_deployments.items(): - if "address" in dep: - contract_address = dep["address"] + # Reverse-resolve target chain_id to its chain name via chain_config. + chain_name: str | None = None + for name, cfg in (self.chain_config or {}).items(): + if cfg.get("chain_id") == target_chain_id: + chain_name = name break - if not contract_address or not w3: + # Pick the deployment for the resolved chain. Deployments may be + # keyed by chain name or by chain_id; check both. + deployment = None + if chain_name is not None and chain_name in rfq_deployments: + deployment = rfq_deployments[chain_name] + elif target_chain_id in rfq_deployments: + deployment = rfq_deployments[target_chain_id] + chain_name = chain_name or str(target_chain_id) + elif str(target_chain_id) in rfq_deployments: + deployment = rfq_deployments[str(target_chain_id)] + chain_name = chain_name or str(target_chain_id) + + if not deployment or "address" not in deployment: return None, None - # Load ABI - abi = self.deployments["MainnetRFQ"][list(self.deployments["MainnetRFQ"].keys())[0]].get( - "abi", [] - ) - contract = w3.eth.contract(address=contract_address, abi=abi) + # chain_name is guaranteed non-None here: each branch above either + # matches on chain_name or assigns it from target_chain_id. + w3 = self.connected_chain_providers.get(cast(str, chain_name)) + if w3 is None: + return None, None + + contract_address = deployment["address"] + abi = deployment.get("abi", []) + contract = w3.eth.contract(address=Web3.to_checksum_address(contract_address), abi=abi) return w3, contract - async def _estimate_swap_gas(self, contract, order_tuple, signature_bytes): - """Estimate gas for swap transaction with retry/rate limiting.""" + async def _estimate_swap_gas(self, contract, order_tuple, signature_bytes, msg_value: int = 0): + """Estimate gas for swap transaction with retry/rate limiting. + + ``msg_value`` mirrors the ``value`` field of the eventual transaction + so the estimator sees the same call shape MainnetRFQ will validate + (native sells require ``msg.value == takerAmount``). + """ if not self.account: raise ValueError("Account is required for gas estimation.") from_addr = cast(str, cast(Any, self.account).address) @@ -595,7 +675,7 @@ async def _estimate_swap_gas(self, contract, order_tuple, signature_bytes): async def _estimate_gas(): return await contract.functions.simpleSwap( order_tuple, signature_bytes - ).estimate_gas({"from": from_addr}) + ).estimate_gas({"from": from_addr, "value": msg_value}) retry_func = async_retry( max_attempts=self.config.retry_max_attempts, @@ -608,20 +688,51 @@ async def _estimate_gas(): return await retry_func() else: return await contract.functions.simpleSwap(order_tuple, signature_bytes).estimate_gas( - {"from": from_addr} + {"from": from_addr, "value": msg_value} + ) + + @staticmethod + def _compute_msg_value(order_data: dict) -> int: + """Return the wei msg.value to attach to a SimpleSwap call. + + The MainnetRFQ contract requires ``msg.value == takerAmount`` when the + taker is sending the chain's native token (``takerAsset`` is the zero + address), and ``msg.value == 0`` otherwise. + """ + taker_asset = order_data.get("taker_asset") or order_data.get("takerAsset") or "" + if taker_asset.lower() == NATIVE_ZERO_ADDRESS: + return SwapClient._to_int( + order_data.get("taker_amount") or order_data.get("takerAmount") ) + return 0 + + @staticmethod + def _to_int(value: Any) -> int: + """Coerce an order field to int, accepting decimal or 0x-hex strings. + + ``nonceAndMeta`` arrives as a 0x-prefixed hex string; ``makerAmount`` / + ``takerAmount`` as decimal strings; ``expiry`` as a JSON number. + """ + if value is None or value == "": + return 0 + if isinstance(value, int): + return value + text = str(value) + return int(text, 16) if text.lower().startswith("0x") else int(text) def _construct_rfq_order(self, order_data): """Construct the Order tuple from order data dictionary.""" # ABI: simpleSwap((nonceAndMeta, expiry, makerAsset, takerAsset, maker, taker, makerAmount, takerAmount), signature) # Use snake_case field names (transformed from API) + maker_asset = order_data.get("maker_asset") or order_data.get("makerAsset") + taker_asset = order_data.get("taker_asset") or order_data.get("takerAsset") return ( - int(order_data.get("nonce_and_meta") or order_data.get("nonceAndMeta", 0)), - int(order_data.get("expiry", 0)), - order_data.get("maker_asset") or order_data.get("makerAsset"), - order_data.get("taker_asset") or order_data.get("takerAsset"), - order_data.get("maker"), - order_data.get("taker"), - int(order_data.get("maker_amount") or order_data.get("makerAmount", 0)), - int(order_data.get("taker_amount") or order_data.get("takerAmount", 0)), + self._to_int(order_data.get("nonce_and_meta") or order_data.get("nonceAndMeta")), + self._to_int(order_data.get("expiry")), + Web3.to_checksum_address(maker_asset), + Web3.to_checksum_address(taker_asset), + Web3.to_checksum_address(order_data["maker"]), + Web3.to_checksum_address(order_data["taker"]), + self._to_int(order_data.get("maker_amount") or order_data.get("makerAmount")), + self._to_int(order_data.get("taker_amount") or order_data.get("takerAmount")), ) diff --git a/tests/unit/core/test_base.py b/tests/unit/core/test_base.py index baf2e50..f7499d7 100644 --- a/tests/unit/core/test_base.py +++ b/tests/unit/core/test_base.py @@ -997,6 +997,40 @@ def smart_side_effect(url, params=None, **kwargs): assert 43114 in client.rfq_pairs assert client.rfq_pairs[43114] == {"Fallback": "Data"} + async def test_fetch_rfq_pairs_status_branches(self, client): + """200 stores pairs; non-200 statuses and exceptions are silent.""" + client.chain_config = { + "Avalanche": {"chain_id": 43114}, + "Ethereum": {"chain_id": 1}, + "Broken": {"chain_id": 999}, + "Network": {"chain_id": 12345}, + } + + def make_resp(status, json_data=None): + resp = AsyncMock() + resp.status = status + resp.json.return_value = json_data or {} + cm = AsyncMock() + cm.__aenter__.return_value = resp + return cm + + def side_effect(url, params=None, **kwargs): + cid = params.get("chainid") if params else None + if cid == 43114: + return make_resp(200, {"AVAX/USDC": {}}) + if cid == 1: + return make_resp(404) + if cid == 999: + return make_resp(500) + raise ConnectionError("network down") + + client._mock_session.get = MagicMock(side_effect=side_effect) + client.rfq_pairs = {} + + await client._fetch_rfq_pairs() + + assert client.rfq_pairs == {43114: {"AVAX/USDC": {}}} + @patch("dexalot_sdk.utils.provider_manager.AsyncWeb3") @patch("dexalot_sdk.core.base.AsyncWeb3") async def test_web3_init_failure(self, mock_web3_base, mock_web3_provider, client): diff --git a/tests/unit/core/test_swap.py b/tests/unit/core/test_swap.py index 5b1c4ee..ebf4110 100644 --- a/tests/unit/core/test_swap.py +++ b/tests/unit/core/test_swap.py @@ -2,6 +2,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest +from web3 import Web3 from dexalot_sdk.core.base import _SEMI_STATIC_CACHE, DexalotBaseClient from dexalot_sdk.core.config import DexalotConfig @@ -39,7 +40,14 @@ def client(self): 43113: {"AVAX/USDC": {}}, } client.connected_chain_providers = {"Avalanche": MagicMock()} - client.deployments = {"MainnetRFQ": {"Avalanche": {"address": "0xRFQ", "abi": []}}} + client.deployments = { + "MainnetRFQ": { + "Avalanche": { + "address": "0xeed3c159f3a96ab8d41c8b9ca49ee1e5071a7cdd", + "abi": [], + } + } + } client._parse_revert_reason = lambda e: str(e) client.chain_id = 43114 client.w3_l1 = MagicMock() @@ -122,94 +130,80 @@ def test_rehydrate_cached_get_swap_pairs_ignores_failed_or_unresolved_input(self client._rehydrate_cached_get_swap_pairs(Result.ok({"AVAX/USDC": {}}), "Unknown") assert client.rfq_pairs == {} - async def test_transform_quote_from_api(self, client): - """Test _transform_quote_from_api transforms field names to snake_case.""" - # Test lowercase fields - quote1 = { + async def test_transform_quote_from_api_lowercase_aliases(self, client): + """Lowercase API identifiers gain snake_case aliases.""" + quote = { "chainid": 43114, - "securequote": { - "signature": "0xSig", - "data": { - "nonceAndMeta": 1, - "expiry": 1, - "makerAsset": "0xM", - "takerAsset": "0xT", - "maker": "0xMkr", - "taker": "0xTkr", - "makerAmount": 100, - "takerAmount": 200, - }, - }, "quoteid": "q123", + "signature": "0xSig", + "order": { + "nonceAndMeta": 1, + "expiry": 1, + "makerAsset": "0xM", + "takerAsset": "0xT", + "maker": "0xMkr", + "taker": "0xTkr", + "makerAmount": 100, + "takerAmount": 200, + }, } - transformed1 = client._transform_quote_from_api(quote1) - assert transformed1["chain_id"] == 43114 - assert transformed1["secure_quote"] is not None - assert transformed1["secure_quote"]["signature"] == "0xSig" - assert transformed1["secure_quote"]["data"]["nonce_and_meta"] == 1 - assert transformed1["secure_quote"]["data"]["maker_asset"] == "0xM" - assert transformed1["secure_quote"]["data"]["taker_asset"] == "0xT" - assert transformed1["secure_quote"]["data"]["maker_amount"] == 100 - assert transformed1["secure_quote"]["data"]["taker_amount"] == 200 - assert transformed1["quote_id"] == "q123" - - # Test preference for existing snake_case fields - quote2 = { - "chain_id": 43114, - "secure_quote": { - "signature": "0xSig", - "data": { - "nonce_and_meta": 1, - "maker_asset": "0xM", - "taker_asset": "0xT", - "maker_amount": 100, - "taker_amount": 200, - }, - }, - "quote_id": "q123", - "chainid": 999, # Should be ignored - "securequote": {}, # Should be ignored - "quoteid": "ignored", # Should be ignored + transformed = client._transform_quote_from_api(quote) + assert transformed["chain_id"] == 43114 + assert transformed["quote_id"] == "q123" + # Original fields preserved. + assert transformed["chainid"] == 43114 + assert transformed["quoteid"] == "q123" + assert transformed["signature"] == "0xSig" + # Inner order normalized via _transform_order_data_from_api. + order = transformed["order"] + assert order["nonce_and_meta"] == 1 + assert order["maker_asset"] == "0xM" + assert order["taker_asset"] == "0xT" + assert order["maker_amount"] == 100 + assert order["taker_amount"] == 200 + # camelCase originals retained. + assert order["nonceAndMeta"] == 1 + assert order["makerAsset"] == "0xM" + + async def test_transform_quote_from_api_camelcase_aliases(self, client): + """camelCase API identifiers gain snake_case aliases.""" + quote = { + "chainId": 43114, + "quoteId": "q123", + "signature": "0xSig", + "order": {"nonceAndMeta": 1, "makerAsset": "0xM"}, } - transformed2 = client._transform_quote_from_api(quote2) - assert transformed2["chain_id"] == 43114 # Prefer existing - assert transformed2["quote_id"] == "q123" # Prefer existing - assert transformed2["secure_quote"]["data"]["nonce_and_meta"] == 1 # Prefer existing + transformed = client._transform_quote_from_api(quote) + assert transformed["chain_id"] == 43114 + assert transformed["quote_id"] == "q123" + assert transformed["order"]["nonce_and_meta"] == 1 + assert transformed["order"]["maker_asset"] == "0xM" - # Test secureQuote (camelCase) field - quote3 = { - "chainid": 43114, - "secureQuote": { - "signature": "0xSig", - "data": { - "nonceAndMeta": 1, - }, - }, + async def test_transform_quote_from_api_prefers_existing_snake_case(self, client): + """Snake_case keys already on the input win over camelCase/lowercase.""" + quote = { + "chain_id": 43114, + "quote_id": "q123", + "chainid": 999, # Should be ignored. + "quoteid": "ignored", # Should be ignored. + "signature": "0xSig", + "order": {"nonce_and_meta": 1, "maker_asset": "0xM"}, } - transformed3 = client._transform_quote_from_api(quote3) - assert transformed3["secure_quote"] is not None - assert transformed3["secure_quote"]["signature"] == "0xSig" - assert transformed3["secure_quote"]["data"]["nonce_and_meta"] == 1 + transformed = client._transform_quote_from_api(quote) + assert transformed["chain_id"] == 43114 + assert transformed["quote_id"] == "q123" + assert transformed["order"]["nonce_and_meta"] == 1 - # Test order field (legacy) - quote4 = { - "chainid": 43114, - "securequote": { - "signature": "0xSig", - "order": { - "nonceAndMeta": 1, - "makerAsset": "0xM", - }, - }, - } + async def test_transform_quote_from_api_no_order(self, client): + """Quotes without an inner order dict pass through cleanly.""" + quote = {"chainid": 43114, "signature": "0xSig"} - transformed4 = client._transform_quote_from_api(quote4) - assert transformed4["secure_quote"]["order"] is not None - assert transformed4["secure_quote"]["order"]["nonce_and_meta"] == 1 - assert transformed4["secure_quote"]["order"]["maker_asset"] == "0xM" + transformed = client._transform_quote_from_api(quote) + assert transformed["chain_id"] == 43114 + assert "order" not in transformed async def test_get_swap_soft_quote(self, client): """Test get_swap_soft_quote logic.""" @@ -267,26 +261,24 @@ async def test_get_swap_firm_quote(self, client): assert not result.success assert "not found in RFQ or CLOB pairs" in result.error client.account = MagicMock() - client.account.address = "0xUser" + client.account.address = "0x0000000000000000000000000000000000000005" await client.get_swap_firm_quote("AVAX", "USDC", 1.0, chain_id=43114) call_args = client._mock_session.get.call_args - assert call_args[1]["params"]["address"] == "0xUser" + assert call_args[1]["params"]["address"] == "0x0000000000000000000000000000000000000005" assert "firm" in call_args[0][0] async def test_get_swap_quote_transforms_fields(self, client): - """Test that get_swap_soft_quote transforms quote fields from API response.""" + """get_swap_soft_quote applies snake_case aliases to the API response.""" mock_resp = AsyncMock() mock_resp.status = 200 mock_resp.json = AsyncMock( return_value={ "chainid": 43114, - "securequote": { - "signature": "0xSig", - "data": { - "nonceAndMeta": 1, - "makerAsset": "0xM", - }, + "signature": "0xSig", + "order": { + "nonceAndMeta": 1, + "makerAsset": "0xM", }, } ) @@ -300,30 +292,28 @@ async def test_get_swap_quote_transforms_fields(self, client): result = await client.get_swap_soft_quote("AVAX", "USDC", 1.0, chain_id=43114) assert result.success assert result.data["chain_id"] == 43114 - assert result.data["secure_quote"] is not None - assert result.data["secure_quote"]["data"]["nonce_and_meta"] == 1 - assert result.data["secure_quote"]["data"]["maker_asset"] == "0xM" + assert result.data["signature"] == "0xSig" + assert result.data["order"]["nonce_and_meta"] == 1 + assert result.data["order"]["maker_asset"] == "0xM" async def test_execute_rfq_swap(self, client): - """Test execute_rfq_swap.""" + """execute_rfq_swap end-to-end with a flat firm-quote dict.""" quote = { "success": True, - "securequote": { - "signature": "0x1234", - "data": { - "nonceAndMeta": 123, - "expiry": 9999999999, - "makerAsset": "0xToken", - "takerAsset": "0xTokenOut", - "maker": "0xMaker", - "taker": "0xTaker", - "makerAmount": 1000, - "takerAmount": 2000, - }, + "signature": "0x1234", + "order": { + "nonceAndMeta": 123, + "expiry": 9999999999, + "makerAsset": "0x1111111111111111111111111111111111111111", + "takerAsset": "0x2222222222222222222222222222222222222222", + "maker": "0x0000000000000000000000000000000000000003", + "taker": "0x0000000000000000000000000000000000000004", + "makerAmount": 1000, + "takerAmount": 2000, }, } - mock_w3 = client.w3_l1 + mock_w3 = client.connected_chain_providers["Avalanche"] mock_w3.eth.chain_id = AsyncMock(return_value=43114) mock_w3.eth.get_transaction_count = AsyncMock(return_value=0) @@ -373,10 +363,10 @@ async def mock_rpc_call(w3, method, *args): expected_tuple = ( 123, 9999999999, - "0xToken", - "0xTokenOut", - "0xMaker", - "0xTaker", + "0x1111111111111111111111111111111111111111", + "0x2222222222222222222222222222222222222222", + "0x0000000000000000000000000000000000000003", + "0x0000000000000000000000000000000000000004", 1000, 2000, ) @@ -385,83 +375,6 @@ async def mock_rpc_call(w3, method, *args): assert args[0] == expected_tuple assert args[1] == b"\x12\x34" - async def test_execute_rfq_swap_with_transformed_fields(self, client): - """Test execute_rfq_swap handles transformed field names.""" - # Quote with lowercase/camelCase fields that need transformation - quote = { - "chainid": 43114, - "securequote": { - "signature": "0x1234", - "data": { - "nonceAndMeta": 123, - "expiry": 9999999999, - "makerAsset": "0xToken", - "takerAsset": "0xTokenOut", - "maker": "0xMaker", - "taker": "0xTaker", - "makerAmount": 1000, - "takerAmount": 2000, - }, - }, - } - - mock_w3 = client.w3_l1 - mock_w3.eth.chain_id = AsyncMock(return_value=43114) - mock_w3.eth.get_transaction_count = AsyncMock(return_value=0) - - class ConstantAwaitable: - def __init__(self, val): - self.val = val - - def __await__(self): - async def _return_value(): - return self.val - - return _return_value().__await__() - - mock_w3.eth.gas_price = ConstantAwaitable(100) - mock_w3.eth.account.sign_transaction.return_value.raw_transaction = b"raw" - mock_w3.eth.send_raw_transaction = AsyncMock(return_value=b"hash") - mock_w3.to_hex.return_value = "0xHash" - - mock_contract = MagicMock() - mock_function_call = MagicMock() - mock_function_call.estimate_gas = AsyncMock(return_value=100000) - mock_function_call.build_transaction = AsyncMock(return_value={}) - mock_contract.functions.simpleSwap.return_value = mock_function_call - mock_w3.eth.contract.return_value = mock_contract - - async def mock_rpc_call(w3, method, *args): - if method == "eth.wait_for_transaction_receipt": - return {"status": 1} - elif method == "eth.send_raw_transaction": - return b"hash" - elif method == "eth.gas_price": - return 100 - return None - - client._rpc_call = AsyncMock(side_effect=mock_rpc_call) - - res = await client.execute_rfq_swap(quote) - assert res.success - assert res.data["tx_hash"] == "0xHash" - assert res.data["operation"] == "execute_rfq_swap" - - # Verify contract call uses transformed field names - expected_tuple = ( - 123, - 9999999999, - "0xToken", - "0xTokenOut", - "0xMaker", - "0xTaker", - 1000, - 2000, - ) - args = mock_contract.functions.simpleSwap.call_args[0] - assert args[0] == expected_tuple - assert args[1] == b"\x12\x34" - async def test_get_swap_quote_error_status(self, client): """Test _get_swap_quote_base with non-200 status.""" mock_resp_error = AsyncMock() @@ -480,25 +393,23 @@ async def test_get_swap_quote_error_status(self, client): assert "Internal Server Error" in result.error async def test_execute_rfq_swap_native(self, client): - """Test execute_rfq_swap with native token.""" + """execute_rfq_swap with native token (zero-address makerAsset).""" quote = { "success": True, - "securequote": { - "signature": "0x1234", - "data": { - "nonceAndMeta": 123, - "expiry": 9999999999, - "makerAsset": "0x0000000000000000000000000000000000000000", - "takerAsset": "0xTokenOut", - "maker": "0xMaker", - "taker": "0xTaker", - "makerAmount": 1000, - "takerAmount": 2000, - }, + "signature": "0x1234", + "order": { + "nonceAndMeta": 123, + "expiry": 9999999999, + "makerAsset": "0x0000000000000000000000000000000000000000", + "takerAsset": "0x2222222222222222222222222222222222222222", + "maker": "0x0000000000000000000000000000000000000003", + "taker": "0x0000000000000000000000000000000000000004", + "makerAmount": 1000, + "takerAmount": 2000, }, } - mock_w3 = client.w3_l1 + mock_w3 = client.connected_chain_providers["Avalanche"] mock_contract = MagicMock() mock_w3.eth.contract.return_value = mock_contract @@ -507,8 +418,7 @@ async def test_execute_rfq_swap_native(self, client): await client.execute_rfq_swap(quote) - # Note: Current implementation does not set 'value' even for native tokens. - # So we just verify it runs without error. + # takerAsset is non-zero, so the call carries value=0. async def test_execute_rfq_swap_errors(self, client): """Test execute_rfq_swap errors.""" @@ -517,22 +427,23 @@ async def test_execute_rfq_swap_errors(self, client): with pytest.raises(ValueError, match="Account is required for signing transactions"): await client.execute_rfq_swap({}) client.account = MagicMock() - client.account.address = "0xUser" + client.account.address = "0x0000000000000000000000000000000000000005" # Provider missing # Ensure data is valid to pass checks - valid_quote = {"success": True, "securequote": {"signature": "s", "data": {"a": 1}}} - # So we need to unset it for this test. - client.w3_l1 = None + valid_quote = {"success": True, "signature": "s", "order": {"a": 1}} + # Strip the connected-chain provider so resolution fails. + saved_providers = client.connected_chain_providers + client.connected_chain_providers = {} result = await client.execute_rfq_swap(valid_quote) assert not result.success assert "not initialized" in result.error - client.w3_l1 = MagicMock() # Restore w3_l1 + client.connected_chain_providers = saved_providers # Restore providers # Contract missing (Empty dict) client.deployments["MainnetRFQ"] = {} result = await client.execute_rfq_swap( - {"chainId": 43114, "success": True, "securequote": {"signature": "s", "data": {"a": 1}}} + {"chainId": 43114, "success": True, "signature": "s", "order": {"a": 1}} ) assert not result.success assert "not initialized" in result.error @@ -541,23 +452,25 @@ async def test_execute_rfq_swap_errors(self, client): if "MainnetRFQ" in client.deployments: del client.deployments["MainnetRFQ"] result = await client.execute_rfq_swap( - {"chainId": 43114, "success": True, "securequote": {"signature": "s", "data": {"a": 1}}} + {"chainId": 43114, "success": True, "signature": "s", "order": {"a": 1}} ) assert not result.success assert "not initialized" in result.error # Exception - client.deployments["MainnetRFQ"] = {"Avalanche": {"address": "0x", "abi": []}} + client.deployments["MainnetRFQ"] = { + "Avalanche": {"address": "0xeed3c159f3a96ab8d41c8b9ca49ee1e5071a7cdd", "abi": []} + } # We need to ensure w3.eth.contract doesn't raise, but something inside try block raises # Or we fix the code to wrap contract creation in try block. # For now, let's mock contract creation to succeed, but function call to fail. - mock_w3 = client.w3_l1 + mock_w3 = client.connected_chain_providers["Avalanche"] mock_contract = MagicMock() mock_w3.eth.contract.return_value = mock_contract mock_contract.functions.simpleSwap.side_effect = Exception("Err") result = await client.execute_rfq_swap( - {"success": True, "securequote": {"signature": "s", "data": {"a": 1}}} + {"success": True, "signature": "s", "order": {"a": 1}} ) assert not result.success assert "executing swap" in result.error.lower() @@ -599,31 +512,25 @@ async def test_coverage_gaps(self, client): assert not result.success assert "Could not resolve" in result.error or "not recognized" in result.error - # We need chain_id=43114 but NOT in chain_config map? - # Or chain_id=43114 and chain_config has it but we want to test fallback? - # The code iterates chain_config to find name. - # If we remove 43114 from chain_config, it should hit fallback. - - client.chain_config = {} # Empty config + # Quote without chain_id falls back to self.chain_id and routes + # through chain_config + connected_chain_providers. quote = { "success": True, - "securequote": { - "signature": "0x1234", - "data": { - "nonceAndMeta": 1, - "expiry": 1, - "makerAsset": "0x", - "takerAsset": "0x", - "maker": "0x", - "taker": "0x", - "makerAmount": 1, - "takerAmount": 1, - }, + "signature": "0x1234", + "order": { + "nonceAndMeta": 1, + "expiry": 1, + "makerAsset": "0x1111111111111111111111111111111111111111", + "takerAsset": "0x2222222222222222222222222222222222222222", + "maker": "0x0000000000000000000000000000000000000003", + "taker": "0x0000000000000000000000000000000000000004", + "makerAmount": 1, + "takerAmount": 1, }, } - # Mock provider and contract - mock_w3 = client.w3_l1 + # Mock provider and contract on the connected-chain provider. + mock_w3 = client.connected_chain_providers["Avalanche"] mock_contract = MagicMock() mock_function_call = MagicMock() mock_function_call.estimate_gas = AsyncMock(return_value=100000) @@ -646,7 +553,10 @@ async def _return_value(): mock_w3.eth.gas_price = ConstantAwaitable(100) mock_w3.to_hex.return_value = "0xHash" mock_w3.eth.account.sign_transaction.return_value.raw_transaction = b"raw" - client.deployments["MainnetRFQ"]["Avalanche"] = {"address": "0x", "abi": []} + client.deployments["MainnetRFQ"]["Avalanche"] = { + "address": "0xeed3c159f3a96ab8d41c8b9ca49ee1e5071a7cdd", + "abi": [], + } # Mock _rpc_call to return receipt with status=1 async def mock_rpc_call(w3, method, *args): @@ -666,23 +576,19 @@ async def mock_rpc_call(w3, method, *args): assert res.data["operation"] == "execute_rfq_swap" async def test_swap_errors(self, client): - """Test swap client errors and edge cases.""" - quote = {"success": False, "reason": "Bad quote"} + """execute_rfq_swap rejects malformed quotes with clear messages.""" client.account = MagicMock() - client.account.address = "0xUser" - result = await client.execute_rfq_swap(quote) - assert not result.success - assert "Cannot execute failed quote" in result.error + client.account.address = "0x0000000000000000000000000000000000000005" - quote = {"success": True} - result = await client.execute_rfq_swap(quote) + # signature missing. + result = await client.execute_rfq_swap({"order": {"a": 1}}) assert not result.success - assert "Invalid quote format" in result.error + assert result.error == "Invalid firm quote: missing 'signature' or 'order' field." - quote = {"success": True, "securequote": {}} - result = await client.execute_rfq_swap(quote) + # order missing. + result = await client.execute_rfq_swap({"signature": "0xSig"}) assert not result.success - assert "Invalid secure quote data" in result.error + assert result.error == "Invalid firm quote: missing 'signature' or 'order' field." async def test_execute_rfq_swap_result_error(self, client): """Test execute_rfq_swap when quote is a Result with error (lines 189-191).""" @@ -701,18 +607,16 @@ async def test_execute_rfq_swap_result_success(self, client): # Test case: quote is a Result with success=True, should extract data quote_data = { "success": True, - "securequote": { - "signature": "0x1234", - "data": { - "nonceAndMeta": 1, - "expiry": 9999999999, - "makerAsset": "0xMakerAsset", - "takerAsset": "0xTakerAsset", - "maker": "0xMaker", - "taker": "0xTaker", - "makerAmount": 1000000, - "takerAmount": 2000000, - }, + "signature": "0x1234", + "order": { + "nonceAndMeta": 1, + "expiry": 9999999999, + "makerAsset": "0x0000000000000000000000000000000000000001", + "takerAsset": "0x0000000000000000000000000000000000000002", + "maker": "0x0000000000000000000000000000000000000003", + "taker": "0x0000000000000000000000000000000000000004", + "makerAmount": 1000000, + "takerAmount": 2000000, }, } successful_quote = Result.ok(quote_data) @@ -723,7 +627,12 @@ async def test_execute_rfq_swap_result_success(self, client): return_value=100000 ) mock_contract.functions.simpleSwap.return_value.build_transaction = AsyncMock( - return_value={"from": "0xUser", "nonce": 0, "gas": 120000, "gasPrice": 100} + return_value={ + "from": "0x0000000000000000000000000000000000000005", + "nonce": 0, + "gas": 120000, + "gasPrice": 100, + } ) client._get_rfq_contract = AsyncMock(return_value=(client.w3_l1, mock_contract)) client.w3_l1.eth.gas_price = AsyncMock(return_value=100) @@ -749,17 +658,17 @@ async def mock_rpc_call(w3, method, *args): quote = { "success": True, - "securequote": { - "signature": b"sig", # Bytes signature - "order": { # 'order' instead of 'data' - "makerAsset": "A", - "takerAsset": "B", - "maker": "M", - "taker": "T", - }, + "signature": b"sig", # Bytes signature path. + "order": { + "makerAsset": "0x1111111111111111111111111111111111111111", + "takerAsset": "0x2222222222222222222222222222222222222222", + "maker": "0x0000000000000000000000000000000000000003", + "taker": "0x0000000000000000000000000000000000000004", }, } - client.deployments["MainnetRFQ"] = {"Avalanche": {"address": "0xRFQ", "abi": []}} + client.deployments["MainnetRFQ"] = { + "Avalanche": {"address": "0xeed3c159f3a96ab8d41c8b9ca49ee1e5071a7cdd", "abi": []} + } mock_w3 = client.w3_l1 mock_contract = MagicMock() mock_function_call = MagicMock() @@ -837,26 +746,24 @@ async def test_execute_rfq_swap_retry_disabled(self, client): client._rpc_rate_limiter = None quote = { - "securequote": { - "order": { - "nonceAndMeta": 1, - "expiry": 9999999999, - "makerAsset": "0xMakerAsset", - "takerAsset": "0xTakerAsset", - "maker": "0xMaker", - "taker": "0xTaker", - "makerAmount": 1000000, - "takerAmount": 2000000, - }, - "signature": "0x1234", - } + "signature": "0x1234", + "order": { + "nonceAndMeta": 1, + "expiry": 9999999999, + "makerAsset": "0x0000000000000000000000000000000000000001", + "takerAsset": "0x0000000000000000000000000000000000000002", + "maker": "0x0000000000000000000000000000000000000003", + "taker": "0x0000000000000000000000000000000000000004", + "makerAmount": 1000000, + "takerAmount": 2000000, + }, } client.rfq_pairs = {43114: {"A/B": {"pair": "A/B"}}} client.deployments = { "MainnetRFQ": { "Avalanche": { - "address": "0xRFQ", + "address": "0xeed3c159f3a96ab8d41c8b9ca49ee1e5071a7cdd", "abi": [], } } @@ -868,7 +775,7 @@ async def test_execute_rfq_swap_retry_disabled(self, client): return_value=100000 ) mock_contract.functions.simpleSwap.return_value.build_transaction = AsyncMock( - return_value={"to": "0xRFQ", "data": "0x"} + return_value={"to": "0xeed3c159f3a96ab8d41c8b9ca49ee1e5071a7cdd", "data": "0x"} ) mock_w3.eth.contract.return_value = mock_contract mock_w3.eth.send_raw_transaction = AsyncMock(return_value=b"tx") @@ -1077,7 +984,7 @@ def side_effect(url, params=None, **kwargs): assert "No swap pairs found" in result.error # ------------------------------------------------------------------ - # secure_quote snake_case transform, nonceAndMeta, execute_swap edge paths + # order-data snake_case transform, nonceAndMeta, execute_swap edge paths # ------------------------------------------------------------------ def test_transform_order_data_nonce_and_meta_camelcase(self, client): @@ -1109,21 +1016,19 @@ async def test_execute_rfq_swap_result_ok_none_data(self, client): assert "empty data" in res.error async def test_execute_rfq_swap_tx_reverted(self, client): - """execute_rfq_swap returns fail when the swap transaction receipt has status=0.""" + """Reverted swap surfaces tx hash, block, and revert reason in the error.""" quote = { "success": True, - "secure_quote": { - "signature": "0xabcd", - "data": { - "makerAsset": "A", - "takerAsset": "B", - "maker": "M", - "taker": "T", - "makerAmount": 1, - "takerAmount": 1, - "expiry": 9999999999, - "nonceAndMeta": 0, - }, + "signature": "0xabcd", + "order": { + "makerAsset": "0x1111111111111111111111111111111111111111", + "takerAsset": "0x2222222222222222222222222222222222222222", + "maker": "0x0000000000000000000000000000000000000003", + "taker": "0x0000000000000000000000000000000000000004", + "makerAmount": 1, + "takerAmount": 1, + "expiry": 9999999999, + "nonceAndMeta": 0, }, } mock_contract = MagicMock() @@ -1133,20 +1038,24 @@ async def test_execute_rfq_swap_tx_reverted(self, client): mock_contract.functions.simpleSwap.return_value.build_transaction = AsyncMock( return_value={ "from": client.account.address, + "to": "0xrfq", + "data": "0xdeadbeef", "nonce": 0, "gas": 120000, "gasPrice": 100, + "value": 0, } ) client._get_rfq_contract = AsyncMock(return_value=(client.w3_l1, mock_contract)) - client.w3_l1.to_hex = lambda x: "0xHash" + client.w3_l1.to_hex = lambda x: "0xdeadbeef" + client.w3_l1.eth.call = AsyncMock(side_effect=Exception("execution reverted: RF-EXP-01")) async def mock_rpc_call(w3, method, *args): if method == "eth.wait_for_transaction_receipt": - return {"status": 0} # reverted - elif method == "eth.send_raw_transaction": + return {"status": 0, "blockNumber": 42} + if method == "eth.send_raw_transaction": return b"tx_hash" - elif method == "eth.gas_price": + if method == "eth.gas_price": return 100 return None @@ -1155,23 +1064,92 @@ async def mock_rpc_call(w3, method, *args): res = await client.execute_rfq_swap(quote) assert not res.success assert "Transaction reverted" in res.error + assert "tx=0xdeadbeef" in res.error + assert "block=42" in res.error + assert "RF-EXP-01" in res.error + + async def test_extract_revert_reason_generic_error(self, client): + """A non-revert exception from eth_call falls back to a sliced message.""" + client.w3_l1.eth.call = AsyncMock(side_effect=Exception("rpc error: foo")) + reason = await client._extract_revert_reason( + client.w3_l1, {"from": "0xa", "to": "0xb", "data": "0xc"}, {"blockNumber": 1} + ) + assert reason == "rpc error: foo" + + async def test_extract_revert_reason_handles_outer_failure(self, client): + """If the helper itself blows up, it returns None instead of propagating.""" + broken_tx = MagicMock() + broken_tx.get.side_effect = RuntimeError("boom") + reason = await client._extract_revert_reason(client.w3_l1, broken_tx, {"blockNumber": 1}) + assert reason is None + + async def test_execute_rfq_swap_revert_without_replay(self, client): + """When eth_call replay is unavailable, error still surfaces tx + block.""" + quote = { + "success": True, + "signature": "0xabcd", + "order": { + "makerAsset": "0x1111111111111111111111111111111111111111", + "takerAsset": "0x2222222222222222222222222222222222222222", + "maker": "0x0000000000000000000000000000000000000003", + "taker": "0x0000000000000000000000000000000000000004", + "makerAmount": 1, + "takerAmount": 1, + "expiry": 9999999999, + "nonceAndMeta": 0, + }, + } + mock_contract = MagicMock() + mock_contract.functions.simpleSwap.return_value.estimate_gas = AsyncMock( + return_value=100000 + ) + mock_contract.functions.simpleSwap.return_value.build_transaction = AsyncMock( + return_value={ + "from": client.account.address, + "to": "0xrfq", + "data": "0xdeadbeef", + "nonce": 0, + "gas": 120000, + "gasPrice": 100, + "value": 0, + } + ) + client._get_rfq_contract = AsyncMock(return_value=(client.w3_l1, mock_contract)) + client.w3_l1.to_hex = lambda x: "0xabc123" + # eth.call returns a value (no exception) — no revert reason available + client.w3_l1.eth.call = AsyncMock(return_value=b"") + + async def mock_rpc_call(w3, method, *args): + if method == "eth.wait_for_transaction_receipt": + return {"status": 0, "blockNumber": 99} + if method == "eth.send_raw_transaction": + return b"tx_hash" + if method == "eth.gas_price": + return 100 + return None + + client._rpc_call = AsyncMock(side_effect=mock_rpc_call) + + res = await client.execute_rfq_swap(quote) + assert not res.success + assert "tx=0xabc123" in res.error + assert "block=99" in res.error + assert "reason=" not in res.error async def test_execute_rfq_swap_no_wait_for_receipt(self, client): """execute_rfq_swap returns 'sent' message when wait_for_receipt=False.""" quote = { "success": True, - "secure_quote": { - "signature": "0xabcd", - "data": { - "makerAsset": "A", - "takerAsset": "B", - "maker": "M", - "taker": "T", - "makerAmount": 1, - "takerAmount": 1, - "expiry": 9999999999, - "nonceAndMeta": 0, - }, + "signature": "0xabcd", + "order": { + "makerAsset": "0x1111111111111111111111111111111111111111", + "takerAsset": "0x2222222222222222222222222222222222222222", + "maker": "0x0000000000000000000000000000000000000003", + "taker": "0x0000000000000000000000000000000000000004", + "makerAmount": 1, + "takerAmount": 1, + "expiry": 9999999999, + "nonceAndMeta": 0, }, } mock_contract = MagicMock() @@ -1208,3 +1186,620 @@ async def test_estimate_swap_gas_no_account_raises(self, client): client.account = None with pytest.raises(ValueError, match="Account is required for gas estimation"): await client._estimate_swap_gas(MagicMock(), (), b"") + + # ------------------------------------------------------------------ + # Flat firm-quote shape (signature/order at top level) — see docs/simple-swap.md + # ------------------------------------------------------------------ + + async def test_execute_rfq_swap_flat_firm_quote_succeeds(self, client): + """execute_rfq_swap completes a swap given a flat firm-quote dict.""" + quote = { + "signature": "0xabcd", + "order": { + "nonceAndMeta": 7, + "expiry": 9999999999, + "makerAsset": "0x0000000000000000000000000000000000000001", + "takerAsset": "0x0000000000000000000000000000000000000002", + "maker": "0x0000000000000000000000000000000000000003", + "taker": "0x0000000000000000000000000000000000000004", + "makerAmount": 1000, + "takerAmount": 2000, + }, + "tx": { + "to": "0xeed3c159f3a96ab8d41c8b9ca49ee1e5071a7cdd", + "data": "0x", + "gasLimit": 120000, + }, + } + + mock_contract = MagicMock() + mock_contract.functions.simpleSwap.return_value.estimate_gas = AsyncMock( + return_value=100000 + ) + mock_contract.functions.simpleSwap.return_value.build_transaction = AsyncMock( + return_value={ + "from": client.account.address, + "nonce": 0, + "gas": 120000, + "gasPrice": 100, + } + ) + client._get_rfq_contract = AsyncMock(return_value=(client.w3_l1, mock_contract)) + client.w3_l1.to_hex = lambda x: "0xHash" + + async def mock_rpc_call(w3, method, *args): + if method == "eth.wait_for_transaction_receipt": + return {"status": 1} + if method == "eth.send_raw_transaction": + return b"tx_hash" + if method == "eth.gas_price": + return 100 + return None + + client._rpc_call = AsyncMock(side_effect=mock_rpc_call) + + res = await client.execute_rfq_swap(quote) + assert res.success + assert res.data["tx_hash"] == "0xHash" + # Order tuple matches the inner order dict. + args = mock_contract.functions.simpleSwap.call_args[0] + assert args[0] == ( + 7, + 9999999999, + "0x0000000000000000000000000000000000000001", + "0x0000000000000000000000000000000000000002", + "0x0000000000000000000000000000000000000003", + "0x0000000000000000000000000000000000000004", + 1000, + 2000, + ) + + async def test_execute_rfq_swap_missing_signature_returns_fail(self, client): + """execute_rfq_swap fails with the documented message when signature is absent.""" + result = await client.execute_rfq_swap({"order": {"a": 1}}) + assert not result.success + assert result.error == "Invalid firm quote: missing 'signature' or 'order' field." + + async def test_execute_rfq_swap_missing_order_returns_fail(self, client): + """execute_rfq_swap fails with the documented message when order is absent.""" + result = await client.execute_rfq_swap({"signature": "0xSig"}) + assert not result.success + assert result.error == "Invalid firm quote: missing 'signature' or 'order' field." + + # ------------------------------------------------------------------ + # Envelope unwrap — RFQ HTTP returns {"success": true, "quote": {...}} + # ------------------------------------------------------------------ + + def test_transform_quote_from_api_unwraps_envelope(self, client): + """The outer ``{"success": true, "quote": {...}}`` envelope is unwrapped.""" + envelope = { + "success": True, + "quote": { + "chainId": 43114, + "signature": "0xSig", + "order": { + "nonceAndMeta": 1, + "makerAsset": "0xM", + "takerAsset": "0xT", + "makerAmount": 100, + "takerAmount": 200, + }, + }, + } + + transformed = client._transform_quote_from_api(envelope) + + # Inner-dict fields hoisted to top level. + assert transformed["signature"] == "0xSig" + assert transformed["chain_id"] == 43114 + assert transformed["order"]["nonce_and_meta"] == 1 + assert transformed["order"]["maker_asset"] == "0xM" + # Envelope keys are gone. + assert "success" not in transformed + assert "quote" not in transformed + + def test_transform_quote_from_api_passthrough_when_no_envelope(self, client): + """An already-inner dict (no ``quote`` key) is processed in place.""" + inner = { + "chainId": 43114, + "signature": "0xSig", + "order": {"nonceAndMeta": 1, "makerAsset": "0xM"}, + } + + transformed = client._transform_quote_from_api(inner) + + # Snake_case aliases applied. + assert transformed["chain_id"] == 43114 + assert transformed["order"]["nonce_and_meta"] == 1 + assert transformed["order"]["maker_asset"] == "0xM" + # Original keys preserved (no envelope to strip). + assert transformed["signature"] == "0xSig" + assert transformed["chainId"] == 43114 + + async def test_get_swap_firm_quote_returns_failure_when_api_says_success_false(self, client): + """HTTP 200 with ``success: false`` becomes Result.fail at the HTTP layer.""" + mock_resp = AsyncMock() + mock_resp.status = 200 + mock_resp.json = AsyncMock( + return_value={"success": False, "reason": "Insufficient liquidity"} + ) + mock_resp.text = AsyncMock(return_value="") + mock_cm = AsyncMock() + mock_cm.__aenter__.return_value = mock_resp + mock_cm.__aexit__.return_value = None + client._mock_session.get.return_value = mock_cm + + client.rfq_pairs[43114] = {"AVAX/USDC": {}} + result = await client.get_swap_firm_quote("AVAX", "USDC", 1.0, chain_id=43114) + + assert not result.success + assert "Insufficient liquidity" in result.error + assert "Cannot execute failed quote" in result.error + + async def test_get_swap_firm_quote_envelope_failure_falls_back_to_error_field(self, client): + """If ``reason`` is absent, fall back to ``error``, then a default message.""" + mock_resp = AsyncMock() + mock_resp.status = 200 + mock_resp.json = AsyncMock(return_value={"success": False, "error": "MM offline"}) + mock_resp.text = AsyncMock(return_value="") + mock_cm = AsyncMock() + mock_cm.__aenter__.return_value = mock_resp + mock_cm.__aexit__.return_value = None + client._mock_session.get.return_value = mock_cm + + client.rfq_pairs[43114] = {"AVAX/USDC": {}} + result = await client.get_swap_firm_quote("AVAX", "USDC", 1.0, chain_id=43114) + + assert not result.success + assert "MM offline" in result.error + + # Now neither reason nor error — default message. + mock_resp.json = AsyncMock(return_value={"success": False}) + result = await client.get_swap_firm_quote("AVAX", "USDC", 1.0, chain_id=43114) + assert not result.success + assert "Quote API returned success=false" in result.error + + async def test_execute_rfq_swap_handles_envelope_wrapped_response(self, client): + """execute_rfq_swap unwraps the envelope when handed the raw API payload.""" + envelope = { + "success": True, + "quote": { + "signature": "0xabcd", + "order": { + "nonceAndMeta": 7, + "expiry": 9999999999, + "makerAsset": "0x0000000000000000000000000000000000000001", + "takerAsset": "0x0000000000000000000000000000000000000002", + "maker": "0x0000000000000000000000000000000000000003", + "taker": "0x0000000000000000000000000000000000000004", + "makerAmount": 1000, + "takerAmount": 2000, + }, + "tx": { + "to": "0xeed3c159f3a96ab8d41c8b9ca49ee1e5071a7cdd", + "data": "0x", + "gasLimit": 120000, + }, + }, + } + + mock_contract = MagicMock() + mock_contract.functions.simpleSwap.return_value.estimate_gas = AsyncMock( + return_value=100000 + ) + mock_contract.functions.simpleSwap.return_value.build_transaction = AsyncMock( + return_value={ + "from": client.account.address, + "nonce": 0, + "gas": 120000, + "gasPrice": 100, + } + ) + client._get_rfq_contract = AsyncMock(return_value=(client.w3_l1, mock_contract)) + client.w3_l1.to_hex = lambda x: "0xHash" + + async def mock_rpc_call(w3, method, *args): + if method == "eth.wait_for_transaction_receipt": + return {"status": 1} + if method == "eth.send_raw_transaction": + return b"tx_hash" + if method == "eth.gas_price": + return 100 + return None + + client._rpc_call = AsyncMock(side_effect=mock_rpc_call) + + res = await client.execute_rfq_swap(envelope) + assert res.success + assert res.data["tx_hash"] == "0xHash" + # Order tuple was extracted from inner dict. + args = mock_contract.functions.simpleSwap.call_args[0] + assert args[0] == ( + 7, + 9999999999, + "0x0000000000000000000000000000000000000001", + "0x0000000000000000000000000000000000000002", + "0x0000000000000000000000000000000000000003", + "0x0000000000000000000000000000000000000004", + 1000, + 2000, + ) + + def test_to_int_accepts_hex_string(self, client): + """``_to_int`` parses a 0x-prefixed hex string.""" + assert client._to_int("0x10") == 16 + assert client._to_int("0xff") == 255 + + def test_to_int_accepts_decimal_string(self, client): + """``_to_int`` parses a decimal string.""" + assert client._to_int("42") == 42 + assert client._to_int("1000000000000000000") == 10**18 + + def test_to_int_handles_none_and_empty(self, client): + """``_to_int`` returns 0 for None / empty string.""" + assert client._to_int(None) == 0 + assert client._to_int("") == 0 + + def test_to_int_passes_through_int(self, client): + """``_to_int`` returns an int input unchanged.""" + assert client._to_int(99) == 99 + + async def test_execute_rfq_swap_signature_without_0x_prefix(self, client): + """``execute_rfq_swap`` accepts a signature hex string without 0x prefix.""" + quote = { + "success": True, + "signature": "abcd", + "order": { + "nonceAndMeta": 1, + "expiry": 9999999999, + "makerAsset": "0x1111111111111111111111111111111111111111", + "takerAsset": "0x2222222222222222222222222222222222222222", + "maker": "0x0000000000000000000000000000000000000003", + "taker": "0x0000000000000000000000000000000000000004", + "makerAmount": 1, + "takerAmount": 1, + }, + } + mock_contract = MagicMock() + mock_contract.functions.simpleSwap.return_value.estimate_gas = AsyncMock( + return_value=100000 + ) + mock_contract.functions.simpleSwap.return_value.build_transaction = AsyncMock( + return_value={ + "from": client.account.address, + "nonce": 0, + "gas": 120000, + "gasPrice": 100, + } + ) + client._get_rfq_contract = AsyncMock(return_value=(client.w3_l1, mock_contract)) + client.w3_l1.to_hex = lambda x: "0xHash" + + async def mock_rpc_call(w3, method, *args): + if method == "eth.wait_for_transaction_receipt": + return {"status": 1} + if method == "eth.send_raw_transaction": + return b"tx_hash" + if method == "eth.gas_price": + return 100 + return None + + client._rpc_call = AsyncMock(side_effect=mock_rpc_call) + + res = await client.execute_rfq_swap(quote) + assert res.success + assert mock_contract.functions.simpleSwap.call_args[0][1] == b"\xab\xcd" + + # ------------------------------------------------------------------ + # _get_rfq_contract chain routing — RFQ executes on the connected + # chain (e.g. Avalanche C-Chain), not on Dexalot L1. + # ------------------------------------------------------------------ + + async def test_get_rfq_contract_uses_connected_chain_not_l1(self, client): + """_get_rfq_contract returns the connected-chain provider for the + target chain_id, never w3_l1.""" + avax_provider = MagicMock(name="avax_provider") + l1_provider = MagicMock(name="l1_provider") + client.connected_chain_providers = {"Avalanche": avax_provider} + client.w3_l1 = l1_provider + client.deployments = { + "MainnetRFQ": { + "Avalanche": { + "address": "0xeed3c159f3a96ab8d41c8b9ca49ee1e5071a7cdd", + "abi": [], + } + } + } + + w3, contract = await client._get_rfq_contract(chain_id=43114) + + assert w3 is avax_provider + assert w3 is not l1_provider + avax_provider.eth.contract.assert_called_once() + + async def test_get_rfq_contract_returns_none_when_no_connected_provider_for_chain(self, client): + """When the target chain_id has no connected provider, return (None, None) + rather than silently falling back to L1.""" + client.connected_chain_providers = {} # No providers at all + client.deployments = { + "MainnetRFQ": { + "Avalanche": { + "address": "0xeed3c159f3a96ab8d41c8b9ca49ee1e5071a7cdd", + "abi": [], + } + } + } + + w3, contract = await client._get_rfq_contract(chain_id=43114) + + assert w3 is None + assert contract is None + + async def test_get_rfq_contract_falls_back_to_self_chain_id_when_arg_none(self, client): + """If chain_id arg is None, fall back to self.chain_id; if that is + also None, return (None, None).""" + client.chain_id = None + client.deployments = { + "MainnetRFQ": { + "Avalanche": { + "address": "0xeed3c159f3a96ab8d41c8b9ca49ee1e5071a7cdd", + "abi": [], + } + } + } + + w3, contract = await client._get_rfq_contract(chain_id=None) + + assert w3 is None + assert contract is None + + async def test_get_rfq_contract_supports_chain_id_keyed_deployments(self, client): + """Deployments keyed directly by chain_id (int or str) resolve through + the connected-chain provider just like name-keyed entries.""" + avax_provider = MagicMock(name="avax_provider") + client.connected_chain_providers = {"Avalanche": avax_provider} + # int-keyed deployment + client.deployments = { + "MainnetRFQ": { + 43114: { + "address": "0xeed3c159f3a96ab8d41c8b9ca49ee1e5071a7cdd", + "abi": [], + } + } + } + w3, contract = await client._get_rfq_contract(chain_id=43114) + assert w3 is avax_provider + assert contract is not None + + # str-keyed deployment (same chain_id, different key form) + client.deployments = { + "MainnetRFQ": { + "43114": { + "address": "0xeed3c159f3a96ab8d41c8b9ca49ee1e5071a7cdd", + "abi": [], + } + } + } + w3, contract = await client._get_rfq_contract(chain_id=43114) + assert w3 is avax_provider + assert contract is not None + + async def test_get_rfq_contract_returns_none_when_deployment_lacks_address(self, client): + """A deployment entry without an 'address' key is treated as missing.""" + avax_provider = MagicMock(name="avax_provider") + client.connected_chain_providers = {"Avalanche": avax_provider} + client.deployments = {"MainnetRFQ": {"Avalanche": {"abi": []}}} + + w3, contract = await client._get_rfq_contract(chain_id=43114) + + assert w3 is None + assert contract is None + + async def test_execute_rfq_swap_routes_to_chain_id_from_quote(self, client): + """execute_rfq_swap forwards quote['chain_id'] to _get_rfq_contract so + the swap targets the connected chain, not L1.""" + avax_provider = MagicMock(name="avax_provider") + l1_provider = MagicMock(name="l1_provider") + client.connected_chain_providers = {"Avalanche": avax_provider} + client.w3_l1 = l1_provider + client.deployments = { + "MainnetRFQ": { + "Avalanche": { + "address": "0xeed3c159f3a96ab8d41c8b9ca49ee1e5071a7cdd", + "abi": [], + } + } + } + + mock_contract = MagicMock() + mock_contract.functions.simpleSwap.return_value.estimate_gas = AsyncMock( + return_value=100000 + ) + mock_contract.functions.simpleSwap.return_value.build_transaction = AsyncMock( + return_value={ + "from": client.account.address, + "nonce": 0, + "gas": 120000, + "gasPrice": 100, + } + ) + avax_provider.eth.contract.return_value = mock_contract + avax_provider.to_hex = lambda x: "0xHash" + + async def mock_rpc_call(w3, method, *args): + if method == "eth.wait_for_transaction_receipt": + return {"status": 1} + if method == "eth.send_raw_transaction": + return b"tx_hash" + if method == "eth.gas_price": + return 100 + return None + + client._rpc_call = AsyncMock(side_effect=mock_rpc_call) + + quote = { + "success": True, + "chain_id": 43114, + "signature": "0xabcd", + "order": { + "nonceAndMeta": 1, + "expiry": 9999999999, + "makerAsset": "0x1111111111111111111111111111111111111111", + "takerAsset": "0x2222222222222222222222222222222222222222", + "maker": "0x0000000000000000000000000000000000000003", + "taker": "0x0000000000000000000000000000000000000004", + "makerAmount": 1, + "takerAmount": 1, + }, + } + + res = await client.execute_rfq_swap(quote) + + assert res.success + # Contract was created against the Avalanche provider, not L1. + avax_provider.eth.contract.assert_called_once() + l1_provider.eth.contract.assert_not_called() + + # ------------------------------------------------------------------ + # _compute_msg_value — MainnetRFQ requires msg.value == takerAmount + # for native sells (takerAsset == zero address) and 0 otherwise. + # ------------------------------------------------------------------ + + def test_compute_msg_value_native_taker(self, client): + """Zero-address takerAsset returns takerAmount as int (lowercase + checksum).""" + # Lowercase zero address. + order = { + "takerAsset": "0x0000000000000000000000000000000000000000", + "takerAmount": "1000000000000000000", + } + assert client._compute_msg_value(order) == 10**18 + + # Checksummed (mixed-case API output) zero address — case-insensitive match. + order_checksum = { + "takerAsset": Web3.to_checksum_address("0x" + "0" * 40), + "takerAmount": 5, + } + assert client._compute_msg_value(order_checksum) == 5 + + def test_compute_msg_value_erc20_taker(self, client): + """Non-zero takerAsset returns 0 regardless of takerAmount.""" + order = { + "takerAsset": "0x2222222222222222222222222222222222222222", + "takerAmount": "999", + } + assert client._compute_msg_value(order) == 0 + + def test_compute_msg_value_handles_camelcase_and_snake_case(self, client): + """Helper accepts both takerAsset/takerAmount and taker_asset/taker_amount.""" + camel = { + "takerAsset": "0x" + "0" * 40, + "takerAmount": 7, + } + snake = { + "taker_asset": "0x" + "0" * 40, + "taker_amount": 7, + } + assert client._compute_msg_value(camel) == 7 + assert client._compute_msg_value(snake) == 7 + + async def test_execute_rfq_swap_passes_value_for_native_taker(self, client): + """Native sell (taker_asset == 0x0) sets value=takerAmount on both calls.""" + quote = { + "success": True, + "signature": "0xabcd", + "order": { + "nonceAndMeta": 1, + "expiry": 9999999999, + "makerAsset": "0x1111111111111111111111111111111111111111", + "takerAsset": "0x0000000000000000000000000000000000000000", + "maker": "0x0000000000000000000000000000000000000003", + "taker": "0x0000000000000000000000000000000000000004", + "makerAmount": 2000, + "takerAmount": 10**18, + }, + } + + mock_contract = MagicMock() + mock_contract.functions.simpleSwap.return_value.estimate_gas = AsyncMock( + return_value=100000 + ) + mock_contract.functions.simpleSwap.return_value.build_transaction = AsyncMock( + return_value={"to": "0xeed3", "data": "0x"} + ) + client._get_rfq_contract = AsyncMock(return_value=(client.w3_l1, mock_contract)) + client.w3_l1.to_hex = lambda x: "0xHash" + + async def mock_rpc_call(w3, method, *args): + if method == "eth.wait_for_transaction_receipt": + return {"status": 1} + if method == "eth.send_raw_transaction": + return b"tx_hash" + if method == "eth.gas_price": + return 100 + return None + + client._rpc_call = AsyncMock(side_effect=mock_rpc_call) + + res = await client.execute_rfq_swap(quote) + assert res.success + + estimate_kwargs = mock_contract.functions.simpleSwap.return_value.estimate_gas.call_args[0][ + 0 + ] + assert estimate_kwargs["value"] == 10**18 + + build_kwargs = mock_contract.functions.simpleSwap.return_value.build_transaction.call_args[ + 0 + ][0] + assert build_kwargs["value"] == 10**18 + + async def test_execute_rfq_swap_passes_zero_value_for_erc20_taker(self, client): + """ERC20 sell (non-zero taker_asset) sets value=0 on both calls.""" + quote = { + "success": True, + "signature": "0xabcd", + "order": { + "nonceAndMeta": 1, + "expiry": 9999999999, + "makerAsset": "0x1111111111111111111111111111111111111111", + "takerAsset": "0x2222222222222222222222222222222222222222", + "maker": "0x0000000000000000000000000000000000000003", + "taker": "0x0000000000000000000000000000000000000004", + "makerAmount": 2000, + "takerAmount": 10**6, + }, + } + + mock_contract = MagicMock() + mock_contract.functions.simpleSwap.return_value.estimate_gas = AsyncMock( + return_value=100000 + ) + mock_contract.functions.simpleSwap.return_value.build_transaction = AsyncMock( + return_value={"to": "0xeed3", "data": "0x"} + ) + client._get_rfq_contract = AsyncMock(return_value=(client.w3_l1, mock_contract)) + client.w3_l1.to_hex = lambda x: "0xHash" + + async def mock_rpc_call(w3, method, *args): + if method == "eth.wait_for_transaction_receipt": + return {"status": 1} + if method == "eth.send_raw_transaction": + return b"tx_hash" + if method == "eth.gas_price": + return 100 + return None + + client._rpc_call = AsyncMock(side_effect=mock_rpc_call) + + res = await client.execute_rfq_swap(quote) + assert res.success + + estimate_kwargs = mock_contract.functions.simpleSwap.return_value.estimate_gas.call_args[0][ + 0 + ] + assert estimate_kwargs["value"] == 0 + + build_kwargs = mock_contract.functions.simpleSwap.return_value.build_transaction.call_args[ + 0 + ][0] + assert build_kwargs["value"] == 0 diff --git a/uv.lock b/uv.lock index 74dd842..c2eeecf 100644 --- a/uv.lock +++ b/uv.lock @@ -733,7 +733,7 @@ wheels = [ [[package]] name = "dexalot-sdk" -version = "0.5.13" +version = "0.5.14" source = { editable = "." } dependencies = [ { name = "aiohttp" },