From c21ef8e684d1b8ec20896f10ea3421012305fa89 Mon Sep 17 00:00:00 2001 From: Fuad ALPHATIC Date: Wed, 22 Jul 2026 14:37:27 +0100 Subject: [PATCH 1/2] feat(models): add SwapPayment model and SwapStatus enum (#41) Add src/shade/models/swap.py implementing SwapPayment and SwapStatus for Shade's cross-asset payment routing capability. Changes: - New SwapStatus(str, Enum) with 5 states: pending, swapping, completed, failed, slippage_exceeded - New SwapPayment class with all required fields: id, pay_in_token, settle_out_token, amount_in, amount_out (Optional[Decimal]), routing_path (list[str]), slippage_tolerance (float), status (SwapStatus), stellar_tx_hash (Optional[str]), created_at (Optional[datetime]) - SwapPayment.from_dict() populates all fields including routing_path - slippage_tolerance validated strictly in the open interval (0, 1); raises ValueError otherwise - SwapPayment.to_dict() for serialisation round-trips - New src/shade/models/__init__.py package init - SwapPayment and SwapStatus surfaced in top-level shade namespace - tests/test_swap_payment.py covering all acceptance criteria Closes #41 --- src/shade/__init__.py | 3 + src/shade/models/__init__.py | 10 ++ src/shade/models/swap.py | 188 +++++++++++++++++++++++++++++++ tests/test_swap_payment.py | 209 +++++++++++++++++++++++++++++++++++ 4 files changed, 410 insertions(+) create mode 100644 src/shade/models/__init__.py create mode 100644 src/shade/models/swap.py create mode 100644 tests/test_swap_payment.py diff --git a/src/shade/__init__.py b/src/shade/__init__.py index d8a9ff7..acc3d7e 100644 --- a/src/shade/__init__.py +++ b/src/shade/__init__.py @@ -15,6 +15,7 @@ RateLimitError, ShadeError, ) +from .models import SwapPayment, SwapStatus __version__ = "0.1.0" @@ -34,6 +35,8 @@ "ShadeClient", "ShadeError", "SyncHTTPClient", + "SwapPayment", + "SwapStatus", "config", "api_base", "environment", diff --git a/src/shade/models/__init__.py b/src/shade/models/__init__.py new file mode 100644 index 0000000..76ae7b2 --- /dev/null +++ b/src/shade/models/__init__.py @@ -0,0 +1,10 @@ +""" +Shade data models. +""" + +from .swap import SwapPayment, SwapStatus + +__all__ = [ + "SwapPayment", + "SwapStatus", +] diff --git a/src/shade/models/swap.py b/src/shade/models/swap.py new file mode 100644 index 0000000..8218d4c --- /dev/null +++ b/src/shade/models/swap.py @@ -0,0 +1,188 @@ +""" +SwapPayment model for Shade's cross-asset payment routing. + +Represents a payment where the payer pays in one token and the +merchant settles in a different token, routed through Shade's +liquidity pools on the Stellar network. +""" + +from __future__ import annotations + +from datetime import datetime +from decimal import Decimal +from enum import Enum +from typing import Any, Dict, List, Optional + + +class SwapStatus(str, Enum): + """Lifecycle states of a swap-routed payment.""" + + PENDING = "pending" + SWAPPING = "swapping" + COMPLETED = "completed" + FAILED = "failed" + SLIPPAGE_EXCEEDED = "slippage_exceeded" + + +class SwapPayment: + """Represents a swap-routed payment on the Shade network. + + The payer sends ``pay_in_token`` and the merchant receives + ``settle_out_token``. Shade routes the conversion through one + or more liquidity pools, expressed as ``routing_path``. + + Parameters + ---------- + id : str + Unique identifier for this swap payment. + pay_in_token : str + Asset code (or full Stellar asset string) the payer sends. + settle_out_token : str + Asset code the merchant receives after the swap. + amount_in : Decimal + Amount of ``pay_in_token`` the payer is sending. + amount_out : Decimal or None + Expected / actual amount of ``settle_out_token`` received. + ``None`` until the swap is executed. + routing_path : list[str] + Ordered list of asset codes describing the swap route, + e.g. ``["USDC", "XLM", "BTC"]``. + slippage_tolerance : float + Maximum acceptable price slippage expressed as a fraction in + the open interval (0, 1). For example ``0.005`` means 0.5 %. + status : SwapStatus + Current lifecycle state of the payment. + stellar_tx_hash : str or None + Stellar transaction hash once the swap is submitted on-chain. + created_at : datetime or None + UTC timestamp when the swap payment was created. + + Raises + ------ + ValueError + If ``slippage_tolerance`` is not in the open interval (0, 1). + """ + + def __init__( + self, + id: str, + pay_in_token: str, + settle_out_token: str, + amount_in: Decimal, + routing_path: List[str], + slippage_tolerance: float, + status: SwapStatus, + amount_out: Optional[Decimal] = None, + stellar_tx_hash: Optional[str] = None, + created_at: Optional[datetime] = None, + ) -> None: + if not (0 < slippage_tolerance < 1): + raise ValueError( + f"slippage_tolerance must be in the open interval (0, 1), " + f"got {slippage_tolerance!r}" + ) + + self.id = id + self.pay_in_token = pay_in_token + self.settle_out_token = settle_out_token + self.amount_in = amount_in + self.amount_out = amount_out + self.routing_path: List[str] = list(routing_path) + self.slippage_tolerance = slippage_tolerance + self.status = status + self.stellar_tx_hash = stellar_tx_hash + self.created_at = created_at + + # ------------------------------------------------------------------ + # Construction helpers + # ------------------------------------------------------------------ + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "SwapPayment": + """Construct a :class:`SwapPayment` from a raw dictionary. + + This is the canonical way to deserialise an API response or a + stored record into a ``SwapPayment`` object. + + Parameters + ---------- + data : dict + Must contain at minimum: ``id``, ``pay_in_token``, + ``settle_out_token``, ``amount_in``, ``routing_path``, + ``slippage_tolerance``, and ``status``. + + Returns + ------- + SwapPayment + A fully populated instance. + + Raises + ------ + ValueError + If ``slippage_tolerance`` is outside (0, 1). + KeyError + If any required field is absent from *data*. + """ + raw_created_at = data.get("created_at") + if isinstance(raw_created_at, str): + created_at: Optional[datetime] = datetime.fromisoformat(raw_created_at) + elif isinstance(raw_created_at, datetime): + created_at = raw_created_at + else: + created_at = None + + raw_amount_out = data.get("amount_out") + amount_out: Optional[Decimal] = ( + Decimal(str(raw_amount_out)) if raw_amount_out is not None else None + ) + + return cls( + id=data["id"], + pay_in_token=data["pay_in_token"], + settle_out_token=data["settle_out_token"], + amount_in=Decimal(str(data["amount_in"])), + amount_out=amount_out, + routing_path=list(data["routing_path"]), + slippage_tolerance=float(data["slippage_tolerance"]), + status=SwapStatus(data["status"]), + stellar_tx_hash=data.get("stellar_tx_hash"), + created_at=created_at, + ) + + # ------------------------------------------------------------------ + # Serialisation + # ------------------------------------------------------------------ + + def to_dict(self) -> Dict[str, Any]: + """Serialise this instance to a plain dictionary.""" + return { + "id": self.id, + "pay_in_token": self.pay_in_token, + "settle_out_token": self.settle_out_token, + "amount_in": str(self.amount_in), + "amount_out": str(self.amount_out) if self.amount_out is not None else None, + "routing_path": self.routing_path, + "slippage_tolerance": self.slippage_tolerance, + "status": self.status.value, + "stellar_tx_hash": self.stellar_tx_hash, + "created_at": self.created_at.isoformat() if self.created_at else None, + } + + # ------------------------------------------------------------------ + # Dunder helpers + # ------------------------------------------------------------------ + + def __repr__(self) -> str: # pragma: no cover + return ( + f"SwapPayment(" + f"id={self.id!r}, " + f"pay_in_token={self.pay_in_token!r}, " + f"settle_out_token={self.settle_out_token!r}, " + f"amount_in={self.amount_in}, " + f"status={self.status!r})" + ) + + def __eq__(self, other: object) -> bool: + if not isinstance(other, SwapPayment): + return NotImplemented + return self.id == other.id diff --git a/tests/test_swap_payment.py b/tests/test_swap_payment.py new file mode 100644 index 0000000..ba685a4 --- /dev/null +++ b/tests/test_swap_payment.py @@ -0,0 +1,209 @@ +""" +Tests for SwapPayment model and SwapStatus enum (issue #41). +""" +from __future__ import annotations + +import pytest +from decimal import Decimal +from datetime import datetime, timezone + +from shade.models.swap import SwapPayment, SwapStatus + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +VALID_PAYLOAD: dict = { + "id": "swap_abc123", + "pay_in_token": "USDC", + "settle_out_token": "XLM", + "amount_in": "250.00", + "amount_out": "1500.75", + "routing_path": ["USDC", "XLM"], + "slippage_tolerance": 0.005, + "status": "pending", + "stellar_tx_hash": None, + "created_at": "2024-06-01T12:00:00", +} + + +# --------------------------------------------------------------------------- +# SwapStatus enum +# --------------------------------------------------------------------------- + + +class TestSwapStatus: + def test_all_members_present(self): + members = {s.value for s in SwapStatus} + assert members == { + "pending", + "swapping", + "completed", + "failed", + "slippage_exceeded", + } + + def test_is_str_enum(self): + """SwapStatus members should compare equal to their string values.""" + assert SwapStatus.PENDING == "pending" + assert SwapStatus.SLIPPAGE_EXCEEDED == "slippage_exceeded" + + def test_construction_from_value(self): + assert SwapStatus("completed") is SwapStatus.COMPLETED + + +# --------------------------------------------------------------------------- +# SwapPayment.from_dict +# --------------------------------------------------------------------------- + + +class TestSwapPaymentFromDict: + def test_populates_all_fields(self): + swap = SwapPayment.from_dict(VALID_PAYLOAD) + + assert swap.id == "swap_abc123" + assert swap.pay_in_token == "USDC" + assert swap.settle_out_token == "XLM" + assert swap.amount_in == Decimal("250.00") + assert swap.amount_out == Decimal("1500.75") + assert swap.routing_path == ["USDC", "XLM"] + assert swap.slippage_tolerance == 0.005 + assert swap.status is SwapStatus.PENDING + assert swap.stellar_tx_hash is None + assert swap.created_at == datetime(2024, 6, 1, 12, 0, 0) + + def test_routing_path_is_list(self): + payload = {**VALID_PAYLOAD, "routing_path": ["USDC", "XLM", "BTC"]} + swap = SwapPayment.from_dict(payload) + assert isinstance(swap.routing_path, list) + assert swap.routing_path == ["USDC", "XLM", "BTC"] + + def test_amount_out_none_when_absent(self): + payload = {k: v for k, v in VALID_PAYLOAD.items() if k != "amount_out"} + swap = SwapPayment.from_dict(payload) + assert swap.amount_out is None + + def test_stellar_tx_hash_populated(self): + tx = "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890" + payload = {**VALID_PAYLOAD, "stellar_tx_hash": tx, "status": "completed"} + swap = SwapPayment.from_dict(payload) + assert swap.stellar_tx_hash == tx + + def test_status_is_swap_status_enum(self): + for status_value in ("pending", "swapping", "completed", "failed", "slippage_exceeded"): + payload = {**VALID_PAYLOAD, "status": status_value} + swap = SwapPayment.from_dict(payload) + assert isinstance(swap.status, SwapStatus) + + def test_created_at_parsed_from_iso_string(self): + swap = SwapPayment.from_dict(VALID_PAYLOAD) + assert isinstance(swap.created_at, datetime) + + def test_created_at_none_when_absent(self): + payload = {k: v for k, v in VALID_PAYLOAD.items() if k != "created_at"} + swap = SwapPayment.from_dict(payload) + assert swap.created_at is None + + def test_created_at_accepts_datetime_object(self): + dt = datetime(2024, 1, 15, 8, 30, 0, tzinfo=timezone.utc) + payload = {**VALID_PAYLOAD, "created_at": dt} + swap = SwapPayment.from_dict(payload) + assert swap.created_at is dt + + def test_missing_required_field_raises_key_error(self): + for required in ("id", "pay_in_token", "settle_out_token", "amount_in", "routing_path", "slippage_tolerance", "status"): + payload = {k: v for k, v in VALID_PAYLOAD.items() if k != required} + with pytest.raises(KeyError): + SwapPayment.from_dict(payload) + + +# --------------------------------------------------------------------------- +# slippage_tolerance validation +# --------------------------------------------------------------------------- + + +class TestSlippageValidation: + def _make(self, slippage: float) -> SwapPayment: + return SwapPayment( + id="swap_test", + pay_in_token="USDC", + settle_out_token="XLM", + amount_in=Decimal("100"), + routing_path=["USDC", "XLM"], + slippage_tolerance=slippage, + status=SwapStatus.PENDING, + ) + + @pytest.mark.parametrize("valid", [0.001, 0.005, 0.01, 0.1, 0.5, 0.999]) + def test_valid_slippage_accepted(self, valid: float): + swap = self._make(valid) + assert swap.slippage_tolerance == valid + + @pytest.mark.parametrize("invalid", [0.0, 1.0, -0.1, 1.5, 2.0, -1.0]) + def test_invalid_slippage_raises_value_error(self, invalid: float): + with pytest.raises(ValueError, match="slippage_tolerance"): + self._make(invalid) + + def test_from_dict_invalid_slippage_raises_value_error(self): + payload = {**VALID_PAYLOAD, "slippage_tolerance": 0.0} + with pytest.raises(ValueError): + SwapPayment.from_dict(payload) + + +# --------------------------------------------------------------------------- +# Direct constructor +# --------------------------------------------------------------------------- + + +class TestSwapPaymentConstructor: + def test_routing_path_is_copied(self): + original = ["USDC", "XLM"] + swap = SwapPayment( + id="swap_001", + pay_in_token="USDC", + settle_out_token="XLM", + amount_in=Decimal("50"), + routing_path=original, + slippage_tolerance=0.01, + status=SwapStatus.PENDING, + ) + original.append("BTC") + assert "BTC" not in swap.routing_path # internal list is a copy + + def test_optional_fields_default_to_none(self): + swap = SwapPayment( + id="swap_002", + pay_in_token="BTC", + settle_out_token="USDC", + amount_in=Decimal("0.01"), + routing_path=["BTC", "XLM", "USDC"], + slippage_tolerance=0.02, + status=SwapStatus.SWAPPING, + ) + assert swap.amount_out is None + assert swap.stellar_tx_hash is None + assert swap.created_at is None + + +# --------------------------------------------------------------------------- +# to_dict round-trip +# --------------------------------------------------------------------------- + + +class TestSwapPaymentToDict: + def test_round_trip_via_from_dict(self): + swap = SwapPayment.from_dict(VALID_PAYLOAD) + d = swap.to_dict() + swap2 = SwapPayment.from_dict(d) + + assert swap2.id == swap.id + assert swap2.amount_in == swap.amount_in + assert swap2.routing_path == swap.routing_path + assert swap2.status == swap.status + + def test_status_serialised_as_string(self): + swap = SwapPayment.from_dict(VALID_PAYLOAD) + d = swap.to_dict() + assert isinstance(d["status"], str) + assert d["status"] == "pending" From 631fdf26e37b8eb88e6a6563af0068a71de23cb5 Mon Sep 17 00:00:00 2001 From: Fuad ALPHATIC Date: Wed, 22 Jul 2026 15:10:33 +0100 Subject: [PATCH 2/2] fix(models): address CodeRabbit review on SwapPayment (#41) - Normalize status to SwapStatus enum in __init__ so the invariant holds even when a raw string is passed directly to the constructor; invalid strings raise ValueError via SwapStatus() - Return list(self.routing_path) from to_dict() to prevent callers from mutating the model's internal routing path list - Add tests: raw-string status coercion, to_dict routing_path isolation --- src/shade/models/swap.py | 10 +++++++-- tests/test_swap_payment.py | 42 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/src/shade/models/swap.py b/src/shade/models/swap.py index 8218d4c..762da0e 100644 --- a/src/shade/models/swap.py +++ b/src/shade/models/swap.py @@ -61,6 +61,8 @@ class SwapPayment: ------ ValueError If ``slippage_tolerance`` is not in the open interval (0, 1). + ValueError + If ``status`` is not a valid :class:`SwapStatus` value. """ def __init__( @@ -89,7 +91,10 @@ def __init__( self.amount_out = amount_out self.routing_path: List[str] = list(routing_path) self.slippage_tolerance = slippage_tolerance - self.status = status + # Normalize raw strings to the enum so the invariant always holds. + self.status: SwapStatus = ( + status if isinstance(status, SwapStatus) else SwapStatus(status) + ) self.stellar_tx_hash = stellar_tx_hash self.created_at = created_at @@ -161,7 +166,8 @@ def to_dict(self) -> Dict[str, Any]: "settle_out_token": self.settle_out_token, "amount_in": str(self.amount_in), "amount_out": str(self.amount_out) if self.amount_out is not None else None, - "routing_path": self.routing_path, + # Return a copy so callers cannot mutate the model's internal list. + "routing_path": list(self.routing_path), "slippage_tolerance": self.slippage_tolerance, "status": self.status.value, "stellar_tx_hash": self.stellar_tx_hash, diff --git a/tests/test_swap_payment.py b/tests/test_swap_payment.py index ba685a4..b6cc1e4 100644 --- a/tests/test_swap_payment.py +++ b/tests/test_swap_payment.py @@ -207,3 +207,45 @@ def test_status_serialised_as_string(self): d = swap.to_dict() assert isinstance(d["status"], str) assert d["status"] == "pending" + + def test_routing_path_copy_is_returned(self): + """Mutating the serialised routing_path must not affect the model.""" + swap = SwapPayment.from_dict(VALID_PAYLOAD) + d = swap.to_dict() + d["routing_path"].append("BTC") # mutate the returned copy + assert "BTC" not in swap.routing_path # internal list unchanged + + +# --------------------------------------------------------------------------- +# Constructor raw-string status coercion (CodeRabbit fix) +# --------------------------------------------------------------------------- + + +class TestSwapStatusCoercion: + def _make(self, status_value) -> SwapPayment: + return SwapPayment( + id="swap_coerce", + pay_in_token="USDC", + settle_out_token="XLM", + amount_in=Decimal("100"), + routing_path=["USDC", "XLM"], + slippage_tolerance=0.01, + status=status_value, + ) + + def test_raw_string_is_coerced_to_enum(self): + """Passing a raw status string should still store a SwapStatus enum.""" + swap = self._make("completed") + assert isinstance(swap.status, SwapStatus) + assert swap.status is SwapStatus.COMPLETED + + def test_to_dict_works_after_raw_string_status(self): + """to_dict must not raise even when status was constructed from a string.""" + swap = self._make("swapping") + d = swap.to_dict() + assert d["status"] == "swapping" + + def test_invalid_raw_string_raises_value_error(self): + """An unknown status string should raise ValueError from the enum.""" + with pytest.raises(ValueError): + self._make("unknown_status")