From a03b556c0cde227df4b28d759e972c9eca82fbb3 Mon Sep 17 00:00:00 2001 From: Brooks Student Portal Date: Fri, 26 Jun 2026 06:41:46 -0700 Subject: [PATCH] feat: SAC detection column on transfers and wraith-py Python SDK --- .github/workflows/publish-python-sdk.yml | 42 ++++ clients/python/.gitignore | 8 + clients/python/README.md | 71 ++++++ clients/python/pyproject.toml | 36 +++ clients/python/tests/test_client.py | 172 ++++++++++++++ clients/python/wraith/__init__.py | 36 +++ clients/python/wraith/client.py | 224 ++++++++++++++++++ clients/python/wraith/errors.py | 23 ++ clients/python/wraith/models.py | 161 +++++++++++++ clients/python/wraith/py.typed | 0 .../migration.sql | 4 + prisma/schema.prisma | 7 + src/__tests__/routes/transfers.test.ts | 1 + src/__tests__/sacDetect.test.ts | 129 ++++++++++ src/db.ts | 4 + src/indexer.ts | 6 + src/indexer/sac-detect.ts | 140 +++++++++++ 17 files changed, 1064 insertions(+) create mode 100644 .github/workflows/publish-python-sdk.yml create mode 100644 clients/python/.gitignore create mode 100644 clients/python/README.md create mode 100644 clients/python/pyproject.toml create mode 100644 clients/python/tests/test_client.py create mode 100644 clients/python/wraith/__init__.py create mode 100644 clients/python/wraith/client.py create mode 100644 clients/python/wraith/errors.py create mode 100644 clients/python/wraith/models.py create mode 100644 clients/python/wraith/py.typed create mode 100644 prisma/migrations/20260626120000_add_token_transfer_is_sac/migration.sql create mode 100644 src/__tests__/sacDetect.test.ts create mode 100644 src/indexer/sac-detect.ts diff --git a/.github/workflows/publish-python-sdk.yml b/.github/workflows/publish-python-sdk.yml new file mode 100644 index 00000000..c23b5648 --- /dev/null +++ b/.github/workflows/publish-python-sdk.yml @@ -0,0 +1,42 @@ +name: Publish Python SDK + +on: + push: + tags: ['pyclient-v*'] + workflow_dispatch: + +defaults: + run: + working-directory: clients/python + +jobs: + test: + name: Test wraith-py + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + - run: pip install -e ".[dev]" + - run: pytest + + publish: + name: Build & publish to PyPI + needs: test + runs-on: ubuntu-latest + # Only publish from a tag, never from a manual test-only dispatch. + if: startsWith(github.ref, 'refs/tags/pyclient-v') + environment: pypi + permissions: + id-token: write # Trusted publishing (OIDC) — no API token needed. + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + - run: pip install build + - run: python -m build + - uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: clients/python/dist diff --git a/clients/python/.gitignore b/clients/python/.gitignore new file mode 100644 index 00000000..5c68c335 --- /dev/null +++ b/clients/python/.gitignore @@ -0,0 +1,8 @@ +# Python build & cache artifacts +__pycache__/ +*.py[cod] +*.egg-info/ +.pytest_cache/ +build/ +dist/ +.venv/ diff --git a/clients/python/README.md b/clients/python/README.md new file mode 100644 index 00000000..3fd05ed3 --- /dev/null +++ b/clients/python/README.md @@ -0,0 +1,71 @@ +# wraith-py + +Python client for the [Wraith](https://github.com/Miracle656/wraith) Soroban +token transfer indexer REST API. Wraps the REST endpoints in typed dataclasses +so data analysts get autocompletion and full i128 precision (amounts are kept as +strings). + +## Install + +```bash +pip install wraith-py +``` + +## Usage + +```python +from wraith import WraithClient + +client = WraithClient("https://wraith.example.com") + +# Transfers received by an address +page = client.incoming_transfers("GABC...", limit=100) +for transfer in page.transfers: + # is_sac distinguishes SAC-wrapped classic assets from native Soroban tokens + print(transfer.contract_id, transfer.amount, transfer.is_sac) + +# Paginate with the cursor +if page.next_cursor: + more = client.incoming_transfers("GABC...", cursor=page.next_cursor) + +# Per-asset holdings for an account +summary = client.account_summary("GABC...") +for holding in summary.assets: + print(holding.contract_id, holding.net, holding.tx_count) + +# Most-active assets +popular = client.popular_assets(window="24h", by="volume") +for asset in popular.assets: + print(asset.contract_id, asset.transfer_count, asset.volume) + +client.close() # or use the client as a context manager +``` + +## Coverage + +| Area | Methods | +| --------- | -------------------------------------------------------------------------------------- | +| Transfers | `incoming_transfers`, `outgoing_transfers`, `address_transfers`, `transaction_transfers` | +| Accounts | `account_summary`, `account_transfers` | +| Assets | `popular_assets` | + +All responses are returned as typed dataclasses (`Transfer`, `TransferPage`, +`AccountSummary`, `AssetHolding`, `PopularAssets`, `PopularAsset`). Non-2xx +responses raise `WraithAPIError`. + +## Development + +```bash +pip install -e ".[dev]" +pytest +``` + +## Publishing + +Builds are published to PyPI from CI on tags matching `pyclient-v*` (see +`.github/workflows/publish-python-sdk.yml`). To publish manually: + +```bash +python -m build +twine upload dist/* +``` diff --git a/clients/python/pyproject.toml b/clients/python/pyproject.toml new file mode 100644 index 00000000..2adafb73 --- /dev/null +++ b/clients/python/pyproject.toml @@ -0,0 +1,36 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "wraith-py" +version = "0.1.0" +description = "Python client for the Wraith Soroban token transfer indexer REST API" +readme = "README.md" +requires-python = ">=3.9" +license = { text = "MIT" } +authors = [{ name = "Wraith contributors" }] +keywords = ["stellar", "soroban", "indexer", "wraith", "sep-41"] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Software Development :: Libraries :: Python Modules", +] +dependencies = ["requests>=2.25"] + +[project.optional-dependencies] +dev = ["pytest>=7", "responses>=0.23", "build>=1.0", "twine>=4.0"] + +[project.urls] +Homepage = "https://github.com/Miracle656/wraith" +Repository = "https://github.com/Miracle656/wraith" +Issues = "https://github.com/Miracle656/wraith/issues" + +[tool.hatch.build.targets.wheel] +packages = ["wraith"] diff --git a/clients/python/tests/test_client.py b/clients/python/tests/test_client.py new file mode 100644 index 00000000..75b0ac1b --- /dev/null +++ b/clients/python/tests/test_client.py @@ -0,0 +1,172 @@ +"""Tests for the Wraith Python client. + +HTTP is stubbed with the ``responses`` library so the tests run offline and +assert both request shaping (query params) and response parsing. +""" + +import pytest +import responses + +from wraith import WraithAPIError, WraithClient + +BASE = "https://wraith.test" + + +@pytest.fixture +def client(): + with WraithClient(BASE) as c: + yield c + + +@responses.activate +def test_incoming_transfers_parses_and_tags_sac(client): + responses.add( + responses.GET, + f"{BASE}/transfers/incoming/GABC", + json={ + "total": 1, + "limit": 50, + "offset": 0, + "nextCursor": None, + "transfers": [ + { + "id": 1, + "contractId": "CSAC", + "eventType": "transfer", + "fromAddress": "GFROM", + "toAddress": "GABC", + "amount": "10000000", + "ledger": 100, + "ledgerClosedAt": "2026-01-01T00:00:00Z", + "txHash": "deadbeef", + "eventId": "evt-1", + "isSac": True, + "displayAmount": "1.0000000", + } + ], + }, + status=200, + ) + + page = client.incoming_transfers("GABC", limit=50) + + assert page.total == 1 + assert len(page.transfers) == 1 + transfer = page.transfers[0] + assert transfer.contract_id == "CSAC" + assert transfer.is_sac is True + assert transfer.amount == "10000000" + assert transfer.display_amount == "1.0000000" + + +@responses.activate +def test_query_params_drop_none_and_pass_through(client): + responses.add( + responses.GET, + f"{BASE}/transfers/address/GABC", + json={"transfers": []}, + status=200, + ) + + client.address_transfers("GABC", contract_id="CXYZ", limit=10) + + request = responses.calls[0].request + assert "contractId=CXYZ" in request.url + assert "limit=10" in request.url + # None-valued params (token, cursor, …) must not be serialised. + assert "token=" not in request.url + assert "cursor=" not in request.url + + +@responses.activate +def test_account_summary(client): + responses.add( + responses.GET, + f"{BASE}/accounts/GABC/summary", + json={ + "address": "GABC", + "assets": [ + { + "contractId": "CXLM", + "totalSent": "5", + "totalReceived": "12", + "net": "7", + "txCount": 3, + "lastActivityAt": "2026-01-01T00:00:00Z", + } + ], + }, + status=200, + ) + + summary = client.account_summary("GABC") + + assert summary.address == "GABC" + assert len(summary.assets) == 1 + assert summary.assets[0].contract_id == "CXLM" + assert summary.assets[0].tx_count == 3 + assert summary.assets[0].net == "7" + + +@responses.activate +def test_popular_assets(client): + responses.add( + responses.GET, + f"{BASE}/assets/popular", + json={ + "window": "24h", + "by": "volume", + "total": 1, + "assets": [ + { + "contractId": "CXLM", + "transferCount": 42, + "volume": "1000", + "displayVolume": "0.0001000", + } + ], + }, + status=200, + ) + + result = client.popular_assets(window="24h", by="volume") + + assert result.by == "volume" + assert result.assets[0].transfer_count == 42 + assert result.assets[0].volume == "1000" + + +@responses.activate +def test_transaction_transfers(client): + responses.add( + responses.GET, + f"{BASE}/transfers/tx/abc123", + json={"transfers": [{"contractId": "C1", "eventType": "mint", "amount": "5"}]}, + status=200, + ) + + transfers = client.transaction_transfers("abc123") + + assert len(transfers) == 1 + assert transfers[0].event_type == "mint" + + +@responses.activate +def test_api_error_raises(client): + responses.add( + responses.GET, + f"{BASE}/transfers/incoming/GBAD", + json={"error": "boom"}, + status=500, + ) + + with pytest.raises(WraithAPIError) as exc: + client.incoming_transfers("GBAD") + + assert exc.value.status_code == 500 + assert "boom" in str(exc.value) + + +def test_base_url_required(): + with pytest.raises(ValueError): + WraithClient("") diff --git a/clients/python/wraith/__init__.py b/clients/python/wraith/__init__.py new file mode 100644 index 00000000..add8632b --- /dev/null +++ b/clients/python/wraith/__init__.py @@ -0,0 +1,36 @@ +"""wraith-py — Python client for the Wraith Soroban token transfer indexer. + +Quickstart: + from wraith import WraithClient + + client = WraithClient("https://wraith.example.com") + page = client.incoming_transfers("GABC...") + for transfer in page.transfers: + print(transfer.contract_id, transfer.amount, transfer.is_sac) +""" + +from .client import WraithClient +from .errors import WraithAPIError, WraithError +from .models import ( + AccountSummary, + AssetHolding, + PopularAsset, + PopularAssets, + Transfer, + TransferPage, +) + +__version__ = "0.1.0" + +__all__ = [ + "WraithClient", + "WraithError", + "WraithAPIError", + "Transfer", + "TransferPage", + "AccountSummary", + "AssetHolding", + "PopularAsset", + "PopularAssets", + "__version__", +] diff --git a/clients/python/wraith/client.py b/clients/python/wraith/client.py new file mode 100644 index 00000000..bd57a8e5 --- /dev/null +++ b/clients/python/wraith/client.py @@ -0,0 +1,224 @@ +"""Synchronous client for the Wraith REST API.""" + +from __future__ import annotations + +from typing import Any, Dict, List, Optional + +import requests + +from .errors import WraithAPIError +from .models import AccountSummary, PopularAssets, Transfer, TransferPage + +DEFAULT_TIMEOUT = 30 + + +class WraithClient: + """A thin, typed wrapper over the Wraith REST API. + + Example: + >>> client = WraithClient("https://wraith.example.com") + >>> page = client.incoming_transfers("GABC...") + >>> for transfer in page.transfers: + ... print(transfer.contract_id, transfer.amount, transfer.is_sac) + """ + + def __init__( + self, + base_url: str, + *, + timeout: int = DEFAULT_TIMEOUT, + session: Optional[requests.Session] = None, + ): + if not base_url: + raise ValueError("base_url is required") + self.base_url = base_url.rstrip("/") + self.timeout = timeout + self._session = session or requests.Session() + + # ── Internal helpers ──────────────────────────────────────────────────── + def _get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + clean = {k: v for k, v in (params or {}).items() if v is not None} + resp = self._session.get( + f"{self.base_url}{path}", params=clean, timeout=self.timeout + ) + if not resp.ok: + message = None + try: + message = resp.json().get("error") + except ValueError: + message = resp.text or None + raise WraithAPIError(resp.status_code, message) + return resp.json() + + @staticmethod + def _transfer_params( + contract_id: Optional[str], + token: Optional[str], + event_type: Optional[str], + from_ledger: Optional[int], + to_ledger: Optional[int], + from_date: Optional[str], + to_date: Optional[str], + limit: Optional[int], + offset: Optional[int], + cursor: Optional[str], + ) -> Dict[str, Any]: + return { + "contractId": contract_id, + "token": token, + "eventType": event_type, + "fromLedger": from_ledger, + "toLedger": to_ledger, + "fromDate": from_date, + "toDate": to_date, + "limit": limit, + "offset": offset, + "cursor": cursor, + } + + # ── Transfers ─────────────────────────────────────────────────────────── + def incoming_transfers( + self, + address: str, + *, + contract_id: Optional[str] = None, + token: Optional[str] = None, + event_type: Optional[str] = None, + from_ledger: Optional[int] = None, + to_ledger: Optional[int] = None, + from_date: Optional[str] = None, + to_date: Optional[str] = None, + limit: Optional[int] = None, + offset: Optional[int] = None, + cursor: Optional[str] = None, + ) -> TransferPage: + """Transfers received by ``address`` (``GET /transfers/incoming/:address``).""" + data = self._get( + f"/transfers/incoming/{address}", + self._transfer_params( + contract_id, token, event_type, from_ledger, to_ledger, + from_date, to_date, limit, offset, cursor, + ), + ) + return TransferPage.from_dict(data) + + def outgoing_transfers( + self, + address: str, + *, + contract_id: Optional[str] = None, + token: Optional[str] = None, + event_type: Optional[str] = None, + from_ledger: Optional[int] = None, + to_ledger: Optional[int] = None, + from_date: Optional[str] = None, + to_date: Optional[str] = None, + limit: Optional[int] = None, + offset: Optional[int] = None, + cursor: Optional[str] = None, + ) -> TransferPage: + """Transfers sent by ``address`` (``GET /transfers/outgoing/:address``).""" + data = self._get( + f"/transfers/outgoing/{address}", + self._transfer_params( + contract_id, token, event_type, from_ledger, to_ledger, + from_date, to_date, limit, offset, cursor, + ), + ) + return TransferPage.from_dict(data) + + def address_transfers( + self, + address: str, + *, + contract_id: Optional[str] = None, + token: Optional[str] = None, + event_type: Optional[str] = None, + from_ledger: Optional[int] = None, + to_ledger: Optional[int] = None, + from_date: Optional[str] = None, + to_date: Optional[str] = None, + limit: Optional[int] = None, + offset: Optional[int] = None, + cursor: Optional[str] = None, + ) -> TransferPage: + """Transfers sent or received by ``address``, each tagged with a direction + (``GET /transfers/address/:address``).""" + data = self._get( + f"/transfers/address/{address}", + self._transfer_params( + contract_id, token, event_type, from_ledger, to_ledger, + from_date, to_date, limit, offset, cursor, + ), + ) + return TransferPage.from_dict(data) + + def transaction_transfers(self, tx_hash: str) -> List[Transfer]: + """All token events emitted within a transaction + (``GET /transfers/tx/:txHash``).""" + data = self._get(f"/transfers/tx/{tx_hash}") + return [Transfer.from_dict(t) for t in data.get("transfers", [])] + + # ── Accounts ──────────────────────────────────────────────────────────── + def account_summary( + self, address: str, *, contract_id: Optional[str] = None + ) -> AccountSummary: + """Per-asset holdings for ``address`` + (``GET /accounts/:address/summary``).""" + data = self._get( + f"/accounts/{address}/summary", {"contractId": contract_id} + ) + return AccountSummary.from_dict(data) + + def account_transfers( + self, + address: str, + *, + contract_id: Optional[str] = None, + token: Optional[str] = None, + event_type: Optional[str] = None, + from_ledger: Optional[int] = None, + to_ledger: Optional[int] = None, + from_date: Optional[str] = None, + to_date: Optional[str] = None, + limit: Optional[int] = None, + offset: Optional[int] = None, + cursor: Optional[str] = None, + ) -> TransferPage: + """Transfers for ``address`` via the accounts router + (``GET /accounts/:address/transfers``).""" + data = self._get( + f"/accounts/{address}/transfers", + self._transfer_params( + contract_id, token, event_type, from_ledger, to_ledger, + from_date, to_date, limit, offset, cursor, + ), + ) + return TransferPage.from_dict(data) + + # ── Assets ────────────────────────────────────────────────────────────── + def popular_assets( + self, + *, + window: str = "24h", + by: str = "transfers", + limit: Optional[int] = None, + offset: Optional[int] = None, + ) -> PopularAssets: + """The most-active assets in a time window + (``GET /assets/popular``).""" + data = self._get( + "/assets/popular", + {"window": window, "by": by, "limit": limit, "offset": offset}, + ) + return PopularAssets.from_dict(data) + + # ── Lifecycle ─────────────────────────────────────────────────────────── + def close(self) -> None: + self._session.close() + + def __enter__(self) -> "WraithClient": + return self + + def __exit__(self, *_exc: Any) -> None: + self.close() diff --git a/clients/python/wraith/errors.py b/clients/python/wraith/errors.py new file mode 100644 index 00000000..8beef90d --- /dev/null +++ b/clients/python/wraith/errors.py @@ -0,0 +1,23 @@ +"""Exception types raised by the Wraith client.""" + +from __future__ import annotations + +from typing import Optional + + +class WraithError(Exception): + """Base class for all errors raised by the Wraith client.""" + + +class WraithAPIError(WraithError): + """Raised when the API returns a non-2xx response. + + Attributes: + status_code: The HTTP status code returned by the API. + message: The error message extracted from the response body, if any. + """ + + def __init__(self, status_code: int, message: Optional[str] = None): + self.status_code = status_code + self.message = message or f"HTTP {status_code}" + super().__init__(f"Wraith API error {status_code}: {self.message}") diff --git a/clients/python/wraith/models.py b/clients/python/wraith/models.py new file mode 100644 index 00000000..3d4242f4 --- /dev/null +++ b/clients/python/wraith/models.py @@ -0,0 +1,161 @@ +"""Typed dataclasses mirroring the Wraith REST API response shapes. + +Each ``from_dict`` classmethod is tolerant of missing/extra keys so the models +keep working as the API gains fields. Numeric amounts are kept as strings to +preserve the full i128 precision the API returns (a 64-bit float would lose it). +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional + + +def _opt_int(value: Any) -> Optional[int]: + return int(value) if value is not None else None + + +@dataclass +class Transfer: + """A single SEP-41/CAP-67 token event (transfer, mint, burn, clawback).""" + + id: Optional[int] + contract_id: str + event_type: str + from_address: Optional[str] + to_address: Optional[str] + amount: str + ledger: int + ledger_closed_at: str + tx_hash: str + event_id: str + is_sac: bool = False + display_amount: Optional[str] = None + direction: Optional[str] = None + raw: Dict[str, Any] = field(default_factory=dict, repr=False) + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "Transfer": + return cls( + id=_opt_int(data.get("id")), + contract_id=data.get("contractId", ""), + event_type=data.get("eventType", ""), + from_address=data.get("fromAddress"), + to_address=data.get("toAddress"), + amount=str(data.get("amount", "0")), + ledger=int(data.get("ledger", 0)), + ledger_closed_at=data.get("ledgerClosedAt", ""), + tx_hash=data.get("txHash", ""), + event_id=data.get("eventId", ""), + is_sac=bool(data.get("isSac", False)), + display_amount=data.get("displayAmount"), + direction=data.get("direction"), + raw=data, + ) + + +@dataclass +class TransferPage: + """A page of transfers plus pagination metadata.""" + + transfers: List[Transfer] + total: Optional[int] = None + limit: Optional[int] = None + offset: Optional[int] = None + next_cursor: Optional[str] = None + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "TransferPage": + return cls( + transfers=[Transfer.from_dict(t) for t in data.get("transfers", [])], + total=_opt_int(data.get("total")), + limit=_opt_int(data.get("limit")), + offset=_opt_int(data.get("offset")), + next_cursor=data.get("nextCursor"), + ) + + +@dataclass +class AssetHolding: + """One row of an account's per-asset summary.""" + + contract_id: str + total_sent: str + total_received: str + net: str + tx_count: int + last_activity_at: Optional[str] = None + display_total_sent: Optional[str] = None + display_total_received: Optional[str] = None + display_net: Optional[str] = None + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "AssetHolding": + return cls( + contract_id=data.get("contractId", ""), + total_sent=str(data.get("totalSent", "0")), + total_received=str(data.get("totalReceived", "0")), + net=str(data.get("net", "0")), + tx_count=int(data.get("txCount", 0)), + last_activity_at=data.get("lastActivityAt"), + display_total_sent=data.get("displayTotalSent"), + display_total_received=data.get("displayTotalReceived"), + display_net=data.get("displayNet"), + ) + + +@dataclass +class AccountSummary: + """An account's holdings across every asset it has touched.""" + + address: str + assets: List[AssetHolding] + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "AccountSummary": + return cls( + address=data.get("address", ""), + assets=[AssetHolding.from_dict(a) for a in data.get("assets", [])], + ) + + +@dataclass +class PopularAsset: + """An asset ranked in the /assets/popular leaderboard.""" + + contract_id: str + transfer_count: int + volume: str + display_volume: Optional[str] = None + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "PopularAsset": + return cls( + contract_id=data.get("contractId", ""), + transfer_count=int(data.get("transferCount", 0)), + volume=str(data.get("volume", "0")), + display_volume=data.get("displayVolume"), + ) + + +@dataclass +class PopularAssets: + """The /assets/popular response: a ranked list plus the query window.""" + + window: str + by: str + assets: List[PopularAsset] + total: Optional[int] = None + limit: Optional[int] = None + offset: Optional[int] = None + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "PopularAssets": + return cls( + window=data.get("window", ""), + by=data.get("by", ""), + assets=[PopularAsset.from_dict(a) for a in data.get("assets", [])], + total=_opt_int(data.get("total")), + limit=_opt_int(data.get("limit")), + offset=_opt_int(data.get("offset")), + ) diff --git a/clients/python/wraith/py.typed b/clients/python/wraith/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/prisma/migrations/20260626120000_add_token_transfer_is_sac/migration.sql b/prisma/migrations/20260626120000_add_token_transfer_is_sac/migration.sql new file mode 100644 index 00000000..803c5a1b --- /dev/null +++ b/prisma/migrations/20260626120000_add_token_transfer_is_sac/migration.sql @@ -0,0 +1,4 @@ +-- Tag each transfer with whether the emitting contract is a Stellar Asset +-- Contract (SAC) wrapping a classic asset, vs. a native Soroban token (#136). +ALTER TABLE "wraith"."TokenTransfer" + ADD COLUMN "isSac" BOOLEAN NOT NULL DEFAULT false; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d6eef721..9b50798c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -26,6 +26,9 @@ model TokenTransfer { ledgerClosedAt DateTime txHash String eventId String @unique + // True when the emitting contract is a Stellar Asset Contract (SAC) wrapping a + // classic asset, as opposed to a native Soroban token. Set by sac-detect (#136). + isSac Boolean @default(false) createdAt DateTime @default(now()) @@index([toAddress]) @@ -193,6 +196,10 @@ model IndexerCheckpoint { lastLedger Int processedAt DateTime @default(now()) updatedAt DateTime @updatedAt + + @@schema("wraith") +} + // ─── Partition Retention Runs ─────────────────────────────────────────────── model RetentionJobRun { id Int @id @default(autoincrement()) diff --git a/src/__tests__/routes/transfers.test.ts b/src/__tests__/routes/transfers.test.ts index ad8cd484..e41033cf 100644 --- a/src/__tests__/routes/transfers.test.ts +++ b/src/__tests__/routes/transfers.test.ts @@ -61,6 +61,7 @@ function baseTransfer() { ledgerClosedAt: new Date("2025-01-01T00:00:00Z"), txHash: "aaaa1111", eventId: "evt-001", + isSac: false, createdAt: new Date("2025-01-01T00:00:01Z"), }; } diff --git a/src/__tests__/sacDetect.test.ts b/src/__tests__/sacDetect.test.ts new file mode 100644 index 00000000..8082d6d7 --- /dev/null +++ b/src/__tests__/sacDetect.test.ts @@ -0,0 +1,129 @@ +/** + * Tests for SAC (Stellar Asset Contract) detection (#136). + * + * Covers the pure executable/instance classifiers plus the cached, injectable + * detectSac / detectSacBatch / tagSacTransfers helpers. No network is used — a + * fake instance fetcher is injected throughout. + */ + +import { xdr } from "@stellar/stellar-sdk"; +import { + executableIsSac, + instanceValIsSac, + detectSac, + detectSacBatch, + tagSacTransfers, + _clearSacCache, + type InstanceFetcher, +} from "../indexer/sac-detect"; + +// The native XLM SACs are hard-coded as known SACs in the module. +const TESTNET_XLM_SAC = "CDMLFMKMMD7MWZP3FKUBZPVHTUEDLSX4BYGYKH4GCESXYHS3IHQ4EIG4"; + +function sacInstanceVal(): xdr.ScVal { + return xdr.ScVal.scvContractInstance( + new xdr.ScContractInstance({ + executable: xdr.ContractExecutable.contractExecutableStellarAsset(), + storage: null, + }), + ); +} + +function wasmInstanceVal(): xdr.ScVal { + return xdr.ScVal.scvContractInstance( + new xdr.ScContractInstance({ + executable: xdr.ContractExecutable.contractExecutableWasm(Buffer.alloc(32, 7)), + storage: null, + }), + ); +} + +beforeEach(() => { + _clearSacCache(); +}); + +describe("executableIsSac", () => { + it("is true for the Stellar Asset executable", () => { + expect(executableIsSac(xdr.ContractExecutable.contractExecutableStellarAsset())).toBe(true); + }); + + it("is false for a Wasm executable", () => { + expect(executableIsSac(xdr.ContractExecutable.contractExecutableWasm(Buffer.alloc(32)))).toBe(false); + }); +}); + +describe("instanceValIsSac", () => { + it("is true for a SAC contract instance", () => { + expect(instanceValIsSac(sacInstanceVal())).toBe(true); + }); + + it("is false for a Wasm contract instance", () => { + expect(instanceValIsSac(wasmInstanceVal())).toBe(false); + }); + + it("is false for a non-instance ScVal", () => { + expect(instanceValIsSac(xdr.ScVal.scvU32(1))).toBe(false); + }); +}); + +describe("detectSac", () => { + it("returns true for a known native XLM SAC without fetching", async () => { + const fetcher = jest.fn, [string]>(); + await expect(detectSac(TESTNET_XLM_SAC, fetcher)).resolves.toBe(true); + expect(fetcher).not.toHaveBeenCalled(); + }); + + it("classifies a SAC via the injected fetcher", async () => { + const fetcher: InstanceFetcher = async () => sacInstanceVal(); + await expect(detectSac("CSACTOKEN", fetcher)).resolves.toBe(true); + }); + + it("classifies a Wasm token as not a SAC", async () => { + const fetcher: InstanceFetcher = async () => wasmInstanceVal(); + await expect(detectSac("CWASMTOKEN", fetcher)).resolves.toBe(false); + }); + + it("treats an unresolvable instance as not a SAC", async () => { + const fetcher: InstanceFetcher = async () => null; + await expect(detectSac("CUNKNOWN", fetcher)).resolves.toBe(false); + }); + + it("caches results so each contract is fetched at most once", async () => { + const fetcher = jest.fn(async () => wasmInstanceVal()); + await detectSac("CCACHED", fetcher); + await detectSac("CCACHED", fetcher); + expect(fetcher).toHaveBeenCalledTimes(1); + }); +}); + +describe("detectSacBatch", () => { + it("de-duplicates contract IDs", async () => { + const fetcher = jest.fn(async () => sacInstanceVal()); + const result = await detectSacBatch(["CA", "CA", "CB"], fetcher); + expect(result.get("CA")).toBe(true); + expect(result.get("CB")).toBe(true); + expect(fetcher).toHaveBeenCalledTimes(2); + }); +}); + +describe("tagSacTransfers", () => { + it("sets isSac on every record based on its contract", async () => { + const fetcher: InstanceFetcher = async (id) => + id === "CSAC" ? sacInstanceVal() : wasmInstanceVal(); + + const records = [ + { contractId: "CSAC", isSac: false }, + { contractId: "CWASM", isSac: false }, + { contractId: "CSAC", isSac: false }, + ]; + + await tagSacTransfers(records, fetcher); + + expect(records.map((r) => r.isSac)).toEqual([true, false, true]); + }); + + it("returns the same array reference for empty input", async () => { + const empty: Array<{ contractId: string; isSac?: boolean }> = []; + await expect(tagSacTransfers(empty)).resolves.toBe(empty); + }); +}); diff --git a/src/db.ts b/src/db.ts index 3ef7dc8b..338f4f7f 100644 --- a/src/db.ts +++ b/src/db.ts @@ -48,6 +48,7 @@ export interface TransferRecord { ledgerClosedAt: Date; txHash: string; eventId: string; + isSac?: boolean; // true when the contract is a Stellar Asset Contract (#136) } type ListPage = { @@ -86,6 +87,7 @@ const TRANSFER_SELECTABLE_FIELDS = [ "ledgerClosedAt", "txHash", "eventId", + "isSac", "createdAt", "displayAmount", "direction", @@ -324,6 +326,7 @@ export async function queryTransfers(params: TransferQueryParams) { ledgerClosedAt: requestedSelect.includes("ledgerClosedAt"), txHash: requestedSelect.includes("txHash"), eventId: requestedSelect.includes("eventId"), + isSac: requestedSelect.includes("isSac"), createdAt: requestedSelect.includes("createdAt"), } : undefined; @@ -793,6 +796,7 @@ export async function queryAllTransfers(params: AllTransfersQueryParams) { ledgerClosedAt: requestedSelect.includes("ledgerClosedAt"), txHash: requestedSelect.includes("txHash"), eventId: requestedSelect.includes("eventId"), + isSac: requestedSelect.includes("isSac"), createdAt: requestedSelect.includes("createdAt"), } : undefined; diff --git a/src/indexer.ts b/src/indexer.ts index 142bc2b4..3cdb051c 100644 --- a/src/indexer.ts +++ b/src/indexer.ts @@ -13,6 +13,7 @@ import { } from "./db"; import { emitTransfer } from "./events"; import { parseHostFnEvent, upsertHostFnLogs, type HostFnRecord } from "./indexer/host-fn-log"; +import { tagSacTransfers } from "./indexer/sac-detect"; import { pollParallel } from "./indexer/parallel"; import { isNftTransferEvent, parseNftEvents, fetchNftMetadata } from "./ingester/nft"; import { createSourceSwitcherWithConfig } from "./indexer/sources"; @@ -139,6 +140,11 @@ async function pollOnce( // ── Fungible path ──────────────────────────────────────────────────────────── const records = parseEvents(fungibleEvents); + // Tag each transfer with whether its contract is a SAC (#136). Best-effort: + // a detection failure must never block ingest, so default to false on error. + await tagSacTransfers(records).catch((e) => + console.error("[indexer] SAC detection failed:", e) + ); const inserted = await upsertTransfers(records); totalIndexed += inserted; diff --git a/src/indexer/sac-detect.ts b/src/indexer/sac-detect.ts new file mode 100644 index 00000000..b422ac42 --- /dev/null +++ b/src/indexer/sac-detect.ts @@ -0,0 +1,140 @@ +/** + * Stellar Asset Contract (SAC) detection (#136). + * + * A SAC is the canonical contract that wraps a *classic* Stellar asset (native + * XLM or a `CODE:ISSUER` credit) so it can be used from Soroban. Unlike an + * ordinary Soroban token — whose contract instance points at uploaded Wasm via + * a code hash — a SAC's instance carries the special "Stellar Asset" executable + * built into the host. Detecting this lets consumers tell a wrapped classic + * asset apart from a native Soroban token. + * + * Detection strategy: + * 1. Read the contract's instance ledger entry (the ContractData entry keyed + * by `ScVal::LedgerKeyContractInstance`). + * 2. Inspect its executable: SACs use `ContractExecutableStellarAsset`, Wasm + * tokens use `ContractExecutableWasm(hash)`. + * + * Results are immutable for the lifetime of a contract, so they are cached in + * memory keyed by contract ID. + */ + +import { xdr } from "@stellar/stellar-sdk"; +import { getRpc } from "../rpc"; + +// ─── Known SACs ─────────────────────────────────────────────────────────────── +// The native XLM SAC on mainnet and testnet. These are fixed by the network and +// never change, so we short-circuit detection (and any RPC call) for them. +const KNOWN_SAC_IDS = new Set([ + "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC", // mainnet native XLM + "CDMLFMKMMD7MWZP3FKUBZPVHTUEDLSX4BYGYKH4GCESXYHS3IHQ4EIG4", // testnet native XLM +]); + +// ─── Pure helpers ───────────────────────────────────────────────────────────── + +/** + * True when a contract executable is the built-in Stellar Asset executable + * (i.e. the contract is a SAC) rather than uploaded Wasm. + */ +export function executableIsSac(executable: xdr.ContractExecutable): boolean { + return ( + executable.switch().value === + xdr.ContractExecutableType.contractExecutableStellarAsset().value + ); +} + +/** + * True when a decoded contract-instance ScVal describes a SAC. + * Returns false for any non-instance value. + */ +export function instanceValIsSac(val: xdr.ScVal): boolean { + if (val.switch().value !== xdr.ScValType.scvContractInstance().value) { + return false; + } + return executableIsSac(val.instance().executable()); +} + +// ─── Instance fetch ─────────────────────────────────────────────────────────── + +/** + * Fetches the decoded contract-instance ScVal for a contract, or null if the + * contract has no instance entry / the lookup fails. Injectable for testing. + */ +export type InstanceFetcher = (contractId: string) => Promise; + +async function fetchInstanceVal(contractId: string): Promise { + try { + const entry = await getRpc().getContractData( + contractId, + xdr.ScVal.scvLedgerKeyContractInstance(), + ); + return entry.val.contractData().val(); + } catch { + // Missing entry, archived state, or RPC error — treat as "not determinable". + return null; + } +} + +// ─── Cached detection ───────────────────────────────────────────────────────── + +const cache = new Map(); + +/** + * Detect whether `contractId` is a Stellar Asset Contract. Cached per contract. + * + * @param contractId The C... contract address to classify. + * @param fetchInstance Override the instance fetcher (used in tests). + */ +export async function detectSac( + contractId: string, + fetchInstance: InstanceFetcher = fetchInstanceVal, +): Promise { + if (KNOWN_SAC_IDS.has(contractId)) return true; + + const cached = cache.get(contractId); + if (cached !== undefined) return cached; + + const val = await fetchInstance(contractId); + const isSac = val !== null && instanceValIsSac(val); + cache.set(contractId, isSac); + return isSac; +} + +/** + * Resolve SAC status for many contracts at once, de-duplicating IDs so each + * unique contract is looked up at most once. Returns a Map keyed by contract ID. + */ +export async function detectSacBatch( + contractIds: Iterable, + fetchInstance: InstanceFetcher = fetchInstanceVal, +): Promise> { + const unique = [...new Set(contractIds)]; + const results = await Promise.all( + unique.map(async (id) => [id, await detectSac(id, fetchInstance)] as const), + ); + return new Map(results); +} + +/** + * Tag a batch of transfer-like records (anything carrying a `contractId` and a + * mutable `isSac`) with their SAC status. Records are mutated in place and also + * returned for convenience. One RPC lookup per unique, unknown contract. + */ +export async function tagSacTransfers( + records: T[], + fetchInstance: InstanceFetcher = fetchInstanceVal, +): Promise { + if (records.length === 0) return records; + const byContract = await detectSacBatch( + records.map((r) => r.contractId), + fetchInstance, + ); + for (const record of records) { + record.isSac = byContract.get(record.contractId) ?? false; + } + return records; +} + +/** Clear the in-memory SAC cache. Intended for tests. */ +export function _clearSacCache(): void { + cache.clear(); +}