Sulkta Coop's Python SDK for merchant-side Cardano payments + NFT certificate-of-authenticity minting. Zero-custody by design. Extracted from TradeCraft's services/cardano_*.py (2,400+ lines of production Cardano-mainnet code) and restructured as an installable Python package. Package layout (cardano_checkout/): - addresses.py — lifted verbatim: CIP-1852 HD derivation, pure pycardano - oracles.py — lifted from cardano_price.py: Koios ADA/USD feed w/ 5m cache - monitor.py — lifted verbatim (SQLAlchemy-coupled; v0.2 refactors to Store) - scheduler.py — lifted verbatim (same refactor note) - invoice.py — NEW: framework-agnostic Invoice dataclass + lifecycle enum - store.py — NEW: InvoiceStore Protocol for pluggable persistence - mint.py — NEW: CIP-25 v2 metadata builder (works); tx submission stub for v0.2 - ipfs.py — NEW: kubo HTTP client with primary-pin + mirror-pin pattern - txbuild.py — NEW: v0.2 stub for PyCardano / Ogmios tx construction Design: - Consumers provide xpub + InvoiceStore impl. SDK provides everything else. - IPFS: local kubo for upload + serve, optional mirror pins for archival. Chromaticcraft pattern: Rackham kubo primary, Lucy kubo mirror. - NFT: single native-script policy per merchant studio (CIP-25 v2, not CIP-68 — full wallet coverage, no mutability needed for static certs). Policy skey stays under Sulkta cold-custody (Lucy pattern); signing is an external hand-off like ADAMaps payouts. Tests: pure-module smoke tests pass for invoice, store-protocol, CIP-25 metadata envelope, IPFS client import, txbuild stub module. Address derivation tests ship but require pycardano + will exercise in CI. LICENSE: Apache-2.0 (matches upstream Cardano tooling). Next (v0.2 scope): - Refactor monitor + scheduler around InvoiceStore (drop SQLAlchemy coupling) - Wire mint.mint_nft_cert to PyCardano + local Ogmios on Rackham - txbuild: Ogmios chain-context + cold-signer hand-off shape - chromaticcraft Phase 2 imports the SDK as its first external consumer
346 lines
11 KiB
Python
346 lines
11 KiB
Python
"""
|
||
Cardano Token Price Service — Phase 2 of the Cardano payments system.
|
||
|
||
Provides cached ADA/USD and token/ADA price lookups used to convert
|
||
invoice amounts into lovelace (ADA's base unit) for payment requests.
|
||
|
||
Data sources:
|
||
- ADA/USD: CoinGecko free API (no key required, rate-limited)
|
||
- Token/ADA: DexHunter v2 API (DEX aggregator on Cardano)
|
||
|
||
Cache strategy: module-level dict with timestamps. TTL = 5 minutes.
|
||
All functions are async, never raise — return None/0 on failure.
|
||
"""
|
||
|
||
import logging
|
||
import time
|
||
from typing import Optional
|
||
|
||
import httpx
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Token registry
|
||
# ---------------------------------------------------------------------------
|
||
|
||
KNOWN_TOKENS: dict[str, dict] = {
|
||
"ada": {
|
||
"policy_id": "",
|
||
"asset_name": "",
|
||
"ticker": "ADA",
|
||
"decimals": 6,
|
||
"type": "native",
|
||
},
|
||
"djed": {
|
||
"policy_id": "8db269c3ec630e06ae29f74bc39edd1f87c819f1056206e879a1cd61",
|
||
"asset_name": "444a4544", # "DJED".encode().hex()
|
||
"ticker": "DJED",
|
||
"decimals": 6,
|
||
"type": "stablecoin",
|
||
},
|
||
"iusd": {
|
||
"policy_id": "f66d78b4a3cb3d37afa0ec36461e51ecbde00f26c8f0a68f94b69880",
|
||
"asset_name": "69555344", # "iUSD".encode().hex()
|
||
"ticker": "iUSD",
|
||
"decimals": 6,
|
||
"type": "stablecoin",
|
||
},
|
||
"night": {
|
||
"policy_id": "0691b2fecca1ac4f53cb6dfb00b7013e561d1f34403b957cbb5af1fa",
|
||
"asset_name": "4e49474854", # "NIGHT".encode().hex()
|
||
"ticker": "NIGHT",
|
||
"decimals": 6,
|
||
"type": "utility",
|
||
},
|
||
"snek": {
|
||
"policy_id": "279c909f348e533da5808898f87f9a14bb2c3dfbbacccd631d927a3f",
|
||
"asset_name": "534e454b", # "SNEK".encode().hex()
|
||
"ticker": "SNEK",
|
||
"decimals": 0,
|
||
"type": "meme",
|
||
},
|
||
"iag": {
|
||
"policy_id": "5d16944c1e00a5fa1d14ba2460709bc2e41a18e8e1b86a1e7a09da09",
|
||
"asset_name": "494147", # "IAG".encode().hex()
|
||
"ticker": "IAG",
|
||
"decimals": 6,
|
||
"type": "utility",
|
||
},
|
||
}
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Internal cache — { key: (value, fetched_at_unix) }
|
||
# ---------------------------------------------------------------------------
|
||
|
||
_CACHE: dict[str, tuple] = {}
|
||
_CACHE_TTL_SECONDS = 300 # 5 minutes
|
||
|
||
|
||
def _cache_get(key: str) -> Optional[float]:
|
||
"""Return cached value if still fresh, else None."""
|
||
entry = _CACHE.get(key)
|
||
if entry is None:
|
||
return None
|
||
value, fetched_at = entry
|
||
if time.monotonic() - fetched_at > _CACHE_TTL_SECONDS:
|
||
return None
|
||
return value
|
||
|
||
|
||
def _cache_set(key: str, value: float) -> None:
|
||
"""Store value in cache with current timestamp."""
|
||
_CACHE[key] = (value, time.monotonic())
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Public API
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
async def get_ada_usd_price() -> float:
|
||
"""
|
||
Fetch the current ADA/USD price from CoinGecko.
|
||
|
||
Caches result for 5 minutes. Returns 0.0 on failure — callers should
|
||
treat 0.0 as a signal that pricing is unavailable.
|
||
|
||
Endpoint: GET https://api.coingecko.com/api/v3/simple/price
|
||
"""
|
||
cache_key = "ada_usd"
|
||
cached = _cache_get(cache_key)
|
||
if cached is not None:
|
||
return cached
|
||
|
||
url = "https://api.coingecko.com/api/v3/simple/price"
|
||
params = {"ids": "cardano", "vs_currencies": "usd"}
|
||
|
||
try:
|
||
async with httpx.AsyncClient(timeout=10) as client:
|
||
resp = await client.get(url, params=params)
|
||
resp.raise_for_status()
|
||
data = resp.json()
|
||
price = float(data["cardano"]["usd"])
|
||
|
||
except httpx.HTTPStatusError as e:
|
||
logger.error(
|
||
"[cardano_price] CoinGecko request failed: %s %s",
|
||
e.response.status_code,
|
||
e.response.text[:200],
|
||
)
|
||
return 0.0
|
||
except (KeyError, ValueError, TypeError) as e:
|
||
logger.error("[cardano_price] CoinGecko response parse error: %s", e)
|
||
return 0.0
|
||
except Exception as e:
|
||
logger.error("[cardano_price] CoinGecko unexpected error: %s", e)
|
||
return 0.0
|
||
|
||
logger.debug("[cardano_price] ADA/USD = %.6f (live)", price)
|
||
_cache_set(cache_key, price)
|
||
return price
|
||
|
||
|
||
async def get_token_ada_price(policy_id: str, asset_name_hex: str) -> Optional[float]:
|
||
"""
|
||
Fetch the price of a Cardano native token in ADA from DexHunter.
|
||
|
||
Tries the DexHunter v2 bestPool endpoint first, then falls back to the
|
||
community pair endpoint. Both return the token's ADA price per base unit.
|
||
|
||
Args:
|
||
policy_id: The token's Cardano policy ID (hex string).
|
||
asset_name_hex: The token's asset name as a hex-encoded string.
|
||
Derive with: token_ticker.encode().hex()
|
||
|
||
Returns:
|
||
Price in ADA per base unit of the token, or None if no liquidity /
|
||
not found / request failed.
|
||
|
||
Cache: 5 minutes per (policy_id, asset_name_hex) pair.
|
||
"""
|
||
if not policy_id or asset_name_hex is None:
|
||
# ADA itself — price is 1 ADA by definition
|
||
return 1.0
|
||
|
||
asset_id = f"{policy_id}{asset_name_hex}"
|
||
cache_key = f"token_ada:{asset_id}"
|
||
cached = _cache_get(cache_key)
|
||
if cached is not None:
|
||
return cached
|
||
|
||
price: Optional[float] = None
|
||
|
||
# --- Attempt 1: DexHunter v2 bestPool ---
|
||
try:
|
||
url = "https://api-v2.dexhunter.io/swap/bestPool"
|
||
params = {"tokenA": "lovelace", "tokenB": asset_id}
|
||
async with httpx.AsyncClient(timeout=10) as client:
|
||
resp = await client.get(url, params=params)
|
||
resp.raise_for_status()
|
||
data = resp.json()
|
||
|
||
# DexHunter returns price_a_per_b or price_b_per_a depending on direction.
|
||
# We want ADA per token — look for the field that represents that.
|
||
raw_price = (
|
||
data.get("price_b_per_a") # token per lovelace inverse
|
||
or data.get("price_a_per_b") # ada per token
|
||
or data.get("price")
|
||
)
|
||
if raw_price is not None:
|
||
candidate = float(raw_price)
|
||
# bestPool returns lovelace-denominated prices — convert to ADA
|
||
# If the value is very large (>1000), it's likely lovelace/token, invert & divide
|
||
if candidate > 1000:
|
||
price = 1_000_000 / candidate # lovelace per token → ADA per token
|
||
else:
|
||
price = candidate
|
||
logger.debug("[cardano_price] %s bestPool price = %.8f ADA", asset_id[:20], price)
|
||
|
||
except httpx.HTTPStatusError as e:
|
||
if e.response.status_code not in (404, 422):
|
||
logger.warning(
|
||
"[cardano_price] DexHunter bestPool error %s for %s",
|
||
e.response.status_code,
|
||
asset_id[:20],
|
||
)
|
||
except Exception as e:
|
||
logger.warning("[cardano_price] DexHunter bestPool failed for %s: %s", asset_id[:20], e)
|
||
|
||
# --- Attempt 2: DexHunter community pair endpoint (fallback) ---
|
||
if price is None:
|
||
try:
|
||
url = f"https://api.dexhunter.io/community/pair/{asset_id}"
|
||
async with httpx.AsyncClient(timeout=10) as client:
|
||
resp = await client.get(url)
|
||
resp.raise_for_status()
|
||
data = resp.json()
|
||
|
||
raw_price = (
|
||
data.get("price_ada")
|
||
or data.get("priceAda")
|
||
or data.get("price")
|
||
)
|
||
if raw_price is not None:
|
||
price = float(raw_price)
|
||
logger.debug(
|
||
"[cardano_price] %s community pair price = %.8f ADA",
|
||
asset_id[:20],
|
||
price,
|
||
)
|
||
|
||
except httpx.HTTPStatusError as e:
|
||
if e.response.status_code not in (404, 422):
|
||
logger.warning(
|
||
"[cardano_price] DexHunter community error %s for %s",
|
||
e.response.status_code,
|
||
asset_id[:20],
|
||
)
|
||
except Exception as e:
|
||
logger.warning("[cardano_price] DexHunter community failed for %s: %s", asset_id[:20], e)
|
||
|
||
if price is not None and price > 0:
|
||
_cache_set(cache_key, price)
|
||
return price
|
||
|
||
logger.info("[cardano_price] No price found for %s (no liquidity or unsupported)", asset_id[:20])
|
||
return None
|
||
|
||
|
||
async def convert_usd_to_lovelace(usd_amount: float) -> int:
|
||
"""
|
||
Convert a USD amount to lovelace using the current ADA/USD price.
|
||
|
||
1 ADA = 1,000,000 lovelace.
|
||
|
||
Args:
|
||
usd_amount: Amount in USD (e.g. 49.99).
|
||
|
||
Returns:
|
||
Equivalent lovelace as an integer, or 0 if ADA price is unavailable.
|
||
|
||
Example:
|
||
>>> await convert_usd_to_lovelace(10.00)
|
||
# At ADA = $0.45 → 10 / 0.45 ADA → 22,222,222 lovelace
|
||
"""
|
||
if usd_amount <= 0:
|
||
return 0
|
||
|
||
ada_usd = await get_ada_usd_price()
|
||
if ada_usd <= 0:
|
||
logger.error("[cardano_price] Cannot convert USD to lovelace — ADA price unavailable")
|
||
return 0
|
||
|
||
ada_amount = usd_amount / ada_usd
|
||
lovelace = int(ada_amount * 1_000_000)
|
||
|
||
logger.debug(
|
||
"[cardano_price] $%.2f USD → %.6f ADA → %d lovelace (rate: $%.6f/ADA)",
|
||
usd_amount,
|
||
ada_amount,
|
||
lovelace,
|
||
ada_usd,
|
||
)
|
||
return lovelace
|
||
|
||
|
||
async def convert_token_to_lovelace(
|
||
policy_id: str,
|
||
asset_name_hex: str,
|
||
token_quantity: int,
|
||
token_decimals: int = 0,
|
||
) -> Optional[int]:
|
||
"""
|
||
Convert a raw token quantity to its equivalent lovelace value.
|
||
|
||
Uses the token's ADA price from DexHunter and accounts for decimal
|
||
precision so that, for example, 1,000,000 units of a 6-decimal token
|
||
equals 1.0 whole token.
|
||
|
||
Args:
|
||
policy_id: Token policy ID.
|
||
asset_name_hex: Token asset name as hex (e.g. "534e454b" for SNEK).
|
||
token_quantity: Raw on-chain token quantity (base units, not decimal-adjusted).
|
||
token_decimals: Number of decimal places for the token (default 0).
|
||
|
||
Returns:
|
||
Equivalent lovelace as an integer, or None if price is unavailable.
|
||
|
||
Example:
|
||
# NIGHT token at 0.001 ADA/NIGHT, 6 decimals
|
||
# quantity = 5_000_000 (= 5.0 NIGHT), price = 0.001 ADA/token
|
||
# → 5.0 * 0.001 ADA = 0.005 ADA = 5,000 lovelace
|
||
>>> await convert_token_to_lovelace(policy_id, asset_name_hex, 5_000_000, 6)
|
||
5000
|
||
"""
|
||
if token_quantity <= 0:
|
||
return 0
|
||
|
||
# ADA is always 1:1 with itself in lovelace terms
|
||
if not policy_id and not asset_name_hex:
|
||
return token_quantity # already in lovelace
|
||
|
||
token_ada_price = await get_token_ada_price(policy_id, asset_name_hex)
|
||
if token_ada_price is None:
|
||
logger.warning(
|
||
"[cardano_price] Cannot convert token to lovelace — no price for %s%s",
|
||
policy_id[:12],
|
||
asset_name_hex[:8],
|
||
)
|
||
return None
|
||
|
||
# Adjust for decimals: base_units / 10^decimals = whole tokens
|
||
whole_tokens = token_quantity / (10 ** token_decimals)
|
||
|
||
# Whole tokens × ADA per token × lovelace per ADA
|
||
lovelace = int(whole_tokens * token_ada_price * 1_000_000)
|
||
|
||
logger.debug(
|
||
"[cardano_price] %d base units (decimals=%d) → %.6f tokens × %.8f ADA → %d lovelace",
|
||
token_quantity,
|
||
token_decimals,
|
||
whole_tokens,
|
||
token_ada_price,
|
||
lovelace,
|
||
)
|
||
return lovelace
|