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
36 changes: 35 additions & 1 deletion tests/client/test_auth.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import asyncio
import json
import logging
import time
from collections.abc import Callable
Expand All @@ -10,7 +11,7 @@
from tplus.client.base import BaseClient, ClientSettings
from tplus.exceptions import MissingClientUserError
from tplus.model.types import UserPublicKey
from tplus.utils.user import User
from tplus.utils.user import DelegatedUser, User


class FakeAuthBackend:
Expand Down Expand Up @@ -125,6 +126,39 @@ def test_auth_has_lock(self):
auth = Auth()
assert isinstance(auth.lock, asyncio.Lock)

@pytest.mark.anyio
async def test_delegated_user_authenticates_target_with_additional_signer(self):
account = User()
signer = User()
delegated = DelegatedUser(account.public_key, signer)
auth_payloads: list[dict] = []

def handler(request: httpx.Request) -> httpx.Response:
if request.url.path == f"/nonce/{account.public_key}":
return httpx.Response(200, json={"value": "nonce"})
if request.url.path == "/auth":
auth_payloads.append(json.loads(request.content))
return httpx.Response(
200,
json={"token": "tok", "expiry_ns": time.time_ns() + 3_600_000_000_000},
)
return httpx.Response(404)

transport = httpx.MockTransport(handler)
httpx_client = httpx.AsyncClient(base_url="http://test", transport=transport)
client = AuthenticatedClient(
base_url="http://test", default_user=delegated, client=httpx_client
)

await client._authenticate()

payload = auth_payloads[0]
assert payload["user_id"] == account.public_key
assert payload["signature"] == []
assert payload["additional_signers"][0]["signer"] == {"Ed25519": signer.public_key_vec}
signer.vk.verify(bytes(payload["additional_signers"][0]["signature"]), b"nonce")
await client.close()


class TestBaseClient:
def _make_client(self, **kwargs) -> BaseClient:
Expand Down
55 changes: 55 additions & 0 deletions tests/utils/test_delegated_orders.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
from tplus.model.asset_identifier import AssetIdentifier
from tplus.model.limit_order import GTC
from tplus.utils.limit_order import create_limit_order_ob_request_payload
from tplus.utils.replace_order import create_replace_order_ob_request_payload
from tplus.utils.signing import create_cancel_order_ob_request_payload
from tplus.utils.user import DelegatedUser, User


def test_delegated_create_order_uses_additional_signer():
account = User()
signer = User()
delegated = DelegatedUser(account.public_key, signer)

request = create_limit_order_ob_request_payload(
quantity=100,
price=200,
side="Buy",
signer=delegated,
book_quantity_decimals=2,
book_price_decimals=2,
asset_identifier=AssetIdentifier("1"),
order_id="order-1",
time_in_force=GTC(post_only=True),
)

assert request.order.signer == account.public_key
assert request.signature == []
assert request.additional_signers[0].signer.model_dump() == {"Ed25519": signer.public_key_vec}
signer.vk.verify(
bytes(request.additional_signers[0].signature),
request.order.signable_part().encode(),
)


def test_delegated_replace_and_cancel_use_target_account_without_master_signature():
account = User()
delegated = DelegatedUser(account.public_key, User())
asset = AssetIdentifier("1")

replace = create_replace_order_ob_request_payload(
original_order_id="order-1",
asset_identifier=asset,
signer=delegated,
new_price=300,
)
cancel = create_cancel_order_ob_request_payload(
signer=delegated,
asset_identifier=asset,
order_id="order-1",
)

assert replace.user_id == account.public_key
assert replace.signature == []
assert cancel.cancel.signer == account.public_key
assert cancel.signature == []
20 changes: 19 additions & 1 deletion tests/utils/test_user.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey # type: ignore
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat # type: ignore

from tplus.utils.user import LocalUser, User
from tplus.utils.user import DelegatedUser, LocalUser, User


class TestUser:
Expand Down Expand Up @@ -61,3 +61,21 @@ def test_local_user_unlock_pubkey_mismatch_raises(self, private_key_hex):

with pytest.raises(ValueError, match="does not match stored public key"):
user.sign("testmessage")

def test_delegated_user_targets_account_and_emits_additional_signature(self):
account = User()
signer = User()
user = DelegatedUser(account.public_key, signer)

master_signature, additional = user.signing_parts("payload")

assert user.public_key == account.public_key
assert master_signature == []
assert len(additional) == 1
assert additional[0].signer.model_dump() == {"Ed25519": signer.public_key_vec}
signer.vk.verify(bytes(additional[0].signature), b"payload")

@pytest.mark.parametrize("account", ["short", "zz" * 32])
def test_delegated_user_rejects_invalid_account_public_key(self, account):
with pytest.raises(ValueError, match="account_public_key"):
DelegatedUser(account, User())
4 changes: 2 additions & 2 deletions tplus/client/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -219,8 +219,7 @@ async def _authenticate(self, user: "User | None" = None) -> None:
# NOTE: nonce_value **must** be a `str` here.
nonce_value = f"{nonce_data['value']}" if isinstance(nonce_data, dict) else f"{nonce_data}"

signature_bytes = user.sign(nonce_value)
signature_array = list(signature_bytes)
signature_array, additional_signers = user.signing_parts(nonce_value)
nonce_value_len = len(nonce_value)

self.logger.debug(f"AUTH DEBUG: nonce={nonce_value} (len={nonce_value_len})")
Expand All @@ -232,6 +231,7 @@ async def _authenticate(self, user: "User | None" = None) -> None:
"user_id": user.public_key,
"nonce": nonce_value,
"signature": signature_array,
"additional_signers": [signer.model_dump(mode="json") for signer in additional_signers],
}

token_resp = await self._client.post("/auth", json=auth_payload)
Expand Down
58 changes: 54 additions & 4 deletions tplus/client/orderbook.py
Original file line number Diff line number Diff line change
Expand Up @@ -925,6 +925,56 @@ async def get_multisig_config(self, user: UserType | None = None) -> dict[str, A
"thresholds": {"low": 1, "medium": 1, "high": 1},
}

async def add_multisig_signer(
self,
signer: "User",
*,
weight: int = 1,
session_duration_ns: int = (2**64) - 1,
user: "User | None" = None,
) -> dict[str, Any]:
"""Register an Ed25519 additional signer on an account.

The resolved ``user`` authorizes the high-tier config mutation.
``signer`` is the key that will subsequently co-sign requests for that
account.
"""
user = self._resolve_user(user=user)
if weight <= 0:
raise ValueError("weight must be greater than zero")
if not 0 <= session_duration_ns <= (2**64) - 1:
raise ValueError("session_duration_ns must fit in a u64")

nonce_data = await self._request(
"GET",
f"/nonce/{user.public_key}",
requires_auth=False,
user=user,
)
inner = {
"user": user.public_key,
"oms_nonce": str(nonce_data["value"]),
"ce_nonce": int(nonce_data["ce_nonce"]),
"signer": {"Ed25519": signer.public_key_vec},
"weight": weight,
"session_duration_ns": session_duration_ns,
}
signing_payload = json.dumps(inner, separators=(",", ":"))
signature, additional_signers = user.signing_parts(signing_payload)
payload = {
"inner": inner,
"signature": signature,
"additional_signers": [
additional.model_dump(mode="json") for additional in additional_signers
],
}
return await self._request(
"POST",
"/multisig/add-signer",
json_data=payload,
user=user,
)

async def stream_orders(self, user: UserType | None = None) -> AsyncIterator[OrderEvent]:
"""Stream order events for the authenticated user.

Expand Down Expand Up @@ -1211,11 +1261,11 @@ def _build_transfer_to_subaccount(
}
self.logger.debug(f"Transfer request: {inner}")
signing_payload = json.dumps(inner, separators=(",", ":"))
signature = list(user.sign(signing_payload))
signature, additional_signers = user.signing_parts(signing_payload)
payload = {
"inner": inner,
"signature": signature,
"additional_signers": [],
"additional_signers": [signer.model_dump(mode="json") for signer in additional_signers],
}
return payload

Expand Down Expand Up @@ -1252,11 +1302,11 @@ def _build_close_position_request(

self.logger.debug(f"Preparing close position request: {inner}")
signing_payload = json.dumps(inner, separators=(",", ":"))
signature = list(user.sign(signing_payload))
signature, additional_signers = user.signing_parts(signing_payload)
payload = {
"inner": inner,
"signature": signature,
"additional_signers": [],
"additional_signers": [signer.model_dump(mode="json") for signer in additional_signers],
}
return payload

Expand Down
2 changes: 2 additions & 0 deletions tplus/model/multisig.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from __future__ import annotations

from typing import Any

from pydantic import BaseModel, model_serializer, model_validator
Expand Down
4 changes: 3 additions & 1 deletion tplus/model/order.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,12 @@
from enum import Enum
from typing import Any, Literal

from pydantic import BaseModel, ValidationError, field_serializer
from pydantic import BaseModel, Field, ValidationError, field_serializer

from tplus.model.asset_identifier import AssetIdentifier
from tplus.model.limit_order import LimitOrderDetails
from tplus.model.market_order import MarketOrderDetails
from tplus.model.multisig import AdditionalSigner
from tplus.model.order_trigger import OrderTrigger
from tplus.model.types import UserPublicKey

Expand Down Expand Up @@ -85,6 +86,7 @@ class CreateOrderRequest(BaseModel):
order: Order
signature: list[int]
post_sign_timestamp: int
additional_signers: list[AdditionalSigner] = Field(default_factory=list)


class OrderResponse(BaseModel):
Expand Down
7 changes: 5 additions & 2 deletions tplus/utils/limit_order.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,13 @@ def create_limit_order_ob_request_payload(
max_trading_fees_rate=(50000 if max_trading_fees_rate is None else max_trading_fees_rate),
)
sign_payload_json = order.signable_part()
signature_bytes = signer.sign(sign_payload_json)
signature, additional_signers = signer.signing_parts(sign_payload_json)

return CreateOrderRequest(
order=order, signature=list(signature_bytes), post_sign_timestamp=time.time_ns()
order=order,
signature=signature,
post_sign_timestamp=time.time_ns(),
additional_signers=additional_signers,
)


Expand Down
7 changes: 5 additions & 2 deletions tplus/utils/market_order.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,11 @@ def create_market_order_ob_request_payload(
)

sign_payload_json = order.signable_part()
signature_bytes = signer.sign(sign_payload_json)
signature, additional_signers = signer.signing_parts(sign_payload_json)

return CreateOrderRequest(
order=order, signature=list(signature_bytes), post_sign_timestamp=time.time_ns()
order=order,
signature=signature,
post_sign_timestamp=time.time_ns(),
additional_signers=additional_signers,
)
4 changes: 2 additions & 2 deletions tplus/utils/replace_order.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,12 +47,12 @@ def create_replace_order_ob_request_payload(
compact_sign_payload_json = (
sign_payload_json.replace(" ", "").replace("\r", "").replace("\n", "")
)
signature_bytes = signer.sign(compact_sign_payload_json)
signature, _ = signer.signing_parts(compact_sign_payload_json)

return ReplaceOrderRequestPayload(
request=replace_details,
user_id=signer.public_key,
asset_id=asset_identifier,
signature=list(signature_bytes),
signature=signature,
post_sign_timestamp=time.time_ns(),
)
4 changes: 2 additions & 2 deletions tplus/utils/signing.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ def create_cancel_order_ob_request_payload(
compact_sign_payload_json = (
sign_payload_json.replace(" ", "").replace("\r", "").replace("\n", "")
)
signature_bytes = signer.sign(compact_sign_payload_json)
signature, _ = signer.signing_parts(compact_sign_payload_json)
return CancelOrderRequest(
cancel=cancel, signature=list(signature_bytes), post_sign_timestamp=time.time_ns()
cancel=cancel, signature=signature, post_sign_timestamp=time.time_ns()
)
4 changes: 2 additions & 2 deletions tplus/utils/user/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from tplus.utils.user.manager import UserManager
from tplus.utils.user.model import LocalUser, User
from tplus.utils.user.model import DelegatedUser, LocalUser, User


def load_user(name: str | None = None, password: str | None = None) -> User:
Expand Down Expand Up @@ -29,4 +29,4 @@ def load_user(name: str | None = None, password: str | None = None) -> User:
raise ValueError("No default user; please add a user.")


__all__ = ("LocalUser", "User", "UserManager", "load_user")
__all__ = ("DelegatedUser", "LocalUser", "User", "UserManager", "load_user")
Loading
Loading