cardano-checkout-py/cardano_checkout/oracles.py
Kayos dc6378eda6 v0.1.0-dev: initial extraction from TradeCraft + new abstractions
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
2026-04-23 18:04:00 -07:00

346 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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