diff --git a/docs/source/builder-integration.rst b/docs/source/builder-integration.rst new file mode 100644 index 0000000..7bfe732 --- /dev/null +++ b/docs/source/builder-integration.rst @@ -0,0 +1,457 @@ +Builder Integration +=================== + +The Builder Code system allows trading interfaces (builders) to earn fees on orders placed through their platforms. When users place orders through a builder's interface, the builder can receive a portion of the trade value as fees. + +.. note:: + + For more details on the Builder Integration system, see the `official Nado documentation `_. + +Overview +-------- + +How It Works +~~~~~~~~~~~~ + +1. **Builder Registration**: Contact the Nado team to get registered as a builder +2. **Order Placement**: Users place orders through your interface with your builder ID and fee rate in the order appendix +3. **Fee Collection**: When orders are matched, builder fees are automatically collected +4. **Fee Claiming**: Claim accumulated fees to your subaccount, then withdraw normally + +Key Concepts +~~~~~~~~~~~~ + +.. list-table:: + :header-rows: 1 + + * - Term + - Description + * - Builder ID + - Unique 16-bit identifier for the builder (1-65535) + * - Builder Fee Rate + - Fee rate in units of 0.1bps (0.001%) + +Fee Rate Units +~~~~~~~~~~~~~~ + +Builder fee rates are specified in **0.1bps units** (0.001% per unit): + +- 1 unit = 0.001% = 0.00001 +- 10 units = 0.01% = 1bps +- 50 units = 0.05% = 5bps +- 100 units = 0.1% = 10bps + +Placing Orders with Builder Info +-------------------------------- + +To route orders through your builder and collect fees, include your builder information in the order ``appendix`` using the ``build_appendix`` function. + +Basic Example +~~~~~~~~~~~~~ + +.. code-block:: python + + from nado_protocol.utils.order import build_appendix + from nado_protocol.utils.expiration import OrderType + + # Create appendix with builder info + # builder_id: Your registered builder ID + # builder_fee_rate: Fee rate in 0.1bps units (e.g., 50 = 5bps = 0.05%) + appendix = build_appendix( + order_type=OrderType.DEFAULT, + builder_id=2, + builder_fee_rate=50 # 5bps = 0.05% + ) + +Complete Order Placement Example +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + from eth_account import Account + from nado_protocol.engine_client import EngineClient, EngineClientOpts + from nado_protocol.engine_client.types.execute import PlaceOrderParams, OrderParams + from nado_protocol.utils.order import build_appendix + from nado_protocol.utils.expiration import OrderType, get_expiration_timestamp + from nado_protocol.utils.math import to_pow_10, to_x18 + from nado_protocol.utils.nonce import gen_order_nonce + from nado_protocol.utils.subaccount import SubaccountParams + + # Initialize client + signer = Account.from_key("YOUR_PRIVATE_KEY") + engine_client = EngineClient( + opts=EngineClientOpts(url="https://gateway.prod.nado.xyz/v1", signer=signer) + ) + + # Get contracts info + contracts = engine_client.get_contracts() + engine_client.endpoint_addr = contracts.endpoint_addr + engine_client.chain_id = contracts.chain_id + + # Build appendix with builder info + builder_appendix = build_appendix( + order_type=OrderType.DEFAULT, + builder_id=2, # Your builder ID + builder_fee_rate=50 # 5bps fee rate + ) + + # Create order + order = OrderParams( + sender=SubaccountParams( + subaccount_owner=signer.address, + subaccount_name="default" + ), + priceX18=to_x18(100000), # Price in x18 format + amount=to_pow_10(1, 16), # 0.01 BTC + expiration=get_expiration_timestamp(60), # 60 seconds from now + nonce=gen_order_nonce(), + appendix=builder_appendix + ) + + # Place order + product_id = 2 # BTC-PERP + result = engine_client.place_order( + PlaceOrderParams(product_id=product_id, order=order) + ) + print(f"Order placed: {result}") + +Combining Builder with Other Appendix Options +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Builder info can be combined with other order options like IOC, reduce-only, or isolated positions: + +.. code-block:: python + + from nado_protocol.utils.order import build_appendix + from nado_protocol.utils.expiration import OrderType + from nado_protocol.utils.math import to_x6 + + # IOC order with builder fee + ioc_builder_appendix = build_appendix( + order_type=OrderType.IOC, + builder_id=2, + builder_fee_rate=30 # 3bps + ) + + # Reduce-only order with builder fee + reduce_only_builder_appendix = build_appendix( + order_type=OrderType.DEFAULT, + reduce_only=True, + builder_id=2, + builder_fee_rate=25 # 2.5bps + ) + + # Isolated position with builder fee + isolated_builder_appendix = build_appendix( + order_type=OrderType.DEFAULT, + isolated=True, + isolated_margin=to_x6(1000), # 1000 USDC margin + builder_id=2, + builder_fee_rate=50 # 5bps + ) + +Extracting Builder Info from Appendix +------------------------------------- + +You can extract builder information from an existing appendix: + +.. code-block:: python + + from nado_protocol.utils.order import ( + build_appendix, + order_builder_id, + order_builder_fee_rate, + order_builder_info + ) + from nado_protocol.utils.expiration import OrderType + + # Create appendix + appendix = build_appendix( + order_type=OrderType.DEFAULT, + builder_id=2, + builder_fee_rate=50 + ) + + # Extract individual fields + builder_id = order_builder_id(appendix) + fee_rate = order_builder_fee_rate(appendix) + + # Or get both as a tuple + builder_info = order_builder_info(appendix) # Returns (builder_id, fee_rate) or None + + print(f"Builder ID: {builder_id}") + print(f"Fee Rate: {fee_rate} (0.1bps units)") + print(f"Builder Info: {builder_info}") + +Querying Orders with Builder Fees +--------------------------------- + +When querying historical orders or matches, the builder fee is included in the response: + +Querying Historical Orders +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + from nado_protocol.indexer_client import IndexerClient + from nado_protocol.utils.order import order_builder_id, order_builder_fee_rate + + indexer = IndexerClient(opts={"url": "https://archive.prod.nado.xyz/v1"}) + + # Query order by digest + orders = indexer.get_historical_orders_by_digest(["0x...order_digest..."]) + + if orders.orders: + order = orders.orders[0] + print(f"Digest: {order.digest}") + print(f"Total Fee: {order.fee}") + print(f"Builder Fee: {order.builder_fee}") + + # Extract builder info from appendix + if order.appendix: + appendix_int = int(order.appendix) + print(f"Builder ID: {order_builder_id(appendix_int)}") + print(f"Builder Fee Rate: {order_builder_fee_rate(appendix_int)}") + +Querying Match Events +~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + from nado_protocol.indexer_client import IndexerClient + from nado_protocol.indexer_client.types.query import IndexerMatchesParams + + indexer = IndexerClient(opts={"url": "https://archive.prod.nado.xyz/v1"}) + + # Query matches for a subaccount + sender_hex = "0x..." # Your subaccount hex + matches = indexer.get_matches( + IndexerMatchesParams( + subaccounts=[sender_hex], + product_ids=[2], # BTC-PERP + limit=10 + ) + ) + + for match in matches.matches: + print(f"Digest: {match.digest}") + print(f"Base Filled: {match.base_filled}") + print(f"Total Fee: {match.fee}") + print(f"Sequencer Fee: {match.sequencer_fee}") + print(f"Builder Fee: {match.builder_fee}") + +Claiming Builder Fees +--------------------- + +Builders can claim their accumulated fees using the ``claim_builder_fee`` method on ``NadoContracts``. + +.. note:: + + Claiming builder fees requires a 1 USDT slow mode fee. Make sure to approve the fee before claiming. + +Complete Claiming Example +~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + from eth_account import Account + from nado_protocol.contracts import ( + NadoContracts, + NadoContractsContext, + ClaimBuilderFeeParams + ) + from nado_protocol.contracts.loader import load_deployment + from nado_protocol.utils.math import to_pow_10 + + # Load deployment config + deployment = load_deployment("mainnet") # or "testnet" + + # Initialize contracts + nado_contracts = NadoContracts( + node_url=deployment.node_url, + contracts_context=NadoContractsContext(**deployment.dict()), + ) + + signer = Account.from_key("YOUR_PRIVATE_KEY") + + # Approve 1 USDT for slow mode fee + usdt_token = nado_contracts.get_token_contract_for_product(0) + slow_mode_fee = to_pow_10(1, 6) # 1 USDT + approve_tx = nado_contracts.approve_allowance(usdt_token, slow_mode_fee, signer) + print(f"Approval tx: {approve_tx}") + + # Wait for approval to be mined + import time + time.sleep(5) + + # Claim builder fees + tx_hash = nado_contracts.claim_builder_fee( + ClaimBuilderFeeParams( + subaccount_owner=signer.address, + subaccount_name="default", + builder_id=2 # Your builder ID + ), + signer + ) + print(f"Claim tx: {tx_hash}") + +Encoding ClaimBuilderFee Transaction Manually +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +If you need to encode the transaction manually: + +.. code-block:: python + + from nado_protocol.utils.slow_mode import ( + SlowModeTxType, + encode_claim_builder_fee_tx + ) + from nado_protocol.utils.bytes32 import subaccount_to_bytes32 + + # Encode the transaction + sender_bytes = subaccount_to_bytes32("0xYourAddress", "default") + builder_id = 2 + + claim_tx = encode_claim_builder_fee_tx(sender_bytes, builder_id) + + # Verify tx type (should be 31) + assert claim_tx[0] == SlowModeTxType.CLAIM_BUILDER_FEE + + print(f"Encoded tx: {claim_tx.hex()}") + +Querying Builder Info +--------------------- + +You can query a builder's configuration: + +.. code-block:: python + + from nado_protocol.contracts import NadoContracts, NadoContractsContext + from nado_protocol.contracts.loader import load_deployment + + deployment = load_deployment("mainnet") + nado_contracts = NadoContracts( + node_url=deployment.node_url, + contracts_context=NadoContractsContext(**deployment.dict()), + ) + + # Query builder info + builder_id = 2 + builder_info = nado_contracts.get_builder_info(builder_id) + + print(f"Owner: {builder_info.owner}") + print(f"Default Fee Tier: {builder_info.default_fee_tier}") + print(f"Lowest Fee Rate: {builder_info.lowest_fee_rate}") + print(f"Highest Fee Rate: {builder_info.highest_fee_rate}") + +Querying Claim Events +--------------------- + +Query ``claim_builder_fee`` events from the indexer: + +.. code-block:: python + + from nado_protocol.indexer_client import IndexerClient + from nado_protocol.indexer_client.types.models import IndexerEventType + from nado_protocol.indexer_client.types.query import ( + IndexerEventsParams, + IndexerEventsRawLimit + ) + + indexer = IndexerClient(opts={"url": "https://archive.prod.nado.xyz/v1"}) + + # Query claim events for your subaccount + sender_hex = "0x..." # Your subaccount hex + events_data = indexer.get_events( + IndexerEventsParams( + subaccounts=[sender_hex], + event_types=[IndexerEventType.CLAIM_BUILDER_FEE], + limit=IndexerEventsRawLimit(raw=10) + ) + ) + + for event in events_data.events: + # Find corresponding tx for timestamp + tx = next( + (t for t in events_data.txs if t.submission_idx == event.submission_idx), + None + ) + if tx: + print(f"Claim event at timestamp: {tx.timestamp}") + print(f"Pre-balance: {event.pre_balance}") + print(f"Post-balance: {event.post_balance}") + +Fee Calculation +--------------- + +Builder fees are calculated based on the trade's notional value: + +.. code-block:: + + builder_fee = maker_price × |base_filled| × builder_fee_rate / 10^18 + +**Example**: Buying 0.1 BTC at $100,000 with a 5bps (50 units) builder fee: + +- Notional = $100,000 × 0.1 = $10,000 +- Fee rate in x18 = 50 × 10^13 = 5 × 10^14 +- Builder fee = $10,000 × 5 × 10^14 / 10^18 = $5 + +Validation Rules +---------------- + +Orders with builder information must satisfy: + +1. **Builder Must Exist**: The builder ID must be registered +2. **Fee Rate Within Bounds**: Fee rate must be within your configured bounds (lowest_fee_rate to highest_fee_rate) +3. **No Fee Without Builder**: If ``builder_id == 0``, then ``builder_fee_rate`` must also be 0 + +Error Codes +~~~~~~~~~~~ + +.. list-table:: + :header-rows: 1 + + * - Error Code + - Error Value + - Description + * - 2118 + - InvalidBuilder + - Builder ID is invalid, not registered, or fee rate is outside allowed bounds + +Getting Started +--------------- + +1. **Contact Nado Team**: Reach out to get registered as a builder +2. **Activate Subaccount**: Make a minimum $5 deposit to activate your subaccount +3. **Integrate**: Update your order placement to include builder info in the appendix +4. **Claim & Withdraw**: Periodically claim fees and withdraw to your wallet + +API Reference +------------- + +Order Appendix Functions +~~~~~~~~~~~~~~~~~~~~~~~~ + +- :func:`nado_protocol.utils.order.build_appendix` - Build an appendix with builder info +- :func:`nado_protocol.utils.order.order_builder_id` - Extract builder ID from appendix +- :func:`nado_protocol.utils.order.order_builder_fee_rate` - Extract fee rate from appendix +- :func:`nado_protocol.utils.order.order_builder_info` - Extract (builder_id, fee_rate) tuple + +Slow Mode Functions +~~~~~~~~~~~~~~~~~~~ + +- :func:`nado_protocol.utils.slow_mode.encode_claim_builder_fee_tx` - Encode ClaimBuilderFee transaction +- :class:`nado_protocol.utils.slow_mode.SlowModeTxType` - Slow mode transaction types + +Contract Methods +~~~~~~~~~~~~~~~~ + +- :meth:`nado_protocol.contracts.NadoContracts.claim_builder_fee` - Claim accumulated builder fees +- :meth:`nado_protocol.contracts.NadoContracts.get_builder_info` - Query builder configuration + +See Also +-------- + +- :doc:`order-appendix` - Detailed appendix field documentation +- :doc:`getting-started` - Getting started with the SDK +- :doc:`user-reference` - API reference diff --git a/docs/source/index.rst b/docs/source/index.rst index ee08394..557e9ef 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -39,4 +39,5 @@ You might want to use a virtual environment to isolate your packages. user-guides margin-manager order-appendix + builder-integration api-reference \ No newline at end of file diff --git a/nado_protocol/client/__init__.py b/nado_protocol/client/__init__.py index fa637aa..f8b965c 100644 --- a/nado_protocol/client/__init__.py +++ b/nado_protocol/client/__init__.py @@ -133,6 +133,7 @@ def create_nado_client( perp_engine_addr=deployment.perp_engine_addr, spot_engine_addr=deployment.spot_engine_addr, clearinghouse_addr=deployment.clearinghouse_addr, + offchain_exchange_addr=deployment.offchain_exchange_addr, airdrop_addr=deployment.airdrop_addr, staking_addr=deployment.staking_addr, foundation_rewards_airdrop_addr=deployment.foundation_rewards_airdrop_addr, diff --git a/nado_protocol/contracts/__init__.py b/nado_protocol/contracts/__init__.py index 26630db..173fdb0 100644 --- a/nado_protocol/contracts/__init__.py +++ b/nado_protocol/contracts/__init__.py @@ -7,14 +7,21 @@ from web3.contract.contract import ContractFunction from eth_account.signers.local import LocalAccount from nado_protocol.contracts.loader import load_abi -from nado_protocol.contracts.types import DepositCollateralParams, NadoAbiName +from nado_protocol.contracts.types import ( + BuilderInfo, + ClaimBuilderFeeParams, + DepositCollateralParams, + NadoAbiName, +) from nado_protocol.utils.bytes32 import ( hex_to_bytes32, str_to_hex, subaccount_name_to_bytes12, + subaccount_to_bytes32, zero_address, ) from nado_protocol.utils.exceptions import InvalidProductId +from nado_protocol.utils.slow_mode import encode_claim_builder_fee_tx from nado_protocol.contracts.types import * @@ -33,6 +40,8 @@ class NadoContractsContext(BaseModel): clearinghouse_addr (Optional[str]): The clearinghouse address. This may be None. + offchain_exchange_addr (Optional[str]): The offchain exchange address. This may be None. + airdrop_addr (Optional[str]): The airdrop address. This may be None. staking_addr (Optional[str]): The staking address. This may be None. @@ -46,6 +55,7 @@ class NadoContractsContext(BaseModel): spot_engine_addr: Optional[str] perp_engine_addr: Optional[str] clearinghouse_addr: Optional[str] + offchain_exchange_addr: Optional[str] airdrop_addr: Optional[str] staking_addr: Optional[str] foundation_rewards_airdrop_addr: Optional[str] @@ -62,6 +72,7 @@ class NadoContracts: querier: Contract endpoint: Contract clearinghouse: Optional[Contract] + offchain_exchange: Optional[Contract] spot_engine: Optional[Contract] perp_engine: Optional[Contract] airdrop: Optional[Contract] @@ -92,6 +103,7 @@ def __init__(self, node_url: str, contracts_context: NadoContractsContext): abi=load_abi(NadoAbiName.ENDPOINT), # type: ignore ) self.clearinghouse = None + self.offchain_exchange = None self.spot_engine = None self.perp_engine = None @@ -113,6 +125,12 @@ def __init__(self, node_url: str, contracts_context: NadoContractsContext): abi=load_abi(NadoAbiName.IPERP_ENGINE), # type: ignore ) + if self.contracts_context.offchain_exchange_addr: + self.offchain_exchange: Contract = self.w3.eth.contract( + address=self.contracts_context.offchain_exchange_addr, + abi=load_abi(NadoAbiName.IOFFCHAIN_EXCHANGE), # type: ignore + ) + if self.contracts_context.staking_addr: self.staking: Contract = self.w3.eth.contract( address=self.contracts_context.staking_addr, @@ -294,6 +312,58 @@ def claim_foundation_rewards( self.foundation_rewards_airdrop.functions.claim(proofs), signer ) + def claim_builder_fee( + self, + params: ClaimBuilderFeeParams, + signer: LocalAccount, + ) -> str: + """ + Claims accumulated builder fees via slow mode transaction. + + This submits a ClaimBuilderFee slow mode transaction to the Endpoint contract. + The fees will be credited to the specified subaccount. + + Args: + params (ClaimBuilderFeeParams): The parameters for claiming builder fees. + signer (LocalAccount): The account that will sign the transaction. + + Returns: + str: The transaction hash of the claim operation. + """ + params = ClaimBuilderFeeParams.parse_obj(params) + sender_bytes = subaccount_to_bytes32( + params.subaccount_owner, params.subaccount_name + ) + tx_bytes = encode_claim_builder_fee_tx(sender_bytes, params.builder_id) + return self.execute( + self.endpoint.functions.submitSlowModeTransaction(tx_bytes), + signer, + ) + + def get_builder_info(self, builder_id: int) -> BuilderInfo: + """ + Gets builder information from the OffchainExchange contract. + + Args: + builder_id (int): The builder ID to query. + + Returns: + BuilderInfo: The builder information including owner, fee tier, and fee rates. + + Raises: + Exception: If the OffchainExchange contract is not initialized. + """ + if self.offchain_exchange is None: + raise Exception("OffchainExchange contract not initialized") + + result = self.offchain_exchange.functions.getBuilder(builder_id).call() + return BuilderInfo( + owner=result[0], + default_fee_tier=result[1], + lowest_fee_rate=result[2], + highest_fee_rate=result[3], + ) + def _mint_mock_erc20( self, erc20: Contract, amount: int, signer: LocalAccount ) -> str: @@ -377,6 +447,8 @@ def _build_tx_params(self, signer: LocalAccount) -> TxParams: __all__ = [ "NadoContractsContext", "NadoContracts", + "BuilderInfo", + "ClaimBuilderFeeParams", "DepositCollateralParams", "NadoExecuteType", "NadoNetwork", diff --git a/nado_protocol/contracts/abis/IOffchainExchange.json b/nado_protocol/contracts/abis/IOffchainExchange.json new file mode 100644 index 0000000..3e3dc5d --- /dev/null +++ b/nado_protocol/contracts/abis/IOffchainExchange.json @@ -0,0 +1,43 @@ +[ + { + "inputs": [ + { + "internalType": "uint32", + "name": "builderId", + "type": "uint32" + } + ], + "name": "getBuilder", + "outputs": [ + { + "components": [ + { + "internalType": "address", + "name": "owner", + "type": "address" + }, + { + "internalType": "uint32", + "name": "defaultFeeTier", + "type": "uint32" + }, + { + "internalType": "int128", + "name": "lowestFeeRate", + "type": "int128" + }, + { + "internalType": "int128", + "name": "highestFeeRate", + "type": "int128" + } + ], + "internalType": "struct IOffchainExchange.Builder", + "name": "", + "type": "tuple" + } + ], + "stateMutability": "view", + "type": "function" + } +] diff --git a/nado_protocol/contracts/deployments/deployment.mainnet.json b/nado_protocol/contracts/deployments/deployment.mainnet.json index 451efe7..983d8f5 100644 --- a/nado_protocol/contracts/deployments/deployment.mainnet.json +++ b/nado_protocol/contracts/deployments/deployment.mainnet.json @@ -9,6 +9,7 @@ "endpoint": "0x05ec92D78ED421f3D3Ada77FFdE167106565974E", "spotEngine": "0xFcD94770B95fd9Cc67143132BB172EB17A0907fE", "perpEngine": "0xF8599D58d1137fC56EcDd9C16ee139C8BDf96da1", + "offchainExchange": "0x8373C3Aa04153aBc0cfD28901c3c971a946994ab", "airdrop": "0x0000000000000000000000000000000000000000", "staking": "0x0000000000000000000000000000000000000000", "foundationRewardsAirdrop": "0x0000000000000000000000000000000000000000" diff --git a/nado_protocol/contracts/deployments/deployment.testing.json b/nado_protocol/contracts/deployments/deployment.testing.json index 483bedb..ce7a7ec 100644 --- a/nado_protocol/contracts/deployments/deployment.testing.json +++ b/nado_protocol/contracts/deployments/deployment.testing.json @@ -9,6 +9,7 @@ "endpoint": "0x8A791620dd6260079BF849Dc5567aDC3F2FdC318", "spotEngine": "0x0DCd1Bf9A1b36cE34237eEaFef220932846BCD82", "perpEngine": "0x0B306BF915C4d645ff596e518fAf3F9669b97016", + "offchainExchange": "0xA51c1fc2f0D1a1b8494Ed1FE312d7C3a78Ed91C0", "airdrop": "0x0000000000000000000000000000000000000000", "staking": "0x0000000000000000000000000000000000000000", "foundationRewardsAirdrop": "0x0000000000000000000000000000000000000000" diff --git a/nado_protocol/contracts/deployments/deployment.testnet.json b/nado_protocol/contracts/deployments/deployment.testnet.json index b15e397..e61b1d9 100644 --- a/nado_protocol/contracts/deployments/deployment.testnet.json +++ b/nado_protocol/contracts/deployments/deployment.testnet.json @@ -9,6 +9,7 @@ "endpoint": "0x698D87105274292B5673367DEC81874Ce3633Ac2", "spotEngine": "0x3352b2fF0fAc4ce38A6eA1C188cF4F924df54E5D", "perpEngine": "0x4E859C47fea3666B5053B16C81AF64e77567702e", + "offchainExchange": "0x1e38180CFa9100b653c1Ea73B1B8175Aca1a45d0", "airdrop": "0x0000000000000000000000000000000000000000", "staking": "0x0000000000000000000000000000000000000000", "foundationRewardsAirdrop": "0x0000000000000000000000000000000000000000" diff --git a/nado_protocol/contracts/types.py b/nado_protocol/contracts/types.py index 462f057..6784fd2 100644 --- a/nado_protocol/contracts/types.py +++ b/nado_protocol/contracts/types.py @@ -30,6 +30,7 @@ class NadoAbiName(StrEnum): FQUERIER = "FQuerier" ICLEARINGHOUSE = "IClearinghouse" IENDPOINT = "IEndpoint" + IOFFCHAIN_EXCHANGE = "IOffchainExchange" IPERP_ENGINE = "IPerpEngine" ISPOT_ENGINE = "ISpotEngine" MOCK_ERC20 = "MockERC20" @@ -74,6 +75,7 @@ class NadoDeployment(NadoBaseModel): airdrop_addr: str = Field(alias="airdrop") staking_addr: str = Field(alias="staking") foundation_rewards_airdrop_addr: str = Field(alias="foundationRewardsAirdrop") + offchain_exchange_addr: str = Field(alias="offchainExchange") class DepositCollateralParams(NadoBaseModel): @@ -119,6 +121,38 @@ class ClaimFoundationRewardsContractParams(NadoBaseModel): claim_proofs: list[ClaimFoundationRewardsProofStruct] +class ClaimBuilderFeeParams(NadoBaseModel): + """ + Parameters for claiming builder fees via slow mode transaction. + + Attributes: + subaccount_owner (str): The address of the subaccount owner. + subaccount_name (str): The name of the subaccount (default: "default"). + builder_id (int): The builder ID to claim fees for. + """ + + subaccount_owner: str + subaccount_name: str = "default" + builder_id: int + + +class BuilderInfo(NadoBaseModel): + """ + Builder information from the OffchainExchange contract. + + Attributes: + owner (str): The address that can claim builder fees. + default_fee_tier (int): The default fee tier for the builder. + lowest_fee_rate (int): The lowest fee rate (x18). + highest_fee_rate (int): The highest fee rate (x18). + """ + + owner: str + default_fee_tier: int + lowest_fee_rate: int + highest_fee_rate: int + + class NadoExecuteType(StrEnum): """ Enumeration of possible actions to execute in Nado. diff --git a/nado_protocol/indexer_client/types/models.py b/nado_protocol/indexer_client/types/models.py index 7aae3a0..a2e869a 100644 --- a/nado_protocol/indexer_client/types/models.py +++ b/nado_protocol/indexer_client/types/models.py @@ -25,6 +25,7 @@ class IndexerEventType(StrEnum): TRANSFER_QUOTE = "transfer_quote" CREATE_ISOLATED_SUBACCOUNT = "create_isolated_subaccount" CLOSE_ISOLATED_SUBACCOUNT = "close_isolated_subaccount" + CLAIM_BUILDER_FEE = "claim_builder_fee" class IndexerCandlesticksGranularity(IntEnum): @@ -57,6 +58,7 @@ class IndexerOrderFill(IndexerBaseModel): base_filled: str quote_filled: str fee: str + builder_fee: str class IndexerHistoricalOrder(IndexerOrderFill): @@ -66,6 +68,7 @@ class IndexerHistoricalOrder(IndexerOrderFill): price_x18: str expiration: str nonce: str + appendix: str isolated: bool @@ -76,6 +79,7 @@ class IndexerSignedOrder(NadoBaseModel): class IndexerMatch(IndexerOrderFill): order: IndexerBaseOrder + sequencer_fee: str cumulative_fee: str cumulative_base_filled: str cumulative_quote_filled: str diff --git a/nado_protocol/utils/__init__.py b/nado_protocol/utils/__init__.py index b6a951a..5671d42 100644 --- a/nado_protocol/utils/__init__.py +++ b/nado_protocol/utils/__init__.py @@ -6,6 +6,7 @@ from nado_protocol.utils.nonce import * from nado_protocol.utils.exceptions import * from nado_protocol.utils.order import * +from nado_protocol.utils.slow_mode import * __all__ = [ "NadoBackendURL", @@ -53,4 +54,11 @@ "order_trigger_type", "order_twap_data", "order_execution_type", + # Builder utilities + "order_builder_id", + "order_builder_fee_rate", + "order_builder_info", + # Slow mode utilities + "SlowModeTxType", + "encode_claim_builder_fee_tx", ] diff --git a/nado_protocol/utils/order.py b/nado_protocol/utils/order.py index 864da68..e54488f 100644 --- a/nado_protocol/utils/order.py +++ b/nado_protocol/utils/order.py @@ -8,9 +8,9 @@ class AppendixBitFields: - # | value | reserved | trigger | reduce only | order type| isolated | version | - # | 64 bits | 50 bits | 2 bits | 1 bit | 2 bits | 1 bit | 8 bits | - # | 127..64 | 63..14 | 13..12 | 11 | 10..9 | 8 | 7..0 | + # | value | builderId | builderFeeRate | reserved | trigger | reduce only | order type| isolated | version | + # | 64 bits | 16 bits | 10 bits | 24 bits | 2 bits | 1 bit | 2 bits | 1 bit | 8 bits | + # | 127..64 | 63..48 | 47..38 | 37..14 | 13..12 | 11 | 10..9 | 8 | 7..0 | # Bit positions (from LSB to MSB) VERSION_BITS = 8 # bits 7..0 @@ -18,7 +18,9 @@ class AppendixBitFields: ORDER_TYPE_BITS = 2 # bits 10..9 REDUCE_ONLY_BITS = 1 # bit 11 TRIGGER_TYPE_BITS = 2 # bits 13..12 - RESERVED_BITS = 50 # bits 63..14 + RESERVED_BITS = 24 # bits 37..14 + BUILDER_FEE_RATE_BITS = 10 # bits 47..38 + BUILDER_ID_BITS = 16 # bits 63..48 VALUE_BITS = 64 # bits 127..64 (for isolated margin or TWAP data) # Bit masks @@ -28,6 +30,8 @@ class AppendixBitFields: REDUCE_ONLY_MASK = (1 << REDUCE_ONLY_BITS) - 1 TRIGGER_TYPE_MASK = (1 << TRIGGER_TYPE_BITS) - 1 RESERVED_MASK = (1 << RESERVED_BITS) - 1 + BUILDER_FEE_RATE_MASK = (1 << BUILDER_FEE_RATE_BITS) - 1 + BUILDER_ID_MASK = (1 << BUILDER_ID_BITS) - 1 VALUE_MASK = (1 << VALUE_BITS) - 1 # Bit shift positions @@ -37,6 +41,8 @@ class AppendixBitFields: REDUCE_ONLY_SHIFT = 11 TRIGGER_TYPE_SHIFT = 12 RESERVED_SHIFT = 14 + BUILDER_FEE_RATE_SHIFT = 38 + BUILDER_ID_SHIFT = 48 VALUE_SHIFT = 64 @@ -111,6 +117,8 @@ def build_appendix( isolated_margin: Optional[int] = None, twap_times: Optional[int] = None, twap_slippage_frac: Optional[float] = None, + builder_id: Optional[int] = None, + builder_fee_rate: Optional[int] = None, _version: Optional[int] = APPENDIX_VERSION, ) -> int: """ @@ -124,6 +132,8 @@ def build_appendix( isolated_margin (Optional[int]): Margin amount for isolated position if isolated is True. twap_times (Optional[int]): Number of TWAP executions (required for TWAP trigger type). twap_slippage_frac (Optional[float]): TWAP slippage fraction (required for TWAP trigger type). + builder_id (Optional[int]): Builder ID for fee sharing (16 bits, max 65535). + builder_fee_rate (Optional[int]): Builder fee rate as raw 10-bit integer (0-1023), each unit = 0.1 bps. Returns: int: The built appendix value with version set to APPENDIX_VERSION. @@ -151,6 +161,12 @@ def build_appendix( "twap_times and twap_slippage_frac are required for TWAP orders" ) + # Builder validation: both must be provided together or neither + if (builder_id is None) != (builder_fee_rate is None): + raise ValueError( + "builder_id and builder_fee_rate must both be provided or both be None" + ) + appendix = 0 version = _version if _version is not None else APPENDIX_VERSION @@ -179,6 +195,15 @@ def build_appendix( trigger_value & AppendixBitFields.TRIGGER_TYPE_MASK ) << AppendixBitFields.TRIGGER_TYPE_SHIFT + # Builder fields (bits 47..38 for fee rate, bits 63..48 for id) + if builder_id is not None and builder_fee_rate is not None: + appendix |= ( + builder_fee_rate & AppendixBitFields.BUILDER_FEE_RATE_MASK + ) << AppendixBitFields.BUILDER_FEE_RATE_SHIFT + appendix |= ( + builder_id & AppendixBitFields.BUILDER_ID_MASK + ) << AppendixBitFields.BUILDER_ID_SHIFT + # Handle upper bits (127..64) based on order type if isolated and isolated_margin is not None: # Isolated margin (bits 127..64) @@ -348,3 +373,57 @@ def order_execution_type(appendix: int) -> OrderType: appendix >> AppendixBitFields.ORDER_TYPE_SHIFT ) & AppendixBitFields.ORDER_TYPE_MASK return OrderType(order_type_bits) + + +def order_builder_id(appendix: int) -> Optional[int]: + """ + Extracts the builder ID from the appendix value. + + Args: + appendix (int): The order appendix value. + + Returns: + Optional[int]: The builder ID if set (non-zero), None otherwise. + """ + builder_id = ( + appendix >> AppendixBitFields.BUILDER_ID_SHIFT + ) & AppendixBitFields.BUILDER_ID_MASK + return builder_id if builder_id > 0 else None + + +def order_builder_fee_rate(appendix: int) -> Optional[int]: + """ + Extracts the builder fee rate from the appendix value. + + Args: + appendix (int): The order appendix value. + + Returns: + Optional[int]: The builder fee rate if builder is set, None otherwise. + This is a raw 10-bit integer (0-1023) where each unit = 0.1 bps. + """ + builder_id = order_builder_id(appendix) + if builder_id is None: + return None + return ( + appendix >> AppendixBitFields.BUILDER_FEE_RATE_SHIFT + ) & AppendixBitFields.BUILDER_FEE_RATE_MASK + + +def order_builder_info(appendix: int) -> Optional[tuple[int, int]]: + """ + Extracts builder info (id and fee rate) from the appendix value. + + Args: + appendix (int): The order appendix value. + + Returns: + Optional[tuple[int, int]]: Tuple of (builder_id, builder_fee_rate) if set, None otherwise. + """ + builder_id = order_builder_id(appendix) + if builder_id is None: + return None + fee_rate = ( + appendix >> AppendixBitFields.BUILDER_FEE_RATE_SHIFT + ) & AppendixBitFields.BUILDER_FEE_RATE_MASK + return (builder_id, fee_rate) diff --git a/nado_protocol/utils/slow_mode.py b/nado_protocol/utils/slow_mode.py new file mode 100644 index 0000000..ea67896 --- /dev/null +++ b/nado_protocol/utils/slow_mode.py @@ -0,0 +1,48 @@ +""" +Utilities for encoding slow mode transactions for on-chain submission. + +Slow mode transactions are submitted directly to the Endpoint contract +via `submitSlowModeTransaction(bytes)`. +""" + +from eth_abi import encode + + +# Slow mode transaction type constants +class SlowModeTxType: + CLAIM_BUILDER_FEE = 31 + + +def encode_claim_builder_fee_tx(sender: bytes, builder_id: int) -> bytes: + """ + Encodes a ClaimBuilderFee slow mode transaction. + + This transaction is submitted on-chain via `endpoint.submitSlowModeTransaction(bytes)`. + + Args: + sender: The subaccount bytes32 that will receive the claimed fees. + builder_id: The builder ID to claim fees for. + + Returns: + bytes: The encoded transaction ready for submission. + + Example: + ```python + from nado_protocol.utils.slow_mode import encode_claim_builder_fee_tx + from nado_protocol.utils.bytes32 import subaccount_to_bytes32 + + sender = subaccount_to_bytes32({"subaccount_owner": "0x...", "subaccount_name": "default"}) + tx_bytes = encode_claim_builder_fee_tx(sender, builder_id=1) + + # Submit via endpoint contract + endpoint.functions.submitSlowModeTransaction(tx_bytes).transact() + ``` + """ + if len(sender) != 32: + raise ValueError("sender must be 32 bytes") + + # Encode the parameters: (bytes32 sender, uint32 builderId) + tx_bytes = encode(["bytes32", "uint32"], [sender, builder_id]) + + # Prepend the transaction type byte + return bytes([SlowModeTxType.CLAIM_BUILDER_FEE]) + tx_bytes diff --git a/pyproject.toml b/pyproject.toml index 92c1da6..9069d14 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "nado-protocol" -version = "0.3.2" +version = "0.3.3" description = "Nado Protocol SDK" authors = ["Jeury Mejia "] homepage = "https://nado.xyz" @@ -40,6 +40,7 @@ client-sanity = "sanity.nado_client:run" rewards-sanity = "sanity.rewards:run" signing-sanity = "sanity.signing:run" margin-sanity = "sanity.margin_manager:run" +builder-sanity = "sanity.builder:run" [[tool.poetry.source]] name = "private" diff --git a/sanity/builder.py b/sanity/builder.py new file mode 100644 index 0000000..26faefb --- /dev/null +++ b/sanity/builder.py @@ -0,0 +1,320 @@ +""" +Builder code sanity tests. + +Tests: +1. Appendix encoding with builder fields +2. ClaimBuilderFee slow mode tx encoding +3. Placing order with builder info +4. Querying historical order for builder fee +5. Querying match events +6. Submitting ClaimBuilderFee slow mode transaction +7. Polling for claim_builder_fee event +Cleanup: Cancel order if still open +""" + +import time +from eth_account import Account + +from sanity import ( + ENGINE_BACKEND_URL, + INDEXER_BACKEND_URL, + NETWORK, + SIGNER_PRIVATE_KEY, +) +from nado_protocol.contracts import ( + ClaimBuilderFeeParams, + NadoContracts, + NadoContractsContext, +) +from nado_protocol.contracts.loader import load_deployment +from nado_protocol.engine_client import EngineClient, EngineClientOpts +from nado_protocol.engine_client.types.execute import ( + CancelOrdersParams, + PlaceOrderParams, + OrderParams, +) +from nado_protocol.indexer_client import IndexerClient +from nado_protocol.indexer_client.types.models import IndexerEventType +from nado_protocol.indexer_client.types.query import ( + IndexerEventsParams, + IndexerEventsRawLimit, + IndexerMatchesParams, +) +from nado_protocol.utils.bytes32 import subaccount_to_bytes32, subaccount_to_hex +from nado_protocol.utils.expiration import OrderType, get_expiration_timestamp +from nado_protocol.utils.math import to_pow_10, to_x18 +from nado_protocol.utils.nonce import gen_order_nonce +from nado_protocol.utils.order import ( + build_appendix, + order_builder_fee_rate, + order_builder_id, + order_builder_info, +) +from nado_protocol.utils.slow_mode import SlowModeTxType, encode_claim_builder_fee_tx +from nado_protocol.utils.subaccount import SubaccountParams + + +def run(): + print("=== Builder Code Sanity Tests ===\n") + + signer = Account.from_key(SIGNER_PRIVATE_KEY) + test_builder_id = 2 + test_builder_fee_rate = 50 # 5 bps (within builder 2's range of 0.2-5 bps) + + # Test 1: Appendix encoding with builder fields + print("Test 1: Testing appendix encoding with builder fields") + builder_appendix = build_appendix( + OrderType.DEFAULT, + builder_id=test_builder_id, + builder_fee_rate=test_builder_fee_rate, + ) + extracted_builder_id = order_builder_id(builder_appendix) + extracted_fee_rate = order_builder_fee_rate(builder_appendix) + builder_info = order_builder_info(builder_appendix) + + print(f" packed appendix: {builder_appendix}") + print(f" extracted builder_id: {extracted_builder_id}") + print(f" extracted fee_rate: {extracted_fee_rate}") + + assert extracted_builder_id == test_builder_id, "Builder ID mismatch!" + assert extracted_fee_rate == test_builder_fee_rate, "Builder fee rate mismatch!" + assert builder_info == ( + test_builder_id, + test_builder_fee_rate, + ), "Builder info mismatch!" + print("✓ Appendix encoding test passed\n") + + # Test 2: ClaimBuilderFee encoding + print("Test 2: Testing ClaimBuilderFee encoding") + sender_bytes = subaccount_to_bytes32(signer.address, "default") + claim_tx = encode_claim_builder_fee_tx(sender_bytes, test_builder_id) + + print(f" encoded tx length: {len(claim_tx)} bytes") + print( + f" tx type byte: {claim_tx[0]} (expected: {SlowModeTxType.CLAIM_BUILDER_FEE})" + ) + + assert ( + claim_tx[0] == SlowModeTxType.CLAIM_BUILDER_FEE + ), "ClaimBuilderFee tx type mismatch!" + assert ( + len(claim_tx) == 65 + ), f"ClaimBuilderFee tx length mismatch: {len(claim_tx)} != 65" + print("✓ ClaimBuilderFee encoding test passed\n") + + # Test 3: Place order with builder info + print("Test 3: Placing order with builder info") + engine_client = EngineClient( + opts=EngineClientOpts(url=ENGINE_BACKEND_URL, signer=SIGNER_PRIVATE_KEY) + ) + + contracts_data = engine_client.get_contracts() + engine_client.endpoint_addr = contracts_data.endpoint_addr + engine_client.chain_id = contracts_data.chain_id + + product_id = 2 # BTC-PERP + + # Get oracle price to ensure order is within 80-120% range + all_products = engine_client.get_all_products() + perp_product = next( + (p for p in all_products.perp_products if p.product_id == product_id), None + ) + if perp_product is None: + raise Exception(f"Product {product_id} not found") + oracle_price_x18 = int(perp_product.oracle_price_x18) + + # Place a buy order well above market to ensure fill (110% of oracle) + # Round down to nearest price_increment_x18 (1e18) + price_increment = 10**18 + order_price_x18 = int(oracle_price_x18 * 1.10) + order_price_x18 = (order_price_x18 // price_increment) * price_increment + + builder_order = OrderParams( + sender=SubaccountParams( + subaccount_owner=signer.address, subaccount_name="default" + ), + priceX18=order_price_x18, + amount=to_pow_10(1, 16), # 0.01 + expiration=get_expiration_timestamp(60), + nonce=gen_order_nonce(), + appendix=builder_appendix, + ) + + order_digest = engine_client.get_order_digest(builder_order, product_id) + print(f" order digest: {order_digest}") + + sender_hex = subaccount_to_hex( + SubaccountParams(subaccount_owner=signer.address, subaccount_name="default") + ) + + order_placed = False + try: + place_order = PlaceOrderParams(product_id=product_id, order=builder_order) + res = engine_client.place_order(place_order) + print(f" order placed: digest={order_digest}") + print("✓ Order with builder info placed successfully") + order_placed = True + except Exception as e: + error_msg = str(e) + if "InvalidBuilder" in error_msg or "invalid builder" in error_msg.lower(): + print(f" Builder {test_builder_id} not configured in test environment.") + print(" Skipping order-based tests.") + print("✓ Builder tests complete (encoding tests passed)\n") + return + raise e + + # Wait for order to process + print(" waiting for order to process...") + time.sleep(2) + + # Test 4: Query historical order for builder fee + print("\nTest 4: Querying historical order for builder fee") + indexer_client = IndexerClient(opts={"url": INDEXER_BACKEND_URL}) + + historical_orders = indexer_client.get_historical_orders_by_digest([order_digest]) + + if historical_orders.orders: + order = historical_orders.orders[0] + print(f" Order found: digest={order.digest}") + print(f" baseFilled: {order.base_filled}") + print(f" totalFee: {order.fee}") + print(f" builderFee: {order.builder_fee}") + if order.builder_fee and int(order.builder_fee) > 0: + print("✓ Builder fee charged") + + # Check appendix has builder info + if order.appendix: + appendix_int = int(order.appendix) + appendix_builder_id = order_builder_id(appendix_int) + appendix_fee_rate = order_builder_fee_rate(appendix_int) + print(f" appendix.builder.builderId: {appendix_builder_id}") + print(f" appendix.builder.builderFeeRate: {appendix_fee_rate}") + if appendix_builder_id != test_builder_id: + raise Exception( + f"Order appendix builderId mismatch: expected {test_builder_id}, got {appendix_builder_id}" + ) + print("✓ Builder info verified in order appendix") + else: + print(" Order not found in indexer yet") + + # Test 5: Query match events + print("\nTest 5: Querying match events") + matches = indexer_client.get_matches( + IndexerMatchesParams( + subaccounts=[sender_hex], limit=10, product_ids=[product_id] + ) + ) + + match_for_order = next( + (m for m in matches.matches if m.digest == order_digest), None + ) + if match_for_order: + print(f" Match found for order: digest={match_for_order.digest}") + print(f" baseFilled: {match_for_order.base_filled}") + print(f" totalFee: {match_for_order.fee}") + print(f" sequencerFee: {match_for_order.sequencer_fee}") + print(f" builderFee: {match_for_order.builder_fee}") + if match_for_order.builder_fee and int(match_for_order.builder_fee) > 0: + print("✓ Builder fee charged in match") + else: + print(" No match found for order yet (order may be unfilled)") + + # Test 6: Submit ClaimBuilderFee slow mode transaction + print("\nTest 6: Submitting ClaimBuilderFee slow mode transaction") + deployment = load_deployment(NETWORK) + nado_contracts = NadoContracts( + node_url=deployment.node_url, + contracts_context=NadoContractsContext(**deployment.dict()), + ) + + # Approve 1 USDT for slow mode fee + print(" approving slow mode fee (1 USDT)...") + usdt_token = nado_contracts.get_token_contract_for_product(0) + slow_mode_fee = to_pow_10(1, 6) # 1 USDT + try: + approve_tx = nado_contracts.approve_allowance(usdt_token, slow_mode_fee, signer) + print(f" ✓ Slow mode fee approved: {approve_tx}") + time.sleep(5) # Wait for approval tx to be mined + except Exception as e: + print(f" approval failed (may already be approved): {e}") + + claim_submit_time = int(time.time()) + + try: + tx_hash = nado_contracts.claim_builder_fee( + ClaimBuilderFeeParams( + subaccount_owner=signer.address, + subaccount_name="default", + builder_id=test_builder_id, + ), + signer, + ) + print(f"✓ ClaimBuilderFee submitted, tx hash: {tx_hash}") + + # Test 7: Poll for claim_builder_fee event + print("\nTest 7: Polling for claim_builder_fee event...") + max_attempts = 10 + poll_interval = 2 + found = False + + for attempt in range(1, max_attempts + 1): + if found: + break + time.sleep(poll_interval) + events_data = indexer_client.get_events( + IndexerEventsParams( + subaccounts=[sender_hex], + event_types=[IndexerEventType.CLAIM_BUILDER_FEE], + limit=IndexerEventsRawLimit(raw=5), + ) + ) + + # Match events to txs by submission_idx to get timestamp + for event in events_data.events: + tx = next( + ( + t + for t in events_data.txs + if t.submission_idx == event.submission_idx + ), + None, + ) + if tx and tx.timestamp and int(tx.timestamp) >= claim_submit_time - 10: + print( + f" Found claim_builder_fee event on attempt {attempt} (timestamp: {tx.timestamp})" + ) + print("✓ ClaimBuilderFee event verified") + found = True + break + + if not found: + print(f" Attempt {attempt}/{max_attempts}: not found yet...") + + if not found: + print( + " No recent claim_builder_fee event found after polling (no fees accumulated or not builder owner)" + ) + + except Exception as e: + error_msg = str(e) + if "TF" in error_msg: + print( + "⚠ ClaimBuilderFee skipped: Slow mode requires 1 USDT0 fee (account may not have approved/funded)" + ) + else: + print(f"⚠ ClaimBuilderFee skipped: {error_msg}") + # Not fatal - might not be builder owner, no fees, or insufficient USDT0 + + # Cleanup: Cancel the order if it wasn't filled + print("\nCleanup: Cancelling order if still open") + if order_placed: + try: + cancel_order = CancelOrdersParams( + sender=sender_hex, productIds=[product_id], digests=[order_digest] + ) + engine_client.cancel_orders(cancel_order) + print("✓ Order cancelled") + except Exception: + print(" Order already filled or cancelled") + + print("\n=== Builder E2E Tests Complete ===") diff --git a/sanity/engine_client.py b/sanity/engine_client.py index ebf76ee..a4d8d38 100644 --- a/sanity/engine_client.py +++ b/sanity/engine_client.py @@ -83,7 +83,30 @@ def run(): btc_perp_book = client.get_orderbook("BTC-PERP_USDT0", 10) pprint(btc_perp_book) - order_price = 100_000 + print("querying all products for oracle prices...") + all_products = client.get_all_products() + + # Get WBTC (product_id=1) oracle price for spot order + spot_product = next( + (p for p in all_products.spot_products if p.product_id == 1), None + ) + if spot_product is None: + raise Exception("WBTC product not found") + spot_oracle_price_x18 = int(spot_product.oracle_price_x18) + # Use 85% of oracle price (within 80-120% range), rounded to price increment + price_increment = 10**18 + spot_order_price_x18 = int(spot_oracle_price_x18 * 0.85) + spot_order_price_x18 = (spot_order_price_x18 // price_increment) * price_increment + + # Get BTC-PERP (product_id=2) oracle price for perp order + perp_product = next( + (p for p in all_products.perp_products if p.product_id == 2), None + ) + if perp_product is None: + raise Exception("BTC-PERP product not found") + perp_oracle_price_x18 = int(perp_product.oracle_price_x18) + perp_order_price_x18 = int(perp_oracle_price_x18 * 0.85) + perp_order_price_x18 = (perp_order_price_x18 // price_increment) * price_increment print("placing order...") product_id = 1 @@ -91,7 +114,7 @@ def run(): sender=SubaccountParams( subaccount_owner=client.signer.address, subaccount_name="default" ), - priceX18=to_x18(order_price), + priceX18=spot_order_price_x18, amount=to_pow_10(1, 17), expiration=get_expiration_timestamp(40), nonce=gen_order_nonce(), @@ -110,7 +133,7 @@ def run(): sender=SubaccountParams( subaccount_owner=client.signer.address, subaccount_name="default" ), - priceX18=to_x18(order_price), + priceX18=spot_order_price_x18, amount=to_pow_10(1, 17), expiration=get_expiration_timestamp(40), nonce=gen_order_nonce(), @@ -195,7 +218,7 @@ def run(): QueryMaxOrderSizeParams( sender=sender, product_id=product_id, - price_x18=to_x18(order_price), + price_x18=spot_order_price_x18, direction="short", ) ) @@ -302,8 +325,8 @@ def run(): sender=SubaccountParams( subaccount_owner=client.signer.address, subaccount_name="default" ), - priceX18=to_x18(order_price), - amount=to_pow_10(1, 17), + priceX18=perp_order_price_x18, + amount=to_pow_10(1, 16), # 0.01 BTC for perp expiration=get_expiration_timestamp(40), nonce=gen_order_nonce(), appendix=build_appendix(OrderType.DEFAULT), diff --git a/sanity/nado_client.py b/sanity/nado_client.py index 7a94edd..ca93c7b 100644 --- a/sanity/nado_client.py +++ b/sanity/nado_client.py @@ -81,7 +81,36 @@ def run(): ) time.sleep(1) - order_price = 90_000 + # Get oracle prices for WBTC (product_id=1) and BTC-PERP (product_id=2) + print("querying all products for oracle prices...") + all_products = client.market.get_all_engine_markets() + + spot_product = next( + (p for p in all_products.spot_products if p.product_id == 1), None + ) + if spot_product is None: + raise Exception("WBTC product not found") + spot_oracle_price_x18 = int(spot_product.oracle_price_x18) + price_increment = 10**18 + # Use 85% of oracle price for buy orders (within 80-120% range) + spot_buy_price_x18 = int(spot_oracle_price_x18 * 0.85) + spot_buy_price_x18 = (spot_buy_price_x18 // price_increment) * price_increment + # Use 115% of oracle price for sell orders + spot_sell_price_x18 = int(spot_oracle_price_x18 * 1.15) + spot_sell_price_x18 = (spot_sell_price_x18 // price_increment) * price_increment + + perp_product = next( + (p for p in all_products.perp_products if p.product_id == 2), None + ) + if perp_product is None: + raise Exception("BTC-PERP product not found") + perp_oracle_price_x18 = int(perp_product.oracle_price_x18) + # Use 85% of oracle price for buy orders + perp_buy_price_x18 = int(perp_oracle_price_x18 * 0.85) + perp_buy_price_x18 = (perp_buy_price_x18 // price_increment) * price_increment + # Use 115% of oracle price for sell orders + perp_sell_price_x18 = int(perp_oracle_price_x18 * 1.15) + perp_sell_price_x18 = (perp_sell_price_x18 // price_increment) * price_increment owner = client.context.engine_client.signer.address print("placing order...") @@ -91,7 +120,7 @@ def run(): subaccount_owner=owner, subaccount_name="default", ), - priceX18=to_x18(order_price), + priceX18=spot_buy_price_x18, amount=to_pow_10(1, 17), expiration=get_expiration_timestamp(40), appendix=build_appendix(OrderType.POST_ONLY), @@ -108,12 +137,15 @@ def run(): ), amount=-to_pow_10(1, 17), ) - res = client.market.place_market_order( - PlaceMarketOrderParams( - product_id=1, market_order=market_order, slippage=0.001 # 0.1% + try: + res = client.market.place_market_order( + PlaceMarketOrderParams( + product_id=1, market_order=market_order, slippage=0.001 # 0.1% + ) ) - ) - print("market order result:", res.json(indent=2)) + print("market order result:", res.json(indent=2)) + except Exception as e: + print("market order failed (likely no liquidity):", e) sender = subaccount_to_hex(order.sender) order.sender = subaccount_to_bytes32(order.sender) @@ -145,8 +177,8 @@ def run(): subaccount_owner=owner, subaccount_name="default", ), - priceX18=to_x18(order_price), - amount=to_pow_10(1, 17), + priceX18=perp_buy_price_x18, + amount=to_pow_10(1, 16), # 0.01 BTC expiration=get_expiration_timestamp(40), appendix=build_appendix(OrderType.POST_ONLY), nonce=gen_order_nonce(), @@ -170,8 +202,8 @@ def run(): subaccount_owner=owner, subaccount_name="default", ), - priceX18=to_x18(order_price + 40_000), - amount=-to_pow_10(1, 17), + priceX18=perp_sell_price_x18, + amount=-to_pow_10(1, 16), # -0.01 BTC expiration=get_expiration_timestamp(40), appendix=build_appendix(OrderType.POST_ONLY), nonce=gen_order_nonce(), @@ -193,8 +225,8 @@ def run(): subaccount_owner=owner, subaccount_name="default", ), - priceX18=to_x18(order_price), - amount=to_pow_10(1, 17), + priceX18=perp_buy_price_x18, + amount=to_pow_10(1, 16), # 0.01 BTC expiration=get_expiration_timestamp(60), appendix=build_appendix(OrderType.POST_ONLY), nonce=gen_order_nonce(), @@ -244,7 +276,7 @@ def run(): print("querying historical orders...") historical_orders = client.market.get_subaccount_historical_orders( - {"subaccount": sender, "limit": 2} + {"subaccounts": [sender], "limit": 2} ) print("subaccount historical orders:", historical_orders.json(indent=2)) @@ -265,8 +297,13 @@ def run(): appendix=build_appendix(OrderType.IOC), nonce=gen_order_nonce(), ) - res = client.market.place_order({"product_id": btc_perp.product_id, "order": order}) - print("order result:", res.json(indent=2)) + try: + res = client.market.place_order( + {"product_id": btc_perp.product_id, "order": order} + ) + print("order result:", res.json(indent=2)) + except Exception as e: + print("open perp position failed (IOC order - likely no liquidity):", e) btc_perp_balance = [ balance @@ -278,13 +315,17 @@ def run(): print("perp balance:", btc_perp_balance.json(indent=2)) print("closing perp position...") - res = client.market.close_position( - subaccount=SubaccountParams( - subaccount_owner=client.context.signer.address, subaccount_name="default" - ), - product_id=2, - ) - print("position close result:", res.json(indent=2)) + try: + res = client.market.close_position( + subaccount=SubaccountParams( + subaccount_owner=client.context.signer.address, + subaccount_name="default", + ), + product_id=2, + ) + print("position close result:", res.json(indent=2)) + except Exception as e: + print("close perp position failed (likely no position or no liquidity):", e) subaccount_summary = client.subaccount.get_engine_subaccount_summary(subaccount) print("subaccount summary post position close:", subaccount_summary.json(indent=2)) @@ -401,9 +442,10 @@ def run(): fee_rates = client.subaccount.get_subaccount_fee_rates(sender) print("fee rates:", fee_rates.json(indent=2)) - print("querying subaccount token rewards...") - token_rewards = client.subaccount.get_subaccount_token_rewards(owner) - print("token rewards:", token_rewards.json(indent=2)) + # Note: get_subaccount_token_rewards is not implemented in the SDK + # print("querying subaccount token rewards...") + # token_rewards = client.subaccount.get_subaccount_token_rewards(owner) + # print("token rewards:", token_rewards.json(indent=2)) print("querying subaccount linked signer rate limits...") linked_signer_rate_limits = ( @@ -452,7 +494,7 @@ def run(): try: twap_res = client.market.place_twap_order( product_id=1, - price_x18=str(to_x18(52_000)), + price_x18=str(spot_buy_price_x18), total_amount_x18=str(to_pow_10(5, 17)), times=5, slippage_frac=0.005, @@ -468,9 +510,9 @@ def run(): try: trigger_res = client.market.place_price_trigger_order( product_id=1, - price_x18=str(to_x18(45_000)), - amount_x18=str(-to_pow_10(1, 18)), - trigger_price_x18=str(to_x18(46_000)), + price_x18=str(spot_sell_price_x18), + amount_x18=str(-to_pow_10(1, 17)), + trigger_price_x18=str(spot_buy_price_x18), trigger_type="last_price_below", reduce_only=True, ) diff --git a/tests/utils/test_order_appendix.py b/tests/utils/test_order_appendix.py index 0b956f2..59b0dcd 100644 --- a/tests/utils/test_order_appendix.py +++ b/tests/utils/test_order_appendix.py @@ -5,6 +5,9 @@ OrderAppendixTriggerType, TWAPBitFields, build_appendix, + order_builder_fee_rate, + order_builder_id, + order_builder_info, order_execution_type, order_is_isolated, order_is_trigger_order, @@ -40,7 +43,9 @@ def test_appendix_bit_field_sizes(): assert AppendixBitFields.ORDER_TYPE_BITS == 2 assert AppendixBitFields.REDUCE_ONLY_BITS == 1 assert AppendixBitFields.TRIGGER_TYPE_BITS == 2 - assert AppendixBitFields.RESERVED_BITS == 50 + assert AppendixBitFields.RESERVED_BITS == 24 + assert AppendixBitFields.BUILDER_FEE_RATE_BITS == 10 + assert AppendixBitFields.BUILDER_ID_BITS == 16 assert AppendixBitFields.VALUE_BITS == 64 @@ -51,7 +56,9 @@ def test_appendix_bit_masks(): assert AppendixBitFields.ORDER_TYPE_MASK == 3 assert AppendixBitFields.REDUCE_ONLY_MASK == 1 assert AppendixBitFields.TRIGGER_TYPE_MASK == 3 - assert AppendixBitFields.RESERVED_MASK == (1 << 50) - 1 + assert AppendixBitFields.RESERVED_MASK == (1 << 24) - 1 + assert AppendixBitFields.BUILDER_FEE_RATE_MASK == (1 << 10) - 1 + assert AppendixBitFields.BUILDER_ID_MASK == (1 << 16) - 1 assert AppendixBitFields.VALUE_MASK == (1 << 64) - 1 @@ -63,6 +70,8 @@ def test_appendix_bit_shift_positions(): assert AppendixBitFields.REDUCE_ONLY_SHIFT == 11 assert AppendixBitFields.TRIGGER_TYPE_SHIFT == 12 assert AppendixBitFields.RESERVED_SHIFT == 14 + assert AppendixBitFields.BUILDER_FEE_RATE_SHIFT == 38 + assert AppendixBitFields.BUILDER_ID_SHIFT == 48 assert AppendixBitFields.VALUE_SHIFT == 64 @@ -565,3 +574,164 @@ def test_twap_minimum_values(): extracted_times, extracted_slippage = twap_data assert extracted_times == times assert abs(extracted_slippage - slippage) < 1e-6 + + +# Builder tests + + +def test_builder_basic(): + """Test basic builder fields.""" + builder_id = 2 + builder_fee_rate = 500 # 50 bps + + appendix = build_appendix( + OrderType.DEFAULT, + builder_id=builder_id, + builder_fee_rate=builder_fee_rate, + ) + + assert order_builder_id(appendix) == builder_id + assert order_builder_fee_rate(appendix) == builder_fee_rate + + +def test_builder_info(): + """Test builder info extraction.""" + builder_id = 123 + builder_fee_rate = 100 + + appendix = build_appendix( + OrderType.IOC, + builder_id=builder_id, + builder_fee_rate=builder_fee_rate, + ) + + info = order_builder_info(appendix) + assert info is not None + assert info == (builder_id, builder_fee_rate) + + +def test_builder_with_other_flags(): + """Test builder fields with other order flags.""" + builder_id = 5 + builder_fee_rate = 200 + + appendix = build_appendix( + OrderType.POST_ONLY, + reduce_only=True, + builder_id=builder_id, + builder_fee_rate=builder_fee_rate, + ) + + assert order_builder_id(appendix) == builder_id + assert order_builder_fee_rate(appendix) == builder_fee_rate + assert order_execution_type(appendix) == OrderType.POST_ONLY + assert order_reduce_only(appendix) + + +def test_builder_max_values(): + """Test builder fields with maximum values.""" + max_builder_id = (1 << 16) - 1 # 16 bits = 65535 + max_fee_rate = (1 << 10) - 1 # 10 bits = 1023 + + appendix = build_appendix( + OrderType.DEFAULT, + builder_id=max_builder_id, + builder_fee_rate=max_fee_rate, + ) + + assert order_builder_id(appendix) == max_builder_id + assert order_builder_fee_rate(appendix) == max_fee_rate + + +def test_no_builder(): + """Test that orders without builder return None.""" + appendix = build_appendix(OrderType.DEFAULT) + + assert order_builder_id(appendix) is None + assert order_builder_fee_rate(appendix) is None + assert order_builder_info(appendix) is None + + +def test_builder_requires_both_fields(): + """Test that builder_id and builder_fee_rate must be provided together.""" + with pytest.raises( + ValueError, + match="builder_id and builder_fee_rate must both be provided or both be None", + ): + build_appendix(OrderType.DEFAULT, builder_id=1) + + with pytest.raises( + ValueError, + match="builder_id and builder_fee_rate must both be provided or both be None", + ): + build_appendix(OrderType.DEFAULT, builder_fee_rate=100) + + +def test_builder_round_trip(): + """Test builder fields round-trip conversion.""" + original_builder_id = 42 + original_fee_rate = 333 + + original_appendix = build_appendix( + OrderType.FOK, + reduce_only=True, + builder_id=original_builder_id, + builder_fee_rate=original_fee_rate, + ) + + # Extract all fields + version = order_version(original_appendix) + order_type = order_execution_type(original_appendix) + reduce_only = order_reduce_only(original_appendix) + builder_info = order_builder_info(original_appendix) + + assert builder_info is not None + extracted_builder_id, extracted_fee_rate = builder_info + + # Rebuild + rebuilt_appendix = build_appendix( + order_type, + reduce_only=reduce_only, + builder_id=extracted_builder_id, + builder_fee_rate=extracted_fee_rate, + ) + + assert original_appendix == rebuilt_appendix + + +def test_builder_with_trigger(): + """Test builder fields with trigger order.""" + builder_id = 10 + builder_fee_rate = 50 + + appendix = build_appendix( + OrderType.DEFAULT, + trigger_type=OrderAppendixTriggerType.PRICE, + builder_id=builder_id, + builder_fee_rate=builder_fee_rate, + ) + + assert order_builder_id(appendix) == builder_id + assert order_builder_fee_rate(appendix) == builder_fee_rate + assert order_trigger_type(appendix) == OrderAppendixTriggerType.PRICE + assert order_is_trigger_order(appendix) + + +def test_builder_with_isolated(): + """Test builder fields with isolated order.""" + builder_id = 7 + builder_fee_rate = 150 + margin = to_x6(100) + + appendix = build_appendix( + OrderType.DEFAULT, + isolated=True, + isolated_margin=margin, + builder_id=builder_id, + builder_fee_rate=builder_fee_rate, + ) + + assert order_builder_id(appendix) == builder_id + assert order_builder_fee_rate(appendix) == builder_fee_rate + assert order_is_isolated(appendix) + assert order_isolated_margin(appendix) == margin diff --git a/tests/utils/test_slow_mode.py b/tests/utils/test_slow_mode.py new file mode 100644 index 0000000..a111fc0 --- /dev/null +++ b/tests/utils/test_slow_mode.py @@ -0,0 +1,60 @@ +import pytest +from nado_protocol.utils.slow_mode import ( + SlowModeTxType, + encode_claim_builder_fee_tx, +) + + +def test_slow_mode_tx_type_constants(): + """Test slow mode transaction type constants.""" + assert SlowModeTxType.CLAIM_BUILDER_FEE == 31 + + +def test_encode_claim_builder_fee_tx_basic(): + """Test basic ClaimBuilderFee encoding.""" + sender = bytes(32) # 32 zero bytes + builder_id = 1 + + tx = encode_claim_builder_fee_tx(sender, builder_id) + + # Should start with tx type 31 (0x1f) + assert tx[0] == 31 + # Total length: 1 (tx type) + 32 (sender) + 32 (uint32 padded to 32 bytes) + assert len(tx) == 65 + + +def test_encode_claim_builder_fee_tx_with_real_sender(): + """Test ClaimBuilderFee encoding with a real sender.""" + # Construct a sender bytes32 (20 bytes address + 12 bytes name) + address = bytes.fromhex("1234567890abcdef1234567890abcdef12345678") + name = b"default" + bytes(5) # "default" is 7 bytes, pad to 12 + sender = address + name + + assert len(sender) == 32 + + builder_id = 42 + tx = encode_claim_builder_fee_tx(sender, builder_id) + + # Verify tx type + assert tx[0] == 31 + + +def test_encode_claim_builder_fee_tx_invalid_sender_length(): + """Test that invalid sender length raises error.""" + with pytest.raises(ValueError, match="sender must be 32 bytes"): + encode_claim_builder_fee_tx(bytes(31), 1) + + with pytest.raises(ValueError, match="sender must be 32 bytes"): + encode_claim_builder_fee_tx(bytes(33), 1) + + with pytest.raises(ValueError, match="sender must be 32 bytes"): + encode_claim_builder_fee_tx(bytes(0), 1) + + +def test_encode_claim_builder_fee_tx_max_builder_id(): + """Test ClaimBuilderFee with max uint32 builder ID.""" + sender = bytes(32) + max_builder_id = (1 << 32) - 1 # Max uint32 + + tx = encode_claim_builder_fee_tx(sender, max_builder_id) + assert tx[0] == 31