|
| 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