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
29 changes: 23 additions & 6 deletions paradex_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
read_signers,
)
from paradex_cli.subkeys import (
EVM_SIWE_DEFAULTS,
SUBKEYS_PATH,
build_evm_register_payload,
build_register_payload,
Expand Down Expand Up @@ -1012,19 +1013,35 @@ def _evm_authed_paradex(
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.
) -> Paradex | ParadexEvm:
"""Return a 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).
Standard environments use the SDK's ``ParadexEvm`` (paradex_py >= 0.6.3),
which implements the whole v2 SIWE flow: onboarding-if-needed, auth,
vault-operator targeting, and JWT refresh on expiry.

``--siwe-domain`` / ``--chain-id`` overrides (needed for environments the
SDK has no SIWE defaults for, e.g. ``local``) keep the CLI's own v2 flow,
since ``ParadexEvm`` hardcodes its per-env SIWE domains. The resulting JWT
is injected via ``set_token`` and does not auto-refresh.

``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 and chain_id is None and env in EVM_SIWE_DEFAULTS:
return ParadexEvm(
env=env,
evm_address=eth_address_from_private_key(l1_key),
evm_private_key=l1_key,
ws_enabled=False,
server_derive_address=True,
vault_operator_address=vault_operator_address,
)

if siwe_domain is None or chain_id is None:
# Raises a helpful "pass --siwe-domain and --chain-id" for unknown envs.
d, c = evm_siwe_defaults(env)
siwe_domain = siwe_domain or d
chain_id = chain_id if chain_id is not None else c
Expand Down Expand Up @@ -1206,7 +1223,7 @@ def _subkey_client(
siwe_domain: str | None,
chain_id: int | None,
vault_operator_address: str | None = None,
) -> Paradex:
) -> Paradex | ParadexEvm:
"""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:
Expand Down
5 changes: 4 additions & 1 deletion paradex_cli/subkeys.py
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,10 @@ def evm_onboard_and_auth(
) -> tuple[str, str]:
"""Onboard (idempotently) and authenticate an EVM (EIP-191) account.

Uses the v2 SIWE endpoints (the SDK only speaks the v1 stark flow):
Fallback for custom ``--siwe-domain`` / ``--chain-id`` overrides only —
standard environments use the SDK's ``ParadexEvm`` (paradex_py >= 0.6.3),
which hardcodes per-env SIWE domains and so cannot serve custom ones.
Uses the v2 SIWE endpoints:
GET /onboarding?eth_address=..&account_signer_type=eip191 -> address
POST /v2/onboarding (SIWE "Paradex Onboarding")
POST /v2/auth (SIWE "Paradex Auth", 30s expiry) -> jwt
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ license = {text = "MIT"}
requires-python = ">=3.10,<3.13"
dependencies = [
"typer>=0.15.2",
"paradex_py>=0.6.2",
"paradex_py>=0.6.3",
"rich>=14.0.0",
"python-dotenv>=1.0",
"siwe>=4.4.0",
Expand Down
12 changes: 12 additions & 0 deletions tests/test_add_guardian.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,18 @@ def mock_asyncio_run(coro):
return coro


@pytest.fixture(autouse=True)
def mock_paradex_cls():
"""Keep this suite hermetic: every command path constructs
``Paradex(env=...)``, whose constructor fetches system config from the
LIVE API — with testnet scaled down, the whole file fails on the ELB's
HTML 503. Patch the class for all tests; the few tests that assert on the
constructor re-patch it themselves (their inner patch wins)."""
with patch("paradex_cli.main.Paradex") as cls:
cls.return_value.config = MagicMock()
yield cls


@pytest.fixture(scope="function")
def setup_env_vars():
os.environ["PARADEX_ACCOUNT_ADDRESS"] = "0x123"
Expand Down
56 changes: 56 additions & 0 deletions tests/test_subkeys.py
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,62 @@ def http_post(path, headers, json_body):
assert eth_address_from_private_key(EVM_KEY) in siwe


# --- _evm_authed_paradex routing --------------------------------------------


def test_evm_authed_paradex_uses_sdk_for_standard_envs(setup_env_vars):
"""No SIWE overrides on a standard env -> the SDK's ParadexEvm carries the
whole v2 flow (onboarding-if-needed, auth, operator targeting, refresh)."""
from paradex_cli.main import _evm_authed_paradex

with patch("paradex_cli.main.ParadexEvm") as mock_pevm:
client = _evm_authed_paradex(
"testnet", EVM_KEY, None, None, vault_operator_address="0xoperator"
)

assert client is mock_pevm.return_value
_, kwargs = mock_pevm.call_args
assert kwargs["env"] == "testnet"
assert kwargs["evm_address"] == eth_address_from_private_key(EVM_KEY)
assert kwargs["evm_private_key"] == EVM_KEY
assert kwargs["ws_enabled"] is False
assert kwargs["server_derive_address"] is True
assert kwargs["vault_operator_address"] == "0xoperator"


def test_evm_authed_paradex_overrides_keep_cli_flow(setup_env_vars):
"""A custom --siwe-domain keeps the CLI's own v2 flow (ParadexEvm hardcodes
per-env SIWE domains) and injects the JWT via set_token."""
from paradex_cli.main import _evm_authed_paradex

with (
patch("paradex_cli.main.ParadexEvm") as mock_pevm,
patch("paradex_cli.main.Paradex") as mock_paradex,
patch("paradex_cli.main.evm_onboard_and_auth", return_value=("0xacct", "jwt-1")) as mock_flow,
):
mock_paradex.return_value.api_client.api_url = "https://api.example.dev/v1"
client = _evm_authed_paradex(
"testnet", EVM_KEY, "custom.example.dev", None, vault_operator_address="0xoperator"
)

mock_pevm.assert_not_called()
assert client is mock_paradex.return_value
args, kwargs = mock_flow.call_args
assert args[3] == "custom.example.dev" # siwe_domain
assert args[4] == 11155111 # chain id defaulted per-env
assert kwargs["target_address"] == "0xoperator"
client.api_client.set_token.assert_called_once_with("jwt-1")


def test_evm_authed_paradex_unknown_env_requires_overrides(setup_env_vars):
"""An env without SIWE defaults must ask for --siwe-domain/--chain-id, not
fall into the SDK (whose env validation would give a confusing error)."""
from paradex_cli.main import _evm_authed_paradex

with pytest.raises(ValueError, match="siwe-domain"):
_evm_authed_paradex("local", EVM_KEY, None, None)


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


Expand Down
8 changes: 4 additions & 4 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading