Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion MIGRATION.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
# Migration Guide

## 1.4.x -> 1.5.0 (REST client)
## 1.4.x -> 1.5.0 (REST/Onboarding clients)

- `x10.perpetual.trading_client.PerpetualTradingClient` has been replaced with
`x10.clients.rest.RestApiClient` (client has the same interface but new name reflects its purpose better).
- Leftover models were migrated to `x10.models.*`.
- Most of the dataclasses are immutable now.
- `markets_info` module has been merged into `info` module.
- `UserClient` replaced by `OnboardingClient`, which accepts an account address and a sign-message callback instead of a raw L1 private key.
- `onboard_subaccount` error handling has changed. Previously, it silently recovered an existing sub-account (HTTP 409) by fetching it from `get_accounts()`. Now it raises `ValidationError` on conflict. Handle duplicates explicitly if you relied on the automatic recovery.
- Fixes https://github.com/x10xchange/python_sdk/issues/99.

---

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -257,7 +257,7 @@ All new accounts should use the `MAINNET_CONFIG` configuration bundle.

## OnBoarding via SDK (Since Version 0.3.0)

To onboard to the Extended Exchange, the `UserClient` defined in [user_client.py](x10/perpetual/user_client/user_client.py) provides a way to use an Ethereum account to onboard onto the Extended Exchange.
To onboard to the Extended Exchange, the `UserClient` defined in [user_client.py](x10/perpetual/user_client/user_client.py) provides a way to use an Ethereum account to onboard onto the Extended Exchange.

### TLDR - Check out: [onboarding_example.py](examples/onboarding_example.py)

Expand Down
22 changes: 17 additions & 5 deletions examples/cases/advanced/onboarding_with_eth_account.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@
from eth_account.signers.local import LocalAccount

from examples.utils import init_env
from x10.clients.onboarding import OnboardingClient
from x10.clients.rest import RestApiClient
from x10.config import TESTNET_CONFIG
from x10.core.stark_account import StarkPerpetualAccount
from x10.perpetual.user_client.user_client import UserClient
from x10.utils.string import is_hex_string

LOGGER = logging.getLogger()
Expand All @@ -23,12 +23,23 @@ async def run_example():
assert is_hex_string(eth_account_private_key), "`eth_account_private_key` must be a hex string"

eth_local_account: LocalAccount = Account.from_key(eth_account_private_key)
user_client = UserClient(config=CONFIG, l1_private_key=eth_local_account.key.hex)

onboarding_client = OnboardingClient(
config=CONFIG,
account_address=eth_local_account.address,
sign_message=lambda msg: eth_local_account.sign_message(msg).signature.hex(),
)

LOGGER.info("Onboarding with ETH account %s...", eth_local_account.address)

main_account = await user_client.onboard()
main_account_api_key = await user_client.create_account_api_key(main_account.account, "Onboarding example API key")
main_account = await onboarding_client.auth.onboard_client()
sub_account = await onboarding_client.auth.onboard_subaccount(
account_index=1, description="Onboarding example subaccount"
)
main_account_api_key = await onboarding_client.account.create_api_key(
account_id=main_account.account.id,
description="Onboarding example API key",
)

starknet_account = StarkPerpetualAccount(
api_key=main_account_api_key,
Expand All @@ -38,7 +49,8 @@ async def run_example():
)
rest_client = RestApiClient(CONFIG, starknet_account)

LOGGER.info("StarkNet public key: %s", starknet_account.public_key)
LOGGER.info("StarkNet public key (main): %s", main_account.l2_key_pair.public_hex)
LOGGER.info("StarkNet public key (sub): %s", sub_account.l2_key_pair.public_hex)

claim = await rest_client.testnet.claim_testing_funds()
claim_id = claim.data.id if claim.data else None
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ build-backend = "poetry.core.masonry.api"

[tool.poetry]
name = "x10-python-trading-starknet"
version = "1.4.1"
version = "1.5.0"
description = "Python client for X10 API"
authors = ["X10 <tech@ex10.org>"]
repository = "https://github.com/x10xchange/python_sdk"
Expand Down
7 changes: 7 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,13 @@ def get_asset_xvs():
return _get_asset_xvs


@pytest.fixture
def get_eth_private_key():
from tests.fixtures.onboarding import get_eth_private_key as _get_eth_private_key

return _get_eth_private_key


@pytest.fixture
def create_asset_operations():
from tests.fixtures.asset import create_asset_operations as _create_asset_operations
Expand Down
3 changes: 3 additions & 0 deletions tests/fixtures/onboarding.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
def get_eth_private_key():
# All known values from authentication service tests are used.
return "50c8e358cc974aaaa6e460641e53f78bdc550fd372984aa78ef8fd27c751e6f4"
15 changes: 0 additions & 15 deletions tests/perpetual/test_l2_key_derivation.py

This file was deleted.

58 changes: 0 additions & 58 deletions tests/perpetual/test_onboarding_payload.py

This file was deleted.

90 changes: 90 additions & 0 deletions tests/signing/test_onboarding.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
from eth_account import Account
from eth_account.messages import SignableMessage
from eth_account.signers.local import LocalAccount
from freezegun import freeze_time
from hamcrest import assert_that, equal_to

from x10.signing.onboarding import (
RequestSignature,
get_l2_keys_from_l1_account,
get_onboarding_payload,
sign_api_request,
)
from x10.utils.date import utc_now

# All known values from authentication service tests are used.
KNOWN_L2_PRIVATE_KEY = "0x7dbb2c8651cc40e1d0d60b45eb52039f317a8aa82798bda52eee272136c0c44"
KNOWN_L2_PUBLIC_KEY = "0x78298687996aff29a0bbcb994e1305db082d084f85ec38bb78c41e6787740ec"


@freeze_time("2024-01-05 01:08:56.860694")
def test_sign_api_request(get_eth_private_key):
local_account: LocalAccount = Account.from_key(get_eth_private_key())
signature = sign_api_request("/action", lambda msg: local_account.sign_message(msg).signature.hex())

assert_that(
signature,
equal_to(
RequestSignature(
"f4e4e9aaf2014a3651dfafec63854e4dfd486dcc10e77f56b330e9942630fde03588e43d6c022f8513c1e4cf211e670c3134d3cfdf1bd61b570d2588bfb9fc921b", # noqa: E501
"2024-01-05T01:08:56Z",
)
),
)


@freeze_time("2024-07-30 16:01:02.000000")
def test_onboarding_object_generation(get_eth_private_key):
l1_account = Account.from_key(get_eth_private_key())

def sign_message(msg: SignableMessage) -> str:
return l1_account.sign_message(msg).signature.hex()

key_pair = get_l2_keys_from_l1_account(
account_index=0, account_address=l1_account.address, signing_domain="x10.exchange", sign_message=sign_message
)

payload = get_onboarding_payload(
account_address=l1_account.address,
time=utc_now(),
host="host",
key_pair=key_pair,
signing_domain="x10.exchange",
sign_message=sign_message,
).to_json()

assert_that(
payload,
equal_to(
{
"l1Signature": "9a59eb699eb58f2ec975455f33dd7205c8a569f7b6d7647c25b71e7ab7eec3d30f2b8c9038f06f077167eb90e0c002602e4ecbab180fad4b2c91d2259883e6571c", # noqa: E501
"l2Key": KNOWN_L2_PUBLIC_KEY,
"l2Signature": {
"r": "0x70881694c59c7212b1a47fbbc07df4d32678f0326f778861ec3a2a5dbc09157",
"s": "0x558805193faa5d780719cba5f699ae1c888eec1fee23da4215fdd94a744d2cb",
},
"accountCreation": {
"accountIndex": 0,
"wallet": "0x2c12f074766f5eF9c5300ca8C85d06fBa605C59f",
"tosAccepted": True,
"time": "2024-07-30T16:01:02Z",
"action": "REGISTER",
"host": "host",
},
"referralCode": None,
}
),
)


def test_known_l2_accounts(get_eth_private_key):
local_account: LocalAccount = Account.from_key(get_eth_private_key())
derived_keys = get_l2_keys_from_l1_account(
account_index=0,
account_address=local_account.address,
signing_domain="x10.exchange",
sign_message=lambda msg: local_account.sign_message(msg).signature.hex(),
)

assert_that(derived_keys.private_hex, equal_to(KNOWN_L2_PRIVATE_KEY))
assert_that(derived_keys.public_hex, equal_to(KNOWN_L2_PUBLIC_KEY))
1 change: 1 addition & 0 deletions x10/clients/onboarding/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from x10.clients.onboarding.onboarding_client import OnboardingClient # noqa: F401
32 changes: 32 additions & 0 deletions x10/clients/onboarding/modules/account_module.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
from x10.clients.onboarding.modules.base_module import BaseModule
from x10.errors import ValidationError
from x10.models.account import ApiKeyRequestModel, ApiKeyResponseModel
from x10.signing.onboarding import sign_api_request
from x10.utils.http import RequestHeader, send_post_request


class AccountModule(BaseModule):
async def create_api_key(self, *, account_id: int, description: str) -> str:
request_path = "/api/v1/user/account/api-key"
signature = sign_api_request(request_path, self._sign_message)
headers: dict[str, str] = {
RequestHeader.AUTH_L1_SIGNATURE: signature.value,
RequestHeader.AUTH_L1_MESSAGE_TIME: signature.time,
RequestHeader.AUTH_ACTIVE_ACCOUNT: str(account_id),
}

payload = ApiKeyRequestModel(description=description)
url = self._get_url(request_path)
response = await send_post_request(
await self._get_session(),
url,
ApiKeyResponseModel,
json=payload.to_api_request_json(),
request_headers=headers,
)
response_data = response.data

if response_data is None:
raise ValidationError("No API key data returned from onboarding")

return response_data.key
89 changes: 89 additions & 0 deletions x10/clients/onboarding/modules/auth_module.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
from aiohttp.web_exceptions import HTTPConflict

from x10.clients.onboarding.modules.base_module import BaseModule
from x10.errors import SdkError, ValidationError
from x10.models.account import AccountModel
from x10.models.client import OnboardedClientModel
from x10.signing.onboarding import (
OnBoardedAccount,
get_l2_keys_from_l1_account,
get_onboarding_payload,
get_sub_account_creation_payload,
sign_api_request,
)
from x10.utils.http import RequestHeader, send_post_request


class SubAccountExists(SdkError):
pass


class AuthModule(BaseModule):
async def onboard_client(self, *, referral_code: str | None = None) -> OnBoardedAccount:
l2_key_pair = get_l2_keys_from_l1_account(
account_index=0,
account_address=self._get_account_address(),
signing_domain=self._get_config().signing.signing_domain,
sign_message=self._sign_message,
)
payload = get_onboarding_payload(
account_address=self._get_account_address(),
signing_domain=self._get_config().signing.signing_domain,
key_pair=l2_key_pair,
referral_code=referral_code,
host=self._get_config().endpoints.onboarding_url,
sign_message=self._sign_message,
)

url = self._get_url("/auth/onboard")
onboarding_response = await send_post_request(
await self._get_session(), url, OnboardedClientModel, json=payload.to_json()
)

onboarded_client = onboarding_response.data

if onboarded_client is None:
raise ValidationError("No account data returned from onboarding")

return OnBoardedAccount(account=onboarded_client.default_account, l2_key_pair=l2_key_pair)

async def onboard_subaccount(self, *, account_index: int, description: str):
request_path = "/auth/onboard/subaccount"
signature = sign_api_request(request_path, self._sign_message)
headers: dict[str, str] = {
RequestHeader.AUTH_L1_SIGNATURE: signature.value,
RequestHeader.AUTH_L1_MESSAGE_TIME: signature.time,
}

key_pair = get_l2_keys_from_l1_account(
account_index=account_index,
account_address=self._get_account_address(),
signing_domain=self._get_config().signing.signing_domain,
sign_message=self._sign_message,
)
payload = get_sub_account_creation_payload(
account_index=account_index,
l1_address=self._get_account_address(),
key_pair=key_pair,
description=description,
host=self._get_config().endpoints.onboarding_url,
)
url = self._get_url(request_path)

try:
onboarding_response = await send_post_request(
await self._get_session(),
url,
AccountModel,
json=payload.to_json(),
request_headers=headers,
response_code_to_exception={HTTPConflict.status_code: SubAccountExists},
)
onboarded_account = onboarding_response.data
except SubAccountExists:
raise ValidationError("Subaccount already exists")

if onboarded_account is None:
raise ValidationError("No account data returned from onboarding")

return OnBoardedAccount(account=onboarded_account, l2_key_pair=key_pair)
Loading
Loading