Skip to content

Commit bb1dc3d

Browse files
authored
Merge branch 'main' into feature/41-swap-payment-model
2 parents 631fdf2 + 264d02d commit bb1dc3d

14 files changed

Lines changed: 915 additions & 137 deletions

poetry.lock

Lines changed: 126 additions & 127 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ packages = [{include = "shade", from = "src"}]
1010
python = "^3.10"
1111
httpx = "^0.28.1"
1212
stellar-sdk = "^13.2.1"
13+
pydantic = "^2.0"
1314

1415
[tool.poetry.group.dev.dependencies]
1516
pytest = "^7.4.0"

src/shade/__init__.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,9 @@
1414
HTTPError,
1515
RateLimitError,
1616
ShadeError,
17+
SignatureVerificationError,
1718
)
18-
from .models import SwapPayment, SwapStatus
19+
from .models import Merchant, ShadeObject, Transfer, TransferStatus
1920

2021
__version__ = "0.1.0"
2122

@@ -29,14 +30,17 @@
2930
"Gateway",
3031
"HTTPError",
3132
"InvalidRequestError",
33+
"Merchant",
3234
"NetworkError",
3335
"NotFoundError",
3436
"RateLimitError",
3537
"ShadeClient",
3638
"ShadeError",
39+
"SignatureVerificationError",
40+
"ShadeObject",
3741
"SyncHTTPClient",
38-
"SwapPayment",
39-
"SwapStatus",
42+
"Transfer",
43+
"TransferStatus",
4044
"config",
4145
"api_base",
4246
"environment",

src/shade/base.py

Whitespace-only changes.

src/shade/errors.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,40 @@ def from_response(
163163
return cls(message, status_code=404, response_body=response_body)
164164

165165

166+
class SignatureVerificationError(ShadeError):
167+
"""
168+
Raised by ``Webhook.construct_event()`` when the HMAC-SHA256 signature in
169+
the ``Shade-Signature`` header does not match the signature computed for
170+
the payload.
171+
172+
Attributes:
173+
header: The raw ``Shade-Signature`` header value as received.
174+
"""
175+
176+
def __init__(
177+
self,
178+
message: str,
179+
header: Optional[str] = None,
180+
) -> None:
181+
super().__init__(message)
182+
self.header = header
183+
184+
@classmethod
185+
def from_mismatch(cls, header: Optional[str] = None) -> "SignatureVerificationError":
186+
"""Construct the error for a computed/received signature mismatch.
187+
188+
The expected signature is deliberately not accepted as an argument
189+
here, so it can never end up in the exception message.
190+
"""
191+
message = (
192+
"Webhook signature verification failed: the received signature "
193+
"does not match the signature computed for this payload. This "
194+
"usually means the webhook secret is incorrect, or the payload "
195+
"was modified in transit."
196+
)
197+
return cls(message, header=header)
198+
199+
166200
class NetworkError(ShadeError):
167201
"""Raised when the SDK cannot complete a network request."""
168202

src/shade/merchant.py

Whitespace-only changes.

src/shade/models/__init__.py

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,8 @@
11
"""
2-
Shade data models.
2+
Shade API response models.
33
"""
4+
from .base import ShadeObject
5+
from .merchant import Merchant
6+
from .transfer import Transfer, TransferStatus
47

5-
from .swap import SwapPayment, SwapStatus
6-
7-
__all__ = [
8-
"SwapPayment",
9-
"SwapStatus",
10-
]
8+
__all__ = ["Merchant", "ShadeObject", "Transfer", "TransferStatus"]

src/shade/models/base.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
"""
2+
Base model shared by every Shade API response object.
3+
"""
4+
from __future__ import annotations
5+
6+
from typing import Any, ClassVar, Optional
7+
8+
from pydantic import BaseModel, ConfigDict, ValidationError
9+
10+
from ..errors import InvalidRequestError
11+
12+
13+
class ShadeObject(BaseModel):
14+
"""Base class for API response models.
15+
16+
Subclasses get dict round-tripping (:meth:`from_dict` / :meth:`to_dict`) and a
17+
readable ``repr``. Unknown fields returned by the API are preserved rather than
18+
rejected, so a server-side addition never breaks an older SDK.
19+
20+
Attributes:
21+
_id_field: Name of the field shown in ``repr``. Subclasses that do not use
22+
a plain ``id`` should override it (e.g. ``_id_field = "invoice_id"``).
23+
"""
24+
25+
model_config = ConfigDict(populate_by_name=True, extra="allow")
26+
27+
_id_field: ClassVar[str] = "id"
28+
29+
def __init__(self, **data: Any) -> None:
30+
try:
31+
super().__init__(**data)
32+
except ValidationError as exc:
33+
raise _as_invalid_request(type(self), exc) from exc
34+
35+
@classmethod
36+
def from_dict(cls, data: dict) -> "ShadeObject":
37+
"""Build a model from a decoded JSON object.
38+
39+
Raises:
40+
InvalidRequestError: If ``data`` is not a dict or fails validation.
41+
"""
42+
if not isinstance(data, dict):
43+
raise InvalidRequestError(
44+
f"{cls.__name__}.from_dict() expects a dict, got "
45+
f"{type(data).__name__}"
46+
)
47+
return cls(**data)
48+
49+
def to_dict(self, **kwargs: Any) -> dict:
50+
"""Return the model as a plain dict, keyed by field alias where one exists.
51+
52+
Any keyword arguments are forwarded to ``model_dump``.
53+
"""
54+
kwargs.setdefault("by_alias", True)
55+
return self.model_dump(**kwargs)
56+
57+
def __repr__(self) -> str:
58+
identifier = self._identifier()
59+
if identifier is None:
60+
return f"<{type(self).__name__}>"
61+
return f"<{type(self).__name__} {self._id_field}={identifier!r}>"
62+
63+
def _identifier(self) -> Optional[Any]:
64+
"""Value of the model's primary ID field, or ``None`` when it has none."""
65+
return getattr(self, self._id_field, None)
66+
67+
68+
def _as_invalid_request(
69+
model: type[BaseModel],
70+
exc: ValidationError,
71+
) -> InvalidRequestError:
72+
"""Translate a pydantic ValidationError into the SDK's InvalidRequestError."""
73+
errors = exc.errors()
74+
field_errors: dict[str, list[str]] = {}
75+
for error in errors:
76+
location = ".".join(str(part) for part in error["loc"]) or "__root__"
77+
field_errors.setdefault(location, []).append(error["msg"])
78+
79+
count = len(errors)
80+
plural = "" if count == 1 else "s"
81+
first = next(iter(field_errors), None)
82+
return InvalidRequestError(
83+
f"{count} validation error{plural} for {model.__name__}",
84+
param=first,
85+
field_errors=field_errors,
86+
)

src/shade/models/merchant.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
"""
2+
Merchant model.
3+
4+
Mirrors the Shade backend's Prisma ``Merchant`` schema, with field names
5+
converted from ``camelCase`` (Prisma/JSON) to ``snake_case`` (Python) via
6+
pydantic field aliases. The :attr:`Merchant.merchant_id` field (from Prisma
7+
``merchantId: Int``) is the numeric identifier the Soroban contract stamps onto
8+
every invoice, making it the bridge between the backend and the on-chain world.
9+
"""
10+
from __future__ import annotations
11+
12+
from typing import Optional
13+
14+
from pydantic import Field, StrictBool, field_validator
15+
from stellar_sdk.strkey import StrKey
16+
17+
from .base import ShadeObject
18+
19+
20+
class Merchant(ShadeObject):
21+
"""A Shade merchant account.
22+
23+
Build one from an API response with :meth:`ShadeObject.from_dict`, which maps
24+
camelCase JSON keys to the snake_case fields below. ``address`` must be a
25+
valid Stellar ed25519 public key and ``active`` / ``verified`` must be real
26+
booleans; anything else raises
27+
:class:`~shade.errors.InvalidRequestError` on construction.
28+
"""
29+
30+
id: str
31+
merchant_id: int = Field(alias="merchantId")
32+
address: str
33+
active: StrictBool
34+
verified: StrictBool
35+
account: Optional[str] = None
36+
email: Optional[str] = None
37+
first_name: Optional[str] = Field(default=None, alias="firstName")
38+
last_name: Optional[str] = Field(default=None, alias="lastName")
39+
business_name: Optional[str] = Field(default=None, alias="businessName")
40+
category: Optional[str] = None
41+
description: Optional[str] = None
42+
logo: Optional[str] = None
43+
webhook: Optional[str] = None
44+
45+
@field_validator("merchant_id", mode="before")
46+
@classmethod
47+
def _reject_bool_merchant_id(cls, value: object) -> object:
48+
# pydantic would otherwise coerce ``True``/``False`` to 1/0; a boolean is
49+
# never a valid merchant id, so reject it rather than silently accept it.
50+
if isinstance(value, bool):
51+
raise ValueError("merchant_id must be an integer, not a boolean")
52+
return value
53+
54+
@field_validator("address")
55+
@classmethod
56+
def _validate_address(cls, value: str) -> str:
57+
if not StrKey.is_valid_ed25519_public_key(value):
58+
raise ValueError(
59+
"address must be a valid Stellar public key "
60+
"(starts with 'G', 56 characters)"
61+
)
62+
return value
63+
64+
@property
65+
def display_name(self) -> Optional[str]:
66+
"""The most informative human-readable name available.
67+
68+
Prefers ``business_name``; falls back to the person's full name
69+
(``"{first_name} {last_name}"``); finally ``email``. Each candidate is
70+
trimmed, so a blank or whitespace-only value falls through to the next
71+
one rather than being returned. ``None`` when nothing is available.
72+
"""
73+
business_name = (self.business_name or "").strip()
74+
if business_name:
75+
return business_name
76+
first_name = (self.first_name or "").strip()
77+
last_name = (self.last_name or "").strip()
78+
full_name = " ".join(part for part in (first_name, last_name) if part)
79+
if full_name:
80+
return full_name
81+
return (self.email or "").strip() or None

src/shade/models/transfer.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
"""
2+
Transfer model.
3+
4+
Represents a payout of funds from the merchant wallet to a destination
5+
address on the Stellar network. Field names are converted from ``camelCase``
6+
(backend/JSON) to ``snake_case`` (Python) via pydantic field aliases, matching
7+
the convention established by :class:`~shade.models.merchant.Merchant`.
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 Optional
15+
16+
from pydantic import Field, field_validator
17+
from stellar_sdk.strkey import StrKey
18+
19+
from .base import ShadeObject
20+
21+
22+
class TransferStatus(str, Enum):
23+
"""Lifecycle status of a transfer."""
24+
25+
PENDING = "pending"
26+
PROCESSING = "processing"
27+
COMPLETED = "completed"
28+
FAILED = "failed"
29+
30+
31+
class Transfer(ShadeObject):
32+
"""A payout of funds from the merchant wallet to a destination address.
33+
34+
Build one from an API response with :meth:`ShadeObject.from_dict`, which
35+
maps camelCase JSON keys to the snake_case fields below. ``asset`` falls
36+
back to ``"XLM"`` when the API omits it (or sends it as ``null``), and
37+
``status`` is always coerced to a :class:`TransferStatus` member.
38+
"""
39+
40+
id: str
41+
source_address: str = Field(alias="sourceAddress")
42+
destination_address: str = Field(alias="destinationAddress")
43+
amount: Decimal
44+
asset: str = "XLM"
45+
status: TransferStatus
46+
stellar_tx_hash: Optional[str] = Field(default=None, alias="stellarTxHash")
47+
fee: Optional[Decimal] = None
48+
created_at: datetime = Field(alias="createdAt")
49+
50+
@field_validator("asset", mode="before")
51+
@classmethod
52+
def _default_asset(cls, value: object) -> object:
53+
# Covers both a missing key (pydantic would already default it) and an
54+
# API response that sends the key as an explicit null/empty string.
55+
# Other falsy-but-wrong types (e.g. False, 0) are left alone so
56+
# pydantic's normal type validation rejects them.
57+
if value is None or value == "":
58+
return "XLM"
59+
return value
60+
61+
@field_validator("source_address", "destination_address")
62+
@classmethod
63+
def _validate_stellar_address(cls, value: str) -> str:
64+
if not StrKey.is_valid_ed25519_public_key(value):
65+
raise ValueError(
66+
"must be a valid Stellar public key (starts with 'G', 56 characters)"
67+
)
68+
return value
69+
70+
@field_validator("amount")
71+
@classmethod
72+
def _amount_must_be_positive(cls, value: Decimal) -> Decimal:
73+
if value <= 0:
74+
raise ValueError("amount must be greater than 0")
75+
return value
76+
77+
@field_validator("fee")
78+
@classmethod
79+
def _fee_must_not_be_negative(cls, value: Optional[Decimal]) -> Optional[Decimal]:
80+
if value is not None and value < 0:
81+
raise ValueError("fee must not be negative")
82+
return value

0 commit comments

Comments
 (0)