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
166 changes: 166 additions & 0 deletions tests/model/test_settlement.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,12 @@

from tplus.model.settlement import (
BatchSettlementRequest,
InnerMakerOrderAttachment,
InnerSettlementRequest,
MakerOrderAttachment,
TxSettlementRequest,
)
from tplus.utils.user import User

CHAIN_ID = "0x000000000000aa36a7"
ASSET_IN = "0x62622E77D1349Face943C6e7D5c01C61465FE1dc"
Expand Down Expand Up @@ -76,6 +79,169 @@ def test_create_signed(self, settlement, user):
assert signed.signature # truthiness


class TestDelegatedSettlement:
@pytest.fixture(scope="class")
def mm_user(self):
return User()

@pytest.fixture(scope="class")
def settler_user(self):
return User()

@pytest.fixture
def maker_order(self, mm_user, settler_user):
return MakerOrderAttachment(
inner=InnerMakerOrderAttachment(
mm_pubkey=mm_user.public_key,
settler=settler_user.public_key,
expires_at=1_700_000_000_000_000_000,
),
signature=[],
)

def test_from_raw_delegated(self, user, mm_user):
request = InnerSettlementRequest.from_raw_delegated(
ASSET_IN,
100,
6,
ASSET_OUT,
100,
18,
user.public_key,
CHAIN_ID,
0,
mm_user.public_key,
1_700_000_000_000_000_000,
)
assert request.settler is None
assert request.mm_pubkey == mm_user.public_key
assert request.expires_at == 1_700_000_000_000_000_000
assert request.amount_in == 100_000_000_000_000

def test_from_raw_with_expires_at(self, user):
request = InnerSettlementRequest.from_raw(
ASSET_IN,
100,
6,
ASSET_OUT,
100,
18,
user.public_key,
CHAIN_ID,
0,
expires_at=1_700_000_000_000_000_000,
)
assert request.expires_at == 1_700_000_000_000_000_000
assert request.mm_pubkey is None
assert request.settler == user.public_key

def test_signing_payload_delegated(self, user, mm_user):
request = InnerSettlementRequest.from_raw_delegated(
ASSET_IN,
100,
6,
ASSET_OUT,
100,
18,
user.public_key,
CHAIN_ID,
0,
mm_user.public_key,
1_700_000_000_000_000_000,
)
payload = request.signing_payload()
# settler must be absent (None), mm_pubkey + expires_at appended at the end
assert '"settler"' not in payload
assert f'"mm_pubkey":"{mm_user.public_key}"' in payload
assert '"expires_at":1700000000000000000' in payload
# Field ordering: chain_id, then expires_at, then mm_pubkey (append order in signing_payload)
assert payload.index('"chain_id"') < payload.index('"expires_at"')
assert payload.index('"expires_at"') < payload.index('"mm_pubkey"')

def test_create_signed_delegated(self, user, mm_user, maker_order):
inner = InnerSettlementRequest.from_raw_delegated(
ASSET_IN,
100,
6,
ASSET_OUT,
100,
18,
user.public_key,
CHAIN_ID,
0,
mm_user.public_key,
1_700_000_000_000_000_000,
)
signed = TxSettlementRequest.create_signed_delegated(inner, user, maker_order)
assert signed.signature
assert signed.maker_order is maker_order
assert signed.inner.mm_pubkey == mm_user.public_key

def test_create_signed_delegated_from_dict(self, user, mm_user, maker_order):
inner_dict = {
"sub_account_index": 0,
"settler": None,
**get_base_settlement_data(),
"chain_id": CHAIN_ID,
"mm_pubkey": mm_user.public_key,
"expires_at": 1_700_000_000_000_000_000,
}
signed = TxSettlementRequest.create_signed_delegated(inner_dict, user, maker_order)
assert signed.inner.tplus_user == user.public_key
assert signed.signature

def test_create_signed_delegated_missing_mm_pubkey(self, user, maker_order):
inner = InnerSettlementRequest.from_raw(
ASSET_IN,
100,
6,
ASSET_OUT,
100,
18,
user.public_key,
CHAIN_ID,
0,
expires_at=1_700_000_000_000_000_000,
)
with pytest.raises(ValueError, match="mm_pubkey to be set"):
TxSettlementRequest.create_signed_delegated(inner, user, maker_order)

def test_create_signed_delegated_missing_expires_at(self, user, mm_user, maker_order):
# Bypass from_raw_delegated's required expires_at to construct the bad state.
inner = InnerSettlementRequest.model_validate(
{
"mode": "margin",
"sub_account_index": 0,
"settler": None,
**get_base_settlement_data(),
"tplus_user": user.public_key,
"chain_id": CHAIN_ID,
"mm_pubkey": mm_user.public_key,
}
)
with pytest.raises(ValueError, match="expires_at to bound the replay window"):
TxSettlementRequest.create_signed_delegated(inner, user, maker_order)

def test_create_signed_delegated_mm_mismatch(self, user, maker_order):
# Different MM than the one in maker_order.
other_mm = User()
inner = InnerSettlementRequest.from_raw_delegated(
ASSET_IN,
100,
6,
ASSET_OUT,
100,
18,
user.public_key,
CHAIN_ID,
0,
other_mm.public_key,
1_700_000_000_000_000_000,
)
with pytest.raises(ValueError, match="MmPubkeyMismatch"):
TxSettlementRequest.create_signed_delegated(inner, user, maker_order)


class TestBundleSettlementRequest:
def test_signing_payload(self, user):
"""
Expand Down
2 changes: 1 addition & 1 deletion tplus/client/clearingengine/settlement.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ async def init_settlement(self, request: dict | TxSettlementRequest):
# Validate.
request = TxSettlementRequest.model_validate(request)

data = request.model_dump(mode="json")
data = request.model_dump(mode="json", exclude_none=True)
await self._post("settlement/init", json_data=data)

async def get_signatures(self, user: str) -> list[dict]:
Expand Down
5 changes: 4 additions & 1 deletion tplus/evm/contracts.py
Original file line number Diff line number Diff line change
Expand Up @@ -538,12 +538,15 @@ def deposit(
def execute_atomic_settlement(
self,
settlement: dict,
settler: HexBytes,
data: HexBytes,
signature: HexBytes,
**tx_kwargs,
) -> "ReceiptAPI":
try:
return self.contract.executeAtomicSettlement(settlement, data, signature, **tx_kwargs)
return self.contract.executeAtomicSettlement(
settlement, settler, data, signature, **tx_kwargs
)
except Exception as err:
err_id = getattr(err, "message", "")
if erc20_err_name := _decode_erc20_error(getattr(err, "message", f"{err}")):
Expand Down
37 changes: 24 additions & 13 deletions tplus/evm/managers/settle.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
from tplus.evm.managers.evm import ChainConnectedManager
from tplus.logger import get_logger
from tplus.model.approval import SettlementApproval
from tplus.model.settlement import SettlementMode, TxSettlementRequest
from tplus.model.settlement import MakerOrderAttachment, SettlementMode, TxSettlementRequest
from tplus.model.types import ChainID, UserPublicKey
from tplus.utils.amount import Amount
from tplus.utils.user.decrypt import decrypt_settlement_approval
Expand Down Expand Up @@ -42,6 +42,7 @@ class SettlementInfo:
amount_out: Amount
nonce: int
chain_id: "ChainID"
settler: "UserPublicKey | None" = None


class SettlementManager(ChainConnectedManager):
Expand Down Expand Up @@ -180,6 +181,8 @@ async def init_settlement(
asset_out: "Address32",
amount_out: Amount,
user: "User | None" = None,
settler: "UserPublicKey | None" = None,
maker_order: MakerOrderAttachment | None = None,
account_index: int | None = None,
mode: SettlementMode = SettlementMode.MARGIN,
then_execute: bool = False,
Expand All @@ -198,6 +201,8 @@ async def init_settlement(
asset_out: The address of the asset leaving the protocol.
amount_out: Both the normalized and atomic amounts for the amount leaving the protocol.
user: Specify the tplus user. Defaults to the default tplus user.
settler: The settler account executing the settlement. Defaults to the user's public key.
maker_order: Optional maker order attachment for delegated settlements.
account_index: Specify the index of the tplus account for this settlement approval. Defaults to the
selected user's account index.
then_execute: Set to ``True`` to wait for the approval and then execute the settlement on-chain.
Expand Down Expand Up @@ -225,18 +230,21 @@ async def init_settlement(
if account_index is None:
account_index = user.sub_account

request = TxSettlementRequest.create_signed(
{
"chain_id": self.chain_id,
"mode": mode,
"asset_in": asset_in,
"amount_in": amount_in_normalized,
"asset_out": asset_out,
"amount_out": amount_out_normalized,
"sub_account_index": account_index,
},
user,
)
request_data = {
"chain_id": self.chain_id,
"mode": mode,
"asset_in": asset_in,
"amount_in": amount_in_normalized,
"asset_out": asset_out,
"amount_out": amount_out_normalized,
"sub_account_index": account_index,
}
if settler is not None:
request_data["settler"] = settler

request = TxSettlementRequest.create_signed(request_data, user)
if maker_order is not None:
request.maker_order = maker_order

settlement_info = SettlementInfo(
asset_in=asset_in,
Expand All @@ -245,6 +253,7 @@ async def init_settlement(
amount_out=amount_out,
nonce=expected_nonce,
chain_id=self.chain_id,
settler=settler or user.public_key,
)

approval_task: asyncio.Task | None = None
Expand Down Expand Up @@ -330,6 +339,7 @@ async def execute_settlement(
nonce = approval.inner.nonce
expiry = approval.expiry
user = user or self.default_user
settler = settlement_info.settler or user.public_key
token_in_address = kwargs.pop("token_in", None)
token_out_address = kwargs.pop("token_out", None)

Expand Down Expand Up @@ -360,6 +370,7 @@ async def execute_settlement(
"nonce": nonce,
"validUntil": expiry,
},
HexBytes(settler),
"",
HexBytes(approval.inner.signature),
**kwargs,
Expand Down
2 changes: 1 addition & 1 deletion tplus/evm/manifests/tplus-contracts.json

Large diffs are not rendered by default.

Loading
Loading