diff --git a/Makefile b/Makefile index 12fec5e..431ad05 100644 --- a/Makefile +++ b/Makefile @@ -11,4 +11,4 @@ test: tox bump: - poetry version patch + poetry version minor diff --git a/examples/cases/vault/deposit_to_vault.py b/examples/cases/vault/deposit_to_vault.py new file mode 100644 index 0000000..d1728ad --- /dev/null +++ b/examples/cases/vault/deposit_to_vault.py @@ -0,0 +1,22 @@ +import logging +from asyncio import run +from decimal import Decimal + +from examples.utils import create_trading_client + +LOGGER = logging.getLogger() + + +async def run_example(): + trading_client = create_trading_client() + collateral_amount = Decimal("5") + + LOGGER.info("Creating deposit of %s USDC to vault...", collateral_amount) + + await trading_client.vault.deposit_to_vault(collateral_amount) + + LOGGER.info("Deposit created") + + +if __name__ == "__main__": + run(main=run_example()) diff --git a/examples/cases/vault/withdraw_from_vault.py b/examples/cases/vault/withdraw_from_vault.py new file mode 100644 index 0000000..88a8291 --- /dev/null +++ b/examples/cases/vault/withdraw_from_vault.py @@ -0,0 +1,22 @@ +import logging +from asyncio import run +from decimal import Decimal + +from examples.utils import create_trading_client + +LOGGER = logging.getLogger() + + +async def run_example(): + trading_client = create_trading_client() + shares_amount = Decimal("5") + + LOGGER.info("Creating withdrawal of %s shares from vault...", shares_amount) + + await trading_client.vault.withdraw_from_vault(shares_amount) + + LOGGER.info("Withdrawal created") + + +if __name__ == "__main__": + run(main=run_example()) diff --git a/pyproject.toml b/pyproject.toml index 687e7b5..46b99ab 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "poetry.core.masonry.api" [tool.poetry] name = "x10-python-trading-starknet" -version = "1.2.0" +version = "1.3.0" description = "Python client for X10 API" authors = ["X10 "] repository = "https://github.com/x10xchange/python_sdk" diff --git a/tests/conftest.py b/tests/conftest.py index 7891862..e6bcef7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -58,6 +58,20 @@ def create_account_update_unknown_message(): return _create_account_update_unknown_message +@pytest.fixture +def get_asset_usd(): + from tests.fixtures.assets import get_asset_usd as _get_asset_usd + + return _get_asset_usd + + +@pytest.fixture +def get_asset_xvs(): + from tests.fixtures.assets import get_asset_xvs as _get_asset_xvs + + return _get_asset_xvs + + @pytest.fixture def create_asset_operations(): from tests.fixtures.assets import ( diff --git a/tests/fixtures/assets.py b/tests/fixtures/assets.py index 9c89f55..c577b17 100644 --- a/tests/fixtures/assets.py +++ b/tests/fixtures/assets.py @@ -1,6 +1,6 @@ from decimal import Decimal -from x10.perpetual.assets import AssetOperationModel +from x10.perpetual.assets import AssetModel, AssetOperationModel def create_asset_operations(): @@ -27,3 +27,35 @@ def create_asset_operations(): account_id=3004, ), ] + + +def get_asset_usd(): + return AssetModel( + id=1, + name="USD", + symbol="USD", + precision=6, + is_active=True, + is_collateral=True, + starkex_id=0x1, + starkex_resolution=1000000, + l1_id="0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + l1_resolution=1000000, + version=3, + ) + + +def get_asset_xvs(): + return AssetModel( + id=94, + name="XVS", + symbol="XVS", + precision=6, + is_active=True, + is_collateral=False, + starkex_id=0x07DB365513DF1EE2EB8FC2D157D4D1CBA3D4A2EF59B44DD3D61124C88B4F6084, + starkex_resolution=1000000, + l1_id="0x07DB365513Df1eE2Eb8fC2d157d4D1cBA3d4a2eF59b44Dd3D61124C88b4f6084", + l1_resolution=1000000, + version=6, + ) diff --git a/tests/perpetual/limit_order_object/test_limit_order_object_settlement.py b/tests/perpetual/limit_order_object/test_limit_order_object_settlement.py new file mode 100644 index 0000000..7cc236c --- /dev/null +++ b/tests/perpetual/limit_order_object/test_limit_order_object_settlement.py @@ -0,0 +1,58 @@ +from decimal import Decimal + +import pytest +from freezegun import freeze_time +from hamcrest import assert_that, equal_to +from pytest_mock import MockerFixture + +FROZEN_NONCE = 1473459052 + + +@freeze_time("2024-01-05 01:08:56.860694") +@pytest.mark.asyncio +async def test_create_buy_limit_order_settlement_data( + mocker: MockerFixture, create_trading_account, get_asset_usd, get_asset_xvs +): + mocker.patch("x10.utils.nonce.generate_nonce", return_value=FROZEN_NONCE) + + from x10.perpetual.configuration import MAINNET_CONFIG + from x10.perpetual.limit_order_object_settlement import create_order_settlement_data + + trading_account = create_trading_account() + collateral_asset = get_asset_usd() + vault_asset = get_asset_xvs() + + settlement, quote_amount_human, base_amount_human = create_order_settlement_data( + quote_amount=Decimal("10"), + base_amount=Decimal("7"), + position_id=trading_account.vault, + quote_asset_model=collateral_asset, + base_asset_model=vault_asset, + starknet_account=trading_account, + starknet_domain=MAINNET_CONFIG.starknet_domain, + is_buy=True, + ) + + assert_that(quote_amount_human.value, equal_to(Decimal("-10"))) + assert_that(base_amount_human.value, equal_to(Decimal("7"))) + assert_that( + settlement.to_api_request_json(), + equal_to( + { + "baseAmount": 7000000, + "quoteAmount": -10000000, + "feeAmount": 0, + "baseAssetId": "0x7db365513df1ee2eb8fc2d157d4d1cba3d4a2ef59b44dd3d61124c88b4f6084", + "quoteAssetId": "0x1", + "feeAssetId": "0x1", + "expirationTimestamp": 1705630137, + "nonce": 1473459052, + "receiverPositionId": 10002, + "senderPositionId": 10002, + "signature": { + "r": "0x77c1a73e45bf4d7934f7deb04fbd4a3c9d3261baf2007e5f25b0fc681dd3183", + "s": "0x26f4fbcf7b3dbb2bf14c438bed1c482eb3a65dc9f73b305e56345c8a3e393cd", + }, + } + ), + ) diff --git a/x10/perpetual/limit_order_object_settlement.py b/x10/perpetual/limit_order_object_settlement.py new file mode 100644 index 0000000..0507857 --- /dev/null +++ b/x10/perpetual/limit_order_object_settlement.py @@ -0,0 +1,72 @@ +import decimal +from datetime import timedelta + +from x10.perpetual.accounts import StarkPerpetualAccount +from x10.perpetual.amounts import HumanReadableAmount, StarkAmount +from x10.perpetual.assets import Asset, AssetModel +from x10.perpetual.configuration import StarknetDomain +from x10.perpetual.order_object_settlement import ( + calculate_order_settlement_expiration, + hash_limit_order, +) +from x10.perpetual.orders import LimitOrderSettlementModel +from x10.utils.date import utc_now +from x10.utils.model import SettlementSignatureModel +from x10.utils.nonce import generate_nonce + + +def create_order_settlement_data( + *, + quote_amount, + base_amount, + position_id, + quote_asset_model: AssetModel, + base_asset_model: AssetModel, + starknet_account: StarkPerpetualAccount, + starknet_domain: StarknetDomain, + is_buy: bool, +): + quote_asset = Asset.from_model(quote_asset_model) + base_asset = Asset.from_model(base_asset_model) + + quote_amount_human = HumanReadableAmount( + asset=quote_asset, + value=-quote_amount if is_buy else quote_amount, + ) + base_amount_human = HumanReadableAmount( + asset=base_asset, + value=base_amount if is_buy else -base_amount, + ) + + quote_amount_stark = quote_amount_human.to_stark_amount(decimal.Context(rounding=decimal.ROUND_UP)) + base_amount_stark = base_amount_human.to_stark_amount(decimal.Context(rounding=decimal.ROUND_UP)) + + nonce = generate_nonce() + expire_time = utc_now() + timedelta(hours=1) + order_hash = hash_limit_order( + amount_base=base_amount_stark, + amount_quote=quote_amount_stark, + max_fee=StarkAmount(0, quote_asset), + nonce=nonce, + position_id=position_id, + expiration_timestamp=expire_time, + public_key=starknet_account.public_key, + starknet_domain=starknet_domain, + ) + order_signature = starknet_account.sign(order_hash) + + settlement = LimitOrderSettlementModel( + base_amount=base_amount_stark.value, + quote_amount=quote_amount_stark.value, + fee_amount=0, + base_asset_id=int(base_asset.settlement_external_id, 16), + quote_asset_id=int(quote_asset.settlement_external_id, 16), + fee_asset_id=int(quote_asset.settlement_external_id, 16), + expiration_timestamp=calculate_order_settlement_expiration(expire_time), + nonce=nonce, + receiver_position_id=position_id, + sender_position_id=position_id, + signature=SettlementSignatureModel(r=order_signature[0], s=order_signature[1]), + ) + + return settlement, quote_amount_human, base_amount_human diff --git a/x10/perpetual/trading_client/vault_module.py b/x10/perpetual/trading_client/vault_module.py index 3b2f329..14c246a 100644 --- a/x10/perpetual/trading_client/vault_module.py +++ b/x10/perpetual/trading_client/vault_module.py @@ -1,26 +1,18 @@ import decimal -from datetime import timedelta from decimal import Decimal from types import NoneType from typing import Optional from x10.errors import X10Error from x10.perpetual.accounts import StarkPerpetualAccount -from x10.perpetual.amounts import HumanReadableAmount, StarkAmount -from x10.perpetual.assets import Asset, AssetModel from x10.perpetual.configuration import EndpointConfig -from x10.perpetual.order_object_settlement import ( - calculate_order_settlement_expiration, - hash_limit_order, -) +from x10.perpetual.limit_order_object_settlement import create_order_settlement_data from x10.perpetual.orders import LimitOrderSettlementModel from x10.perpetual.trading_client.account_module import AccountModule from x10.perpetual.trading_client.base_module import BaseModule from x10.perpetual.trading_client.info_module import InfoModule -from x10.utils.date import utc_now from x10.utils.http import send_post_request -from x10.utils.model import SettlementSignatureModel, X10BaseModel -from x10.utils.nonce import generate_nonce +from x10.utils.model import X10BaseModel # Protects from an error on shares pricing fluctuations. VAULT_SHARES_SLIPPAGE_PCT = Decimal("0.65") @@ -50,7 +42,7 @@ def __init__( *, info_module: InfoModule, account_module: AccountModule, - account: Optional[StarkPerpetualAccount], + account: Optional[StarkPerpetualAccount] = None, api_key: Optional[str] = None, ): super().__init__(endpoint_config, api_key=api_key) @@ -68,6 +60,9 @@ async def get_vault_share_balance(self) -> Decimal: return total_vault_asset_balance async def deposit_to_vault(self, *, collateral_amount: Decimal) -> None: + if self._account is None: + raise X10Error("Stark account is required for vault operations") + account_info = (await self._account_module.get_account()).data assets = await self._info_module.get_assets_dict() vault_asset_price = ( @@ -86,13 +81,15 @@ async def deposit_to_vault(self, *, collateral_amount: Decimal) -> None: vault_asset.precision, ) - settlement, collateral_amount_human, shares_amount_human = self.__create_limit_order( - collateral_amount=collateral_amount, - shares_amount=vault_shares_expected, + settlement, collateral_amount_human, shares_amount_human = create_order_settlement_data( + quote_amount=collateral_amount, + base_amount=vault_shares_expected, position_id=position_id, - collateral_asset_model=collateral_asset, - vault_asset_model=vault_asset, - buying_shares=True, + quote_asset_model=collateral_asset, + base_asset_model=vault_asset, + starknet_account=self._account, + starknet_domain=self._get_endpoint_config().starknet_domain, + is_buy=True, ) deposit_request = DepositRequestModel( from_account_id=account_info.id, @@ -115,6 +112,9 @@ async def deposit_to_vault(self, *, collateral_amount: Decimal) -> None: raise X10Error(f"Deposit error: {resp.error}") async def withdraw_from_vault(self, *, shares_amount: Decimal) -> None: + if self._account is None: + raise X10Error("Stark account is required for vault operations") + assets = await self._info_module.get_assets_dict() account_info = (await self._account_module.get_account()).data vault_asset_price = ( @@ -133,13 +133,15 @@ async def withdraw_from_vault(self, *, shares_amount: Decimal) -> None: vault_asset.precision, ) - settlement, collateral_amount_human, shares_amount_human = self.__create_limit_order( - collateral_amount=collateral_amount_expected, - shares_amount=shares_amount, + settlement, collateral_amount_human, shares_amount_human = create_order_settlement_data( + quote_amount=collateral_amount_expected, + base_amount=shares_amount, position_id=position_id, - collateral_asset_model=collateral_asset, - vault_asset_model=vault_asset, - buying_shares=False, + quote_asset_model=collateral_asset, + base_asset_model=vault_asset, + starknet_account=self._account, + starknet_domain=self._get_endpoint_config().starknet_domain, + is_buy=False, ) withdraw_request = WithdrawRequestModel( from_account_id=account_info.id, @@ -160,64 +162,6 @@ async def withdraw_from_vault(self, *, shares_amount: Decimal) -> None: if resp.error is not None: raise X10Error(f"Withdraw error: {resp.error}") - def __create_limit_order( - self, - *, - collateral_amount, - shares_amount, - position_id, - collateral_asset_model: AssetModel, - vault_asset_model: AssetModel, - buying_shares=True, - ): - if self._account is None: - raise X10Error("Stark account is required for vault investments") - - collateral_asset = Asset.from_model(collateral_asset_model) - vault_asset = Asset.from_model(vault_asset_model) - - collateral_amount_human = HumanReadableAmount( - asset=collateral_asset, - value=-collateral_amount if buying_shares else collateral_amount, - ) - - shares_amount_human = HumanReadableAmount( - asset=vault_asset, - value=shares_amount if buying_shares else -shares_amount, - ) - collateral_amount_stark = collateral_amount_human.to_stark_amount(decimal.Context(rounding=decimal.ROUND_UP)) - shares_amount_stark = shares_amount_human.to_stark_amount(decimal.Context(rounding=decimal.ROUND_UP)) - - nonce = generate_nonce() - expire_time = utc_now() + timedelta(hours=1) - order_hash = hash_limit_order( - amount_base=shares_amount_stark, - amount_quote=collateral_amount_stark, - max_fee=StarkAmount(0, collateral_asset), - nonce=nonce, - position_id=position_id, - expiration_timestamp=expire_time, - public_key=self._account.public_key, - starknet_domain=self._get_endpoint_config().starknet_domain, - ) - order_signature = self._account.sign(order_hash) - - settlement = LimitOrderSettlementModel( - base_amount=shares_amount_stark.value, - quote_amount=collateral_amount_stark.value, - fee_amount=0, - base_asset_id=int(vault_asset.settlement_external_id, 16), - quote_asset_id=int(collateral_asset.settlement_external_id, 16), - fee_asset_id=int(collateral_asset.settlement_external_id, 16), - expiration_timestamp=calculate_order_settlement_expiration(expire_time), - nonce=nonce, - receiver_position_id=position_id, - sender_position_id=position_id, - signature=SettlementSignatureModel(r=order_signature[0], s=order_signature[1]), - ) - - return settlement, collateral_amount_human, shares_amount_human - @staticmethod def __calc_vault_shares_expected( collateral_amount: Decimal, vault_asset_price: Decimal, vault_asset_precision: int