Skip to content
Merged
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
90 changes: 71 additions & 19 deletions paradex_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -1007,13 +1007,22 @@ def _authed_paradex(env: str) -> Paradex:


def _evm_authed_paradex(
env: str, l1_key: str, siwe_domain: str | None, chain_id: int | None
env: str,
l1_key: str,
siwe_domain: str | None,
chain_id: int | None,
vault_operator_address: str | None = None,
) -> Paradex:
"""Return a Paradex client authenticated as an EVM (EIP-191) account.

The SDK only implements the v1 stark onboarding/auth flow, so we run the
v2 SIWE onboarding+auth ourselves and inject the resulting JWT into the
api_client (set_token sets the Authorization header used by all calls).

``vault_operator_address`` authenticates *as* that EVM vault operator
sub-account (owner key signs, operator is the target): the JWT's subject is
the operator, so subkey operations in this session act on the operator's
subkeys — the way API traders trade an EVM-owned vault.
"""
if siwe_domain is None or chain_id is None:
d, c = evm_siwe_defaults(env)
Expand All @@ -1033,7 +1042,10 @@ def http_post(path, headers, json_body):
base = root_url if path.startswith("v2/") else v1_url
return ac.post(api_url=base, path=path, payload=json_body, headers=headers)

_, jwt = evm_onboard_and_auth(http_post, http_get, l1_key, siwe_domain, chain_id)
_, jwt = evm_onboard_and_auth(
http_post, http_get, l1_key, siwe_domain, chain_id,
target_address=vault_operator_address,
)
ac.set_token(jwt)
return pclient

Expand Down Expand Up @@ -1113,11 +1125,14 @@ def _register_subkey(
l1_key: str | None = None,
siwe_domain: str | None = None,
chain_id: int | None = None,
vault_operator_address: str | None = None,
) -> dict:
# EVM accounts authenticate via the v2 SIWE flow; stark accounts via the
# SDK's v1 flow. The authorization signature in the payload matches.
if vault_operator_address and not l1_key:
raise typer.BadParameter("--vault-operator-address requires --l1-key")
if l1_key:
pclient = _evm_authed_paradex(env, l1_key, siwe_domain, chain_id)
pclient = _evm_authed_paradex(env, l1_key, siwe_domain, chain_id, vault_operator_address)
else:
pclient = _authed_paradex(env)
payload = _register_payload_for(
Expand Down Expand Up @@ -1154,6 +1169,14 @@ def register_subkey(
chain_id: int = typer.Option(
None, "--chain-id", help="Override the EIP-155 chain id (default: per-env)."
),
vault_operator_address: str = typer.Option(
None,
"--vault-operator-address",
help="EVM (EIP-191) accounts: register the subkey under this EVM vault "
"operator sub-account instead of the main account (requires --l1-key; "
"the operator address is the vault's operator_account). Subkeys "
"registered this way trade the vault.",
),
env: str = option_env,
):
"""
Expand All @@ -1165,21 +1188,31 @@ def register_subkey(

For a StarkNet main account, use --sign to attach a stark-curve
authorization. For an EVM (EIP-191) main account, pass --l1-key (or
PARADEX_L1_PRIVATE_KEY) to authorize via a SIWE personal_sign.
PARADEX_L1_PRIVATE_KEY) to authorize via a SIWE personal_sign; add
--vault-operator-address to register the subkey under an EVM vault
operator you own (for trading the vault via API).
"""
res = _register_subkey(public_key, name, env, sign, l1_key, siwe_domain, chain_id)
res = _register_subkey(
public_key, name, env, sign, l1_key, siwe_domain, chain_id, vault_operator_address
)
print("Subkey registered:", public_key)
if res:
print(json.dumps(res, indent=2))


def _subkey_client(
env: str, l1_key: str | None, siwe_domain: str | None, chain_id: int | None
env: str,
l1_key: str | None,
siwe_domain: str | None,
chain_id: int | None,
vault_operator_address: str | None = None,
) -> Paradex:
"""Authenticated client for subkey ops: EVM (v2 SIWE) when an L1 key is
supplied, otherwise the StarkNet (v1) flow."""
if vault_operator_address and not l1_key:
raise typer.BadParameter("--vault-operator-address requires --l1-key")
if l1_key:
return _evm_authed_paradex(env, l1_key, siwe_domain, chain_id)
return _evm_authed_paradex(env, l1_key, siwe_domain, chain_id, vault_operator_address)
return _authed_paradex(env)


Expand All @@ -1190,10 +1223,18 @@ def _subkey_client(
)
_siwe_domain_opt = typer.Option(None, "--siwe-domain", help="Override SIWE domain (default: per-env).")
_chain_id_opt = typer.Option(None, "--chain-id", help="Override EIP-155 chain id (default: per-env).")
_vault_operator_opt = typer.Option(
None,
"--vault-operator-address",
help="EVM (EIP-191) accounts: act on the subkeys of this EVM vault operator "
"sub-account instead of the main account (requires --l1-key).",
)


def _list_subkeys(env, with_revoked, l1_key=None, siwe_domain=None, chain_id=None) -> dict:
pclient = _subkey_client(env, l1_key, siwe_domain, chain_id)
def _list_subkeys(
env, with_revoked, l1_key=None, siwe_domain=None, chain_id=None, vault_operator_address=None
) -> dict:
pclient = _subkey_client(env, l1_key, siwe_domain, chain_id, vault_operator_address)
params = {"with_revoked": "true"} if with_revoked else None
return pclient.api_client.get(
api_url=pclient.api_client.api_url, path=SUBKEYS_PATH, params=params
Expand All @@ -1208,15 +1249,18 @@ def list_subkeys(
l1_key: str = _l1_key_opt,
siwe_domain: str = _siwe_domain_opt,
chain_id: int = _chain_id_opt,
vault_operator_address: str = _vault_operator_opt,
env: str = option_env,
):
"""List all subkeys registered under the main account."""
res = _list_subkeys(env, with_revoked, l1_key, siwe_domain, chain_id)
res = _list_subkeys(env, with_revoked, l1_key, siwe_domain, chain_id, vault_operator_address)
print(json.dumps(res, indent=2))


def _get_subkey(public_key, env, l1_key=None, siwe_domain=None, chain_id=None) -> dict:
pclient = _subkey_client(env, l1_key, siwe_domain, chain_id)
def _get_subkey(
public_key, env, l1_key=None, siwe_domain=None, chain_id=None, vault_operator_address=None
) -> dict:
pclient = _subkey_client(env, l1_key, siwe_domain, chain_id, vault_operator_address)
return pclient.api_client.get(
api_url=pclient.api_client.api_url, path=f"{SUBKEYS_PATH}/{public_key}"
)
Expand All @@ -1228,15 +1272,18 @@ def get_subkey(
l1_key: str = _l1_key_opt,
siwe_domain: str = _siwe_domain_opt,
chain_id: int = _chain_id_opt,
vault_operator_address: str = _vault_operator_opt,
env: str = option_env,
):
"""Fetch a single subkey by public key."""
res = _get_subkey(public_key, env, l1_key, siwe_domain, chain_id)
res = _get_subkey(public_key, env, l1_key, siwe_domain, chain_id, vault_operator_address)
print(json.dumps(res, indent=2))


def _revoke_subkey(public_key, env, l1_key=None, siwe_domain=None, chain_id=None) -> dict:
pclient = _subkey_client(env, l1_key, siwe_domain, chain_id)
def _revoke_subkey(
public_key, env, l1_key=None, siwe_domain=None, chain_id=None, vault_operator_address=None
) -> dict:
pclient = _subkey_client(env, l1_key, siwe_domain, chain_id, vault_operator_address)
return pclient.api_client.delete(
api_url=pclient.api_client.api_url, path=f"{SUBKEYS_PATH}/{public_key}"
)
Expand All @@ -1248,21 +1295,23 @@ def revoke_subkey(
l1_key: str = _l1_key_opt,
siwe_domain: str = _siwe_domain_opt,
chain_id: int = _chain_id_opt,
vault_operator_address: str = _vault_operator_opt,
env: str = option_env,
):
"""
Revoke a subkey. Subkeys can only be revoked using the main account.
"""
res = _revoke_subkey(public_key, env, l1_key, siwe_domain, chain_id)
res = _revoke_subkey(public_key, env, l1_key, siwe_domain, chain_id, vault_operator_address)
print("Subkey revoked:", public_key)
if res:
print(json.dumps(res, indent=2))


def _update_subkey_allowed_cidrs(
public_key, cidrs, env, l1_key=None, siwe_domain=None, chain_id=None
public_key, cidrs, env, l1_key=None, siwe_domain=None, chain_id=None,
vault_operator_address=None,
) -> dict:
pclient = _subkey_client(env, l1_key, siwe_domain, chain_id)
pclient = _subkey_client(env, l1_key, siwe_domain, chain_id, vault_operator_address)
return pclient.api_client.put(
api_url=pclient.api_client.api_url,
path=f"{SUBKEYS_PATH}/{public_key}/allowed-cidrs",
Expand All @@ -1283,13 +1332,16 @@ def update_subkey_allowed_cidrs(
l1_key: str = _l1_key_opt,
siwe_domain: str = _siwe_domain_opt,
chain_id: int = _chain_id_opt,
vault_operator_address: str = _vault_operator_opt,
env: str = option_env,
):
"""
Replace the IP allowlist (CIDRs) for a subkey. The provided list fully
replaces the previous one; an empty list makes the subkey unrestricted.
"""
res = _update_subkey_allowed_cidrs(public_key, cidr, env, l1_key, siwe_domain, chain_id)
res = _update_subkey_allowed_cidrs(
public_key, cidr, env, l1_key, siwe_domain, chain_id, vault_operator_address
)
print("Updated allowed CIDRs for subkey:", public_key)
if res:
print(json.dumps(res, indent=2))
Expand Down
52 changes: 33 additions & 19 deletions paradex_cli/subkeys.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,11 @@
feature flag is on; it is harmless (ignored) when off. See
``verifyRegisterSubkeySignature`` in web-api ``handlers/account_keys.go``.

Only the StarkNet-key (stark-curve) authorization path is implemented here. EVM
(EIP-191/SIWE) accounts authorize subkey registration with an L1 ``personal_sign``
that requires the Ethereum wallet, which is outside this CLI's scope.
EVM (EIP-191) accounts are supported end to end: SIWE onboarding/auth
(``evm_onboard_and_auth`` — optionally targeting an EVM vault operator
sub-account owned by the same key), and SIWE ``personal_sign`` subkey
registration (``build_evm_register_payload``, verified by the backend's
``verifyRegisterSubkeySignatureEvm``).
"""

import time
Expand Down Expand Up @@ -220,6 +222,7 @@ def evm_onboard_and_auth(
eth_private_key: str,
siwe_domain: str,
chain_id: int,
target_address: str | None = None,
) -> tuple[str, str]:
"""Onboard (idempotently) and authenticate an EVM (EIP-191) account.

Expand All @@ -228,30 +231,41 @@ def evm_onboard_and_auth(
POST /v2/onboarding (SIWE "Paradex Onboarding")
POST /v2/auth (SIWE "Paradex Auth", 30s expiry) -> jwt

When ``target_address`` is set, authenticate *as* that account instead of
the key's own main account — the SIWE message is still signed by
``eth_private_key``, but PARADEX-STARKNET-ACCOUNT names the target. This is
how an EVM vault operator sub-account is authenticated (same owner key, no
key of its own). Onboarding is skipped in that case: operators are created
by vault creation (``operator_signer_type=eip191``), never by
POST /v2/onboarding.

``http_get(path, params)`` and ``http_post(path, headers, json)`` are thin
callables the caller supplies (wrapping the SDK http client / httpx) so this
stays transport-agnostic and testable. Returns ``(paradex_address, jwt)``.
"""
import secrets as _secrets

eth_address = eth_address_from_private_key(eth_private_key)
uncompressed_pub = evm_uncompressed_pubkey(eth_private_key)
if target_address is not None:
paradex_address = target_address
else:
eth_address = eth_address_from_private_key(eth_private_key)
uncompressed_pub = evm_uncompressed_pubkey(eth_private_key)

onb = http_get(
"onboarding",
{"eth_address": eth_address, "account_signer_type": "eip191"},
)
paradex_address = onb["address"]

if not onb.get("exists"):
http_post(
"v2/onboarding",
_evm_siwe_headers(
paradex_address, eth_private_key, siwe_domain, chain_id,
"Paradex Onboarding", _secrets.token_hex(16), with_expiry=False,
),
{"public_key": uncompressed_pub},
onb = http_get(
"onboarding",
{"eth_address": eth_address, "account_signer_type": "eip191"},
)
paradex_address = onb["address"]

if not onb.get("exists"):
http_post(
"v2/onboarding",
_evm_siwe_headers(
paradex_address, eth_private_key, siwe_domain, chain_id,
"Paradex Onboarding", _secrets.token_hex(16), with_expiry=False,
),
{"public_key": uncompressed_pub},
)

auth_resp = http_post(
"v2/auth",
Expand Down
88 changes: 88 additions & 0 deletions tests/test_subkeys.py
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,39 @@ def http_post(path, headers, json_body):
assert posts == ["v2/auth"]


def test_evm_onboard_and_auth_targets_vault_operator():
"""target_address authenticates AS the operator: no GET /onboarding, no
onboarding POST (operators are onboarded by vault creation), and the SIWE
auth headers name the operator while the signature stays the owner key's."""
posts = []

def http_get(path, params):
raise AssertionError("GET /onboarding must not be called for an operator target")

def http_post(path, headers, json_body):
posts.append((path, headers))
assert path == "v2/auth", "only auth may be posted for an operator target"
return {"jwt_token": "OPJWT"}

addr, jwt = evm_onboard_and_auth(
http_post,
http_get,
EVM_KEY,
"app.testnet.paradex.trade",
11155111,
target_address="0xoperator",
)
assert addr == "0xoperator"
assert jwt == "OPJWT"
_, headers = posts[0]
assert headers["PARADEX-STARKNET-ACCOUNT"] == "0xoperator"
# SIWE message is still signed by (and names) the owner's eth address.
import base64 as _b64

siwe = _b64.b64decode(headers["PARADEX-SIWE-MESSAGE"]).decode()
assert eth_address_from_private_key(EVM_KEY) in siwe


# --- CLI commands (smoke) --------------------------------------------------


Expand Down Expand Up @@ -313,6 +346,61 @@ def test_register_subkey_command_evm_l1_key(setup_env_vars):
assert "signature" not in payload


def test_register_subkey_command_vault_operator(setup_env_vars):
pclient = _mock_authed_paradex()
with patch("paradex_cli.main._evm_authed_paradex", return_value=pclient) as mock_evm_auth:
result = runner.invoke(
app,
[
"register-subkey",
SUBKEY,
"--name",
"vault-bot",
"--l1-key",
EVM_KEY,
"--vault-operator-address",
"0xoperator",
"--env",
"testnet",
],
)
assert result.exit_code == 0, result.output
# The operator address is threaded through to the SIWE auth as the target.
args, _ = mock_evm_auth.call_args
assert args[-1] == "0xoperator"


def _plain_cli_output(output: str) -> str:
"""Strip ANSI escapes and rich box-drawing, collapse whitespace.

Depending on the installed rich/typer versions, usage errors render either
as plain text or inside an ANSI-colored panel with line wrapping — assert
on normalized text so the test passes under both."""
import re

no_ansi = re.sub(r"\x1b\[[0-9;]*m", "", output)
no_box = re.sub(r"[│╭╮╰╯─]", " ", no_ansi)
return " ".join(no_box.split())


def test_vault_operator_address_requires_l1_key(setup_env_vars):
result = runner.invoke(
app,
[
"register-subkey",
SUBKEY,
"--name",
"vault-bot",
"--vault-operator-address",
"0xoperator",
"--env",
"testnet",
],
)
assert result.exit_code != 0
assert "requires --l1-key" in _plain_cli_output(result.output)


def test_list_subkeys_command(setup_env_vars):
pclient = _mock_authed_paradex()
with patch("paradex_cli.main._authed_paradex", return_value=pclient):
Expand Down
Loading