Skip to content

Commit 306cf79

Browse files
authored
Merge pull request #46 from ALIPHATICHYD/feature/41-swap-payment-model
feat(models): add SwapPayment model and SwapStatus enum (#41)
2 parents 264d02d + bb1dc3d commit 306cf79

2 files changed

Lines changed: 445 additions & 0 deletions

File tree

src/shade/models/swap.py

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
"""
2+
SwapPayment model for Shade's cross-asset payment routing.
3+
4+
Represents a payment where the payer pays in one token and the
5+
merchant settles in a different token, routed through Shade's
6+
liquidity pools on the Stellar network.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
from datetime import datetime
12+
from decimal import Decimal
13+
from enum import Enum
14+
from typing import Any, Dict, List, Optional
15+
16+
17+
class SwapStatus(str, Enum):
18+
"""Lifecycle states of a swap-routed payment."""
19+
20+
PENDING = "pending"
21+
SWAPPING = "swapping"
22+
COMPLETED = "completed"
23+
FAILED = "failed"
24+
SLIPPAGE_EXCEEDED = "slippage_exceeded"
25+
26+
27+
class SwapPayment:
28+
"""Represents a swap-routed payment on the Shade network.
29+
30+
The payer sends ``pay_in_token`` and the merchant receives
31+
``settle_out_token``. Shade routes the conversion through one
32+
or more liquidity pools, expressed as ``routing_path``.
33+
34+
Parameters
35+
----------
36+
id : str
37+
Unique identifier for this swap payment.
38+
pay_in_token : str
39+
Asset code (or full Stellar asset string) the payer sends.
40+
settle_out_token : str
41+
Asset code the merchant receives after the swap.
42+
amount_in : Decimal
43+
Amount of ``pay_in_token`` the payer is sending.
44+
amount_out : Decimal or None
45+
Expected / actual amount of ``settle_out_token`` received.
46+
``None`` until the swap is executed.
47+
routing_path : list[str]
48+
Ordered list of asset codes describing the swap route,
49+
e.g. ``["USDC", "XLM", "BTC"]``.
50+
slippage_tolerance : float
51+
Maximum acceptable price slippage expressed as a fraction in
52+
the open interval (0, 1). For example ``0.005`` means 0.5 %.
53+
status : SwapStatus
54+
Current lifecycle state of the payment.
55+
stellar_tx_hash : str or None
56+
Stellar transaction hash once the swap is submitted on-chain.
57+
created_at : datetime or None
58+
UTC timestamp when the swap payment was created.
59+
60+
Raises
61+
------
62+
ValueError
63+
If ``slippage_tolerance`` is not in the open interval (0, 1).
64+
ValueError
65+
If ``status`` is not a valid :class:`SwapStatus` value.
66+
"""
67+
68+
def __init__(
69+
self,
70+
id: str,
71+
pay_in_token: str,
72+
settle_out_token: str,
73+
amount_in: Decimal,
74+
routing_path: List[str],
75+
slippage_tolerance: float,
76+
status: SwapStatus,
77+
amount_out: Optional[Decimal] = None,
78+
stellar_tx_hash: Optional[str] = None,
79+
created_at: Optional[datetime] = None,
80+
) -> None:
81+
if not (0 < slippage_tolerance < 1):
82+
raise ValueError(
83+
f"slippage_tolerance must be in the open interval (0, 1), "
84+
f"got {slippage_tolerance!r}"
85+
)
86+
87+
self.id = id
88+
self.pay_in_token = pay_in_token
89+
self.settle_out_token = settle_out_token
90+
self.amount_in = amount_in
91+
self.amount_out = amount_out
92+
self.routing_path: List[str] = list(routing_path)
93+
self.slippage_tolerance = slippage_tolerance
94+
# Normalize raw strings to the enum so the invariant always holds.
95+
self.status: SwapStatus = (
96+
status if isinstance(status, SwapStatus) else SwapStatus(status)
97+
)
98+
self.stellar_tx_hash = stellar_tx_hash
99+
self.created_at = created_at
100+
101+
# ------------------------------------------------------------------
102+
# Construction helpers
103+
# ------------------------------------------------------------------
104+
105+
@classmethod
106+
def from_dict(cls, data: Dict[str, Any]) -> "SwapPayment":
107+
"""Construct a :class:`SwapPayment` from a raw dictionary.
108+
109+
This is the canonical way to deserialise an API response or a
110+
stored record into a ``SwapPayment`` object.
111+
112+
Parameters
113+
----------
114+
data : dict
115+
Must contain at minimum: ``id``, ``pay_in_token``,
116+
``settle_out_token``, ``amount_in``, ``routing_path``,
117+
``slippage_tolerance``, and ``status``.
118+
119+
Returns
120+
-------
121+
SwapPayment
122+
A fully populated instance.
123+
124+
Raises
125+
------
126+
ValueError
127+
If ``slippage_tolerance`` is outside (0, 1).
128+
KeyError
129+
If any required field is absent from *data*.
130+
"""
131+
raw_created_at = data.get("created_at")
132+
if isinstance(raw_created_at, str):
133+
created_at: Optional[datetime] = datetime.fromisoformat(raw_created_at)
134+
elif isinstance(raw_created_at, datetime):
135+
created_at = raw_created_at
136+
else:
137+
created_at = None
138+
139+
raw_amount_out = data.get("amount_out")
140+
amount_out: Optional[Decimal] = (
141+
Decimal(str(raw_amount_out)) if raw_amount_out is not None else None
142+
)
143+
144+
return cls(
145+
id=data["id"],
146+
pay_in_token=data["pay_in_token"],
147+
settle_out_token=data["settle_out_token"],
148+
amount_in=Decimal(str(data["amount_in"])),
149+
amount_out=amount_out,
150+
routing_path=list(data["routing_path"]),
151+
slippage_tolerance=float(data["slippage_tolerance"]),
152+
status=SwapStatus(data["status"]),
153+
stellar_tx_hash=data.get("stellar_tx_hash"),
154+
created_at=created_at,
155+
)
156+
157+
# ------------------------------------------------------------------
158+
# Serialisation
159+
# ------------------------------------------------------------------
160+
161+
def to_dict(self) -> Dict[str, Any]:
162+
"""Serialise this instance to a plain dictionary."""
163+
return {
164+
"id": self.id,
165+
"pay_in_token": self.pay_in_token,
166+
"settle_out_token": self.settle_out_token,
167+
"amount_in": str(self.amount_in),
168+
"amount_out": str(self.amount_out) if self.amount_out is not None else None,
169+
# Return a copy so callers cannot mutate the model's internal list.
170+
"routing_path": list(self.routing_path),
171+
"slippage_tolerance": self.slippage_tolerance,
172+
"status": self.status.value,
173+
"stellar_tx_hash": self.stellar_tx_hash,
174+
"created_at": self.created_at.isoformat() if self.created_at else None,
175+
}
176+
177+
# ------------------------------------------------------------------
178+
# Dunder helpers
179+
# ------------------------------------------------------------------
180+
181+
def __repr__(self) -> str: # pragma: no cover
182+
return (
183+
f"SwapPayment("
184+
f"id={self.id!r}, "
185+
f"pay_in_token={self.pay_in_token!r}, "
186+
f"settle_out_token={self.settle_out_token!r}, "
187+
f"amount_in={self.amount_in}, "
188+
f"status={self.status!r})"
189+
)
190+
191+
def __eq__(self, other: object) -> bool:
192+
if not isinstance(other, SwapPayment):
193+
return NotImplemented
194+
return self.id == other.id

0 commit comments

Comments
 (0)