diff --git a/examples/06_crossuser_withdrawal.py b/examples/06_crossuser_withdrawal.py new file mode 100644 index 0000000..5941287 --- /dev/null +++ b/examples/06_crossuser_withdrawal.py @@ -0,0 +1,57 @@ +import logging.handlers +from asyncio import run +from decimal import Decimal + +from eth_account import Account + +from examples.init_env import init_env +from x10.perpetual.accounts import StarkPerpetualAccount +from x10.perpetual.configuration import MAINNET_CONFIG, TESTNET_CONFIG +from x10.perpetual.trading_client import PerpetualTradingClient + +LOGGER = logging.getLogger() +ENDPOINT_CONFIG = MAINNET_CONFIG + + +# Bridged withdrawal example. Bridge disabled on sepolia, example works only on mainnet +async def run_example(): + env_config = init_env() + amount = 5 + target_chain = "ETH" + targetWallet = "" + signing_account = Account.from_key("") + + stark_account = StarkPerpetualAccount( + api_key=env_config.api_key, + public_key=env_config.public_key, + private_key=env_config.private_key, + vault=env_config.vault_id, + ) + trading_client = PerpetualTradingClient(ENDPOINT_CONFIG, stark_account) + LOGGER.info("Getting quote") + quote = (await trading_client.account.get_bridge_quote(chain_in="STRK", + chain_out=target_chain, + amount=amount, + recipient=targetWallet + )).data + if quote.fee > Decimal(2): + LOGGER.info("Fee %s is too high", quote.fee) + return + LOGGER.info("Commiting quote") + await trading_client.account.commit_bridge_quote(quote.id) + LOGGER.info("Requesting withdrawal") + withdrawal_id = ( + await trading_client.account.withdraw( + amount=Decimal(amount), + chain_id=target_chain, + quote_id=quote.id, + target_wallet=targetWallet, + signing_account=signing_account + ) + ).data + + LOGGER.info("Withdrawal %s requested", withdrawal_id) + + +if __name__ == "__main__": + run(main=run_example()) diff --git a/examples/07_transfer_between_user_accounts.py b/examples/07_transfer_between_user_accounts.py new file mode 100644 index 0000000..d9c3383 --- /dev/null +++ b/examples/07_transfer_between_user_accounts.py @@ -0,0 +1,38 @@ +import logging.handlers +from asyncio import run +from decimal import Decimal + +from examples.init_env import init_env +from x10.perpetual.accounts import StarkPerpetualAccount +from x10.perpetual.configuration import MAINNET_CONFIG +from x10.perpetual.trading_client import PerpetualTradingClient + +LOGGER = logging.getLogger() +ENDPOINT_CONFIG = MAINNET_CONFIG + + +# Bridged withdrawal example. Bridge disabled on sepolia, example works only on mainnet +async def run_example(): + env_config = init_env() + amount = 1 + to_vault = -1 + to_l2_key = "" + + stark_account = StarkPerpetualAccount( + api_key=env_config.api_key, + public_key=env_config.public_key, + private_key=env_config.private_key, + vault=env_config.vault_id, + ) + trading_client = PerpetualTradingClient(ENDPOINT_CONFIG, stark_account) + LOGGER.info("Sending transfer") + await trading_client.account.transfer( + to_vault=to_vault, + to_l2_key=to_l2_key, + amount=Decimal(amount), + ) + LOGGER.info("Transfer sent") + + +if __name__ == "__main__": + run(main=run_example()) diff --git a/examples/08_crossuser_transfer.py b/examples/08_crossuser_transfer.py new file mode 100644 index 0000000..40a2615 --- /dev/null +++ b/examples/08_crossuser_transfer.py @@ -0,0 +1,42 @@ +import logging.handlers +from asyncio import run +from decimal import Decimal + +from eth_account import Account +from eth_account.signers.local import LocalAccount + +from examples.init_env import init_env +from x10.perpetual.accounts import StarkPerpetualAccount +from x10.perpetual.configuration import TESTNET_CONFIG, MAINNET_CONFIG +from x10.perpetual.trading_client import PerpetualTradingClient + +LOGGER = logging.getLogger() +ENDPOINT_CONFIG = MAINNET_CONFIG + + +# Bridged withdrawal example. Bridge disabled on sepolia, example works only on mainnet +async def run_example(): + env_config = init_env() + amount = 1 + to_vault = -1 + to_l2_key = "" + signing_account = Account.from_key("") + stark_account = StarkPerpetualAccount( + api_key=env_config.api_key, + public_key=env_config.public_key, + private_key=env_config.private_key, + vault=env_config.vault_id, + ) + trading_client = PerpetualTradingClient(ENDPOINT_CONFIG, stark_account) + LOGGER.info("Sending transfer") + await trading_client.account.transfer( + to_vault=to_vault, + to_l2_key=to_l2_key, + amount=Decimal(amount), + signing_account=signing_account + ) + LOGGER.info("Transfer sent") + + +if __name__ == "__main__": + run(main=run_example()) diff --git a/tests/perpetual/test_withdrawal_object.py b/tests/perpetual/test_withdrawal_object.py new file mode 100644 index 0000000..8e1be4b --- /dev/null +++ b/tests/perpetual/test_withdrawal_object.py @@ -0,0 +1,29 @@ +import datetime + +from hamcrest import assert_that, equal_to +from eth_account import Account +from decimal import Decimal + +from hamcrest import equal_to + +from x10.perpetual.user_client.onboarding import get_l2_keys_from_l1_account +from x10.perpetual.withdrawals import Withdrawal + + +def test_withdrawal_object_generation(): + known_private_key = "50c8e358cc974aaaa6e460641e53f78bdc550fd372984aa78ef8fd27c751e6f4" + + l1_account = Account.from_key(known_private_key) + + payload = Withdrawal( + account_id=12, + target_wallet="0x1234", + amount=Decimal("1"), + expiration=datetime.datetime.fromtimestamp(1710176400,tz=datetime.timezone.utc), + asset_id="0x1", + ) + result = l1_account.sign_message(payload.to_signable_message("x10.exchange")).signature.hex() + assert_that( + result, + equal_to('f1d965cc6c3d020c103e2bd295a0416ab50b0f6c67b01312bf09ea883788df2027a7dad3666c7cca618063b9eda37854642c02dc7842679ccc07e4ea0d0ec0ec1c') + ) diff --git a/x10/perpetual/configuration.py b/x10/perpetual/configuration.py index a73f24d..00549a2 100644 --- a/x10/perpetual/configuration.py +++ b/x10/perpetual/configuration.py @@ -32,7 +32,7 @@ class EndpointConfig: signing_domain="starknet.sepolia.extended.exchange", collateral_asset_contract="0x31857064564ed0ff978e687456963cba09c2c6985d8f9300a1de4962fafa054", asset_operations_contract="", - collateral_asset_on_chain_id="0x1", + collateral_asset_on_chain_id="0x31857064564ed0ff978e687456963cba09c2c6985d8f9300a1de4962fafa054", collateral_decimals=6, collateral_asset_id="0x1", starknet_domain=StarknetDomain(name="Perpetuals", version="v0", chain_id="SN_SEPOLIA", revision="1"), diff --git a/x10/perpetual/trading_client/account_module.py b/x10/perpetual/trading_client/account_module.py index 78a698a..9eaffd5 100644 --- a/x10/perpetual/trading_client/account_module.py +++ b/x10/perpetual/trading_client/account_module.py @@ -1,6 +1,8 @@ from decimal import Decimal from typing import List, Optional +from eth_account.signers.local import LocalAccount + from x10.perpetual.accounts import AccountLeverage, AccountModel from x10.perpetual.assets import ( AssetOperationModel, @@ -16,8 +18,9 @@ from x10.perpetual.trades import AccountTradeModel, TradeType from x10.perpetual.trading_client.base_module import BaseModule from x10.perpetual.transfer_object import create_transfer_object -from x10.perpetual.transfers import TransferResponseModel +from x10.perpetual.transfers import TransferResponseModel, Transfer from x10.perpetual.withdrawal_object import create_withdrawal_object +from x10.perpetual.withdrawals import Withdrawal from x10.utils.http import ( WrappedApiResponse, send_get_request, @@ -45,7 +48,7 @@ async def get_balance(self) -> WrappedApiResponse[BalanceModel]: return await send_get_request(await self.get_session(), url, BalanceModel, api_key=self._get_api_key()) async def get_positions( - self, *, market_names: Optional[List[str]] = None, position_side: Optional[PositionSide] = None + self, *, market_names: Optional[List[str]] = None, position_side: Optional[PositionSide] = None ) -> WrappedApiResponse[List[PositionModel]]: """ https://api.docs.extended.exchange/#get-positions @@ -55,11 +58,11 @@ async def get_positions( return await send_get_request(await self.get_session(), url, List[PositionModel], api_key=self._get_api_key()) async def get_positions_history( - self, - market_names: Optional[List[str]] = None, - position_side: Optional[PositionSide] = None, - cursor: Optional[int] = None, - limit: Optional[int] = None, + self, + market_names: Optional[List[str]] = None, + position_side: Optional[PositionSide] = None, + cursor: Optional[int] = None, + limit: Optional[int] = None, ) -> WrappedApiResponse[List[PositionHistoryModel]]: """ https://api.docs.extended.exchange/#get-positions-history @@ -74,10 +77,10 @@ async def get_positions_history( ) async def get_open_orders( - self, - market_names: Optional[List[str]] = None, - order_type: Optional[OrderType] = None, - order_side: Optional[OrderSide] = None, + self, + market_names: Optional[List[str]] = None, + order_type: Optional[OrderType] = None, + order_side: Optional[OrderSide] = None, ) -> WrappedApiResponse[List[OpenOrderModel]]: """ https://api.docs.extended.exchange/#get-open-orders @@ -90,12 +93,12 @@ async def get_open_orders( return await send_get_request(await self.get_session(), url, List[OpenOrderModel], api_key=self._get_api_key()) async def get_orders_history( - self, - market_names: Optional[List[str]] = None, - order_type: Optional[OrderType] = None, - order_side: Optional[OrderSide] = None, - cursor: Optional[int] = None, - limit: Optional[int] = None, + self, + market_names: Optional[List[str]] = None, + order_type: Optional[OrderType] = None, + order_side: Optional[OrderSide] = None, + cursor: Optional[int] = None, + limit: Optional[int] = None, ) -> WrappedApiResponse[List[OpenOrderModel]]: """ https://api.docs.extended.exchange/#get-orders-history @@ -126,12 +129,12 @@ async def get_order_by_external_id(self, external_id: str) -> WrappedApiResponse return await send_get_request(await self.get_session(), url, list[OpenOrderModel], api_key=self._get_api_key()) async def get_trades( - self, - market_names: Optional[List[str]] = None, - trade_side: Optional[OrderSide] = None, - trade_type: Optional[TradeType] = None, - cursor: Optional[int] = None, - limit: Optional[int] = None, + self, + market_names: Optional[List[str]] = None, + trade_side: Optional[OrderSide] = None, + trade_type: Optional[TradeType] = None, + cursor: Optional[int] = None, + limit: Optional[int] = None, ) -> WrappedApiResponse[List[AccountTradeModel]]: """ https://api.docs.extended.exchange/#get-trades @@ -147,7 +150,7 @@ async def get_trades( ) async def get_fees( - self, *, market_names: Optional[List[str]] = None, builder_id: Optional[int] = None + self, *, market_names: Optional[List[str]] = None, builder_id: Optional[int] = None ) -> WrappedApiResponse[List[TradingFeeModel]]: """ https://api.docs.extended.exchange/#get-fees @@ -189,13 +192,15 @@ async def get_bridge_config(self) -> WrappedApiResponse[BridgesConfig]: url = self._get_url("/user/bridge/config") return await send_get_request(await self.get_session(), url, BridgesConfig, api_key=self._get_api_key()) - async def get_bridge_quote(self, chain_in: str, chain_out: str, amount: Decimal) -> WrappedApiResponse[Quote]: + async def get_bridge_quote(self, chain_in: str, chain_out: str, amount: Decimal, recipient: str | None = None) -> \ + WrappedApiResponse[Quote]: url = self._get_url( "/user/bridge/quote", query={ "chainIn": chain_in, "chainOut": chain_out, "amount": amount, + "recipient": recipient }, ) return await send_get_request(await self.get_session(), url, Quote, api_key=self._get_api_key()) @@ -210,11 +215,12 @@ async def commit_bridge_quote(self, id: str): await send_post_request(await self.get_session(), url, EmptyModel, api_key=self._get_api_key()) async def transfer( - self, - to_vault: int, - to_l2_key: int | str, - amount: Decimal, - nonce: int | None = None, + self, + to_vault: int, + to_l2_key: int | str, + amount: Decimal, + nonce: int | None = None, + signing_account: LocalAccount | None = None, ) -> WrappedApiResponse[TransferResponseModel]: from_vault = self._get_stark_account().vault url = self._get_url("/user/transfer/onchain") @@ -232,6 +238,17 @@ async def transfer( nonce=nonce, ) + if signing_account is not None: + message_to_sign = Transfer( + from_vault=from_vault, + to_vault=to_vault, + asset="USD", + amount=amount, + starknet_hash=request_model.transfer_hash, + ).to_signable_message(self._get_endpoint_config().signing_domain) + signature = '0x' + signing_account.sign_message(message_to_sign).signature.hex() + request_model = request_model.model_copy(update={"signature": signature}) + return await send_post_request( await self.get_session(), url, @@ -241,13 +258,18 @@ async def transfer( ) async def withdraw( - self, - amount: Decimal, - chain_id: str = "STRK", - stark_address: str | None = None, - nonce: int | None = None, - quote_id: str | None = None, + self, + amount: Decimal, + chain_id: str = "STRK", + stark_address: str | None = None, + nonce: int | None = None, + quote_id: str | None = None, + target_wallet: str | None = None, + signing_account: LocalAccount | None = None, ) -> WrappedApiResponse[int]: + if target_wallet is not None: + target_wallet = target_wallet.lower() + url = self._get_url("/user/withdrawal") account = (await self.get_account()).data if account is None: @@ -283,7 +305,19 @@ async def withdraw( chain_id=chain_id, quote_id=quote_id, nonce=nonce, + target_wallet=target_wallet ) + if signing_account is not None: + message_to_sign = Withdrawal( + account_id=account.id, + target_wallet=target_wallet, + asset=request_model.asset, + amount=amount, + starknet_hash=request_model.withdrawal_hash, + ).to_signable_message(self._get_endpoint_config().signing_domain) + signature = '0x' + signing_account.sign_message(message_to_sign).signature.hex() + request_model = request_model.model_copy(update={"signature": signature}) + return await send_post_request( await self.get_session(), url, @@ -293,14 +327,14 @@ async def withdraw( ) async def asset_operations( - self, - id: Optional[int] = None, - operations_type: Optional[List[AssetOperationType]] = None, - operations_status: Optional[List[AssetOperationStatus]] = None, - start_time: Optional[int] = None, - end_time: Optional[int] = None, - cursor: Optional[int] = None, - limit: Optional[int] = None, + self, + id: Optional[int] = None, + operations_type: Optional[List[AssetOperationType]] = None, + operations_status: Optional[List[AssetOperationStatus]] = None, + start_time: Optional[int] = None, + end_time: Optional[int] = None, + cursor: Optional[int] = None, + limit: Optional[int] = None, ) -> WrappedApiResponse[List[AssetOperationModel]]: url = self._get_url( "/user/assetOperations", diff --git a/x10/perpetual/transfer_object.py b/x10/perpetual/transfer_object.py index 44c752a..31c13cf 100644 --- a/x10/perpetual/transfer_object.py +++ b/x10/perpetual/transfer_object.py @@ -76,5 +76,6 @@ def create_transfer_object( to_vault=to_vault, amount=amount, settlement=settlement, + transfer_hash= hex(transfer_hash), transferred_asset=config.collateral_asset_id, ) diff --git a/x10/perpetual/transfers.py b/x10/perpetual/transfers.py index 1466feb..9e507b7 100644 --- a/x10/perpetual/transfers.py +++ b/x10/perpetual/transfers.py @@ -1,7 +1,11 @@ +from dataclasses import dataclass from decimal import Decimal from x10.perpetual.orders import SettlementSignatureModel from x10.utils.model import HexValue, X10BaseModel +from datetime import datetime, timezone +from eth_account.messages import SignableMessage, encode_typed_data + class StarkTransferSettlement(X10BaseModel): @@ -30,6 +34,8 @@ class OnChainPerpetualTransferModel(X10BaseModel): amount: Decimal settlement: StarkTransferSettlement transferred_asset: str + transfer_hash: str + signature: str | None = None class TransferResponseModel(X10BaseModel): @@ -37,3 +43,42 @@ class TransferResponseModel(X10BaseModel): id: int | None = None hash_calculated: str | None = None stark_ex_representation: dict | None = None + +@dataclass +class Transfer: + from_vault: int + to_vault: int + asset: str + amount: Decimal + starknet_hash: str + + def to_signable_message(self, signing_domain) -> SignableMessage: + domain = {"name": signing_domain} + + message = { + "fromVault": self.from_vault, + "toVault": self.to_vault, + "asset": self.asset, + "amount": str(self.amount), + "starknetHash": self.starknet_hash, + } + types = { + "EIP712Domain": [ + {"name": "name", "type": "string"} + ], + "Transfer": [ + {"name": "fromVault", "type": "int64"}, + {"name": "toVault", "type": "int64"}, + {"name": "asset", "type": "string"}, + {"name": "amount", "type": "string"}, + {"name": "starknetHash", "type": "string"} + ] + } + primary_type = "Transfer" + structured_data = { + "types": types, + "domain": domain, + "primaryType": primary_type, + "message": message, + } + return encode_typed_data(full_message=structured_data) \ No newline at end of file diff --git a/x10/perpetual/withdrawal_object.py b/x10/perpetual/withdrawal_object.py index 74a06cb..25483b2 100644 --- a/x10/perpetual/withdrawal_object.py +++ b/x10/perpetual/withdrawal_object.py @@ -24,15 +24,17 @@ def calc_expiration_timestamp(): def create_withdrawal_object( - amount: Decimal, - recipient_stark_address: str, - stark_account: StarkPerpetualAccount, - config: EndpointConfig, - account_id: int, - chain_id: str, - description: str | None = None, - nonce: int | None = None, - quote_id: str | None = None, + amount: Decimal, + recipient_stark_address: str, + stark_account: StarkPerpetualAccount, + config: EndpointConfig, + account_id: int, + chain_id: str, + description: str | None = None, + nonce: int | None = None, + quote_id: str | None = None, + target_wallet: str | None = None, + signature: str | None = None, ) -> WithdrawalRequest: expiration_timestamp = calc_expiration_timestamp() scaled_amount = amount.scaleb(config.collateral_decimals) @@ -78,4 +80,7 @@ def create_withdrawal_object( chain_id=chain_id, quote_id=quote_id, asset="USD", + target_wallet=target_wallet, + signature=signature, + withdrawal_hash = hex(withdrawal_hash) ) diff --git a/x10/perpetual/withdrawals.py b/x10/perpetual/withdrawals.py index 896659e..d35c757 100644 --- a/x10/perpetual/withdrawals.py +++ b/x10/perpetual/withdrawals.py @@ -1,3 +1,7 @@ +from dataclasses import dataclass +from datetime import datetime, timezone +from eth_account.messages import SignableMessage, encode_typed_data + from decimal import Decimal from x10.utils.model import HexValue, SettlementSignatureModel, X10BaseModel @@ -25,3 +29,46 @@ class WithdrawalRequest(X10BaseModel): chain_id: str quote_id: str | None = None asset: str + target_wallet: str | None = None + signature: str | None = None + withdrawal_hash: str | None = None + + +@dataclass +class Withdrawal: + account_id: int + target_wallet: str + asset: str + amount: Decimal + starknet_hash: str + + def to_signable_message(self, signing_domain) -> SignableMessage: + domain = {"name": signing_domain} + message = { + "account": self.account_id, + "targetWallet": self.target_wallet, + "asset": self.asset, + "amount": str(self.amount), + "starknetHash": self.starknet_hash, + } + types = { + "EIP712Domain": [ + {"name": "name", "type": "string"} + ], + "Withdrawal": [ + {"name": "account", "type": "int64"}, + {"name": "targetWallet", "type": "string"}, + {"name": "asset", "type": "string"}, + {"name": "amount", "type": "string"}, + {"name": "starknetHash", "type": "string"} + ] + } + primary_type = "Withdrawal" + structured_data = { + "types": types, + "domain": domain, + "primaryType": primary_type, + "message": message, + } + return encode_typed_data(full_message=structured_data) +