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
317 lines
11 KiB
Python
317 lines
11 KiB
Python
"""
|
|
Cardano UTXO Monitoring Service
|
|
|
|
Polls Koios API to detect on-chain payments at derived Cardano addresses.
|
|
Called by the scheduler every 15 seconds for pending payments, and every
|
|
60 seconds to reprice expired payment requests.
|
|
|
|
Koios endpoint used:
|
|
POST https://api.koios.rest/api/v1/address_utxos
|
|
Body: {"_addresses": ["addr1..."]}
|
|
|
|
Status flow applied here:
|
|
pending -> confirmed (received >= expected * 0.98)
|
|
pending -> underpaid (received > 0 but < expected * 0.98)
|
|
pending -> overpaid (received >= expected * 1.02 — still confirmed)
|
|
pending -> expired (handled by reprice_expired_payments)
|
|
"""
|
|
import logging
|
|
from datetime import datetime, timedelta, timezone
|
|
from decimal import Decimal
|
|
from typing import Optional
|
|
|
|
import httpx
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from models import CardanoPayment, Config, PlatformConfig
|
|
from services.cardano_price import (
|
|
convert_token_to_lovelace,
|
|
get_ada_usd_price,
|
|
convert_usd_to_lovelace,
|
|
KNOWN_TOKENS,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
KOIOS_URL = "https://api.koios.rest/api/v1/address_utxos"
|
|
KOIOS_TIMEOUT = 15 # seconds
|
|
|
|
# Tolerance for confirming payment (2%)
|
|
CONFIRM_TOLERANCE = 0.98
|
|
OVERPAY_THRESHOLD = 1.02
|
|
|
|
|
|
# =============================================================================
|
|
# Koios API
|
|
# =============================================================================
|
|
|
|
async def _check_address_utxos(address: str) -> list[dict]:
|
|
"""
|
|
Query Koios for all UTXOs at the given Cardano address.
|
|
|
|
Returns a list of UTXO dicts from Koios, or an empty list on error.
|
|
Each UTXO has keys: tx_hash, tx_index, value (lovelace), asset_list.
|
|
"""
|
|
try:
|
|
async with httpx.AsyncClient(timeout=KOIOS_TIMEOUT) as client:
|
|
resp = await client.post(
|
|
KOIOS_URL,
|
|
json={"_addresses": [address]},
|
|
headers={"Accept": "application/json"},
|
|
)
|
|
resp.raise_for_status()
|
|
data = resp.json()
|
|
if not isinstance(data, list):
|
|
logger.warning("[cardano-monitor] Unexpected Koios response shape for %s", address[:20])
|
|
return []
|
|
return data
|
|
|
|
except httpx.HTTPStatusError as e:
|
|
logger.error(
|
|
"[cardano-monitor] Koios HTTP %s for %s: %s",
|
|
e.response.status_code, address[:20], e.response.text[:200],
|
|
)
|
|
return []
|
|
except httpx.TimeoutException:
|
|
logger.warning("[cardano-monitor] Koios timeout for address %s", address[:20])
|
|
return []
|
|
except Exception as e:
|
|
logger.error("[cardano-monitor] Koios unexpected error for %s: %s", address[:20], e)
|
|
return []
|
|
|
|
|
|
# =============================================================================
|
|
# Payment evaluation
|
|
# =============================================================================
|
|
|
|
async def _evaluate_payment(payment: CardanoPayment, utxos: list[dict]) -> tuple[str, int, int, dict, Optional[str]]:
|
|
"""
|
|
Evaluate UTXOs against the expected payment and determine new status.
|
|
|
|
Returns:
|
|
(new_status, received_lovelace, total_value_lovelace, received_assets, tx_hash)
|
|
|
|
Status rules:
|
|
- No UTXOs -> "pending" (no change)
|
|
- total_value >= expected * OVERPAY_THRESHOLD -> "overpaid" (treated as confirmed)
|
|
- total_value >= expected * CONFIRM_TOLERANCE -> "confirmed"
|
|
- total_value > 0 but below tolerance -> "underpaid"
|
|
"""
|
|
if not utxos:
|
|
return "pending", 0, 0, {}, None
|
|
|
|
raw_lovelace = 0
|
|
received_assets: dict[str, int] = {}
|
|
latest_tx_hash: Optional[str] = None
|
|
|
|
for utxo in utxos:
|
|
# Sum ADA (lovelace)
|
|
try:
|
|
raw_lovelace += int(utxo.get("value", 0))
|
|
except (ValueError, TypeError):
|
|
pass
|
|
|
|
# Track latest tx_hash
|
|
tx = utxo.get("tx_hash")
|
|
if tx:
|
|
latest_tx_hash = tx
|
|
|
|
# Collect native assets
|
|
for asset in utxo.get("asset_list", []) or []:
|
|
policy_id = asset.get("policy_id", "")
|
|
asset_name = asset.get("asset_name", "")
|
|
asset_id = f"{policy_id}.{asset_name}"
|
|
try:
|
|
qty = int(asset.get("quantity", 0))
|
|
except (ValueError, TypeError):
|
|
qty = 0
|
|
if qty > 0:
|
|
received_assets[asset_id] = received_assets.get(asset_id, 0) + qty
|
|
|
|
# Convert native assets to lovelace equivalent
|
|
asset_lovelace = 0
|
|
for asset_id, qty in received_assets.items():
|
|
if "." not in asset_id:
|
|
continue
|
|
policy_id, asset_name_hex = asset_id.split(".", 1)
|
|
|
|
# Find matching known token for decimals
|
|
decimals = 0
|
|
for token_info in KNOWN_TOKENS.values():
|
|
if token_info.get("policy_id") == policy_id:
|
|
decimals = token_info.get("decimals", 0)
|
|
break
|
|
|
|
try:
|
|
lv = await convert_token_to_lovelace(policy_id, asset_name_hex, qty, decimals)
|
|
if lv is not None:
|
|
asset_lovelace += lv
|
|
except Exception as e:
|
|
logger.warning("[cardano-monitor] Failed to convert asset %s to lovelace: %s", asset_id[:20], e)
|
|
|
|
total_value = raw_lovelace + asset_lovelace
|
|
expected = payment.expected_lovelace or 0
|
|
|
|
if expected == 0:
|
|
# Degenerate case — treat any payment as confirmed
|
|
new_status = "confirmed"
|
|
elif total_value >= expected * OVERPAY_THRESHOLD:
|
|
new_status = "overpaid"
|
|
elif total_value >= expected * CONFIRM_TOLERANCE:
|
|
new_status = "confirmed"
|
|
elif total_value > 0:
|
|
new_status = "underpaid"
|
|
else:
|
|
new_status = "pending"
|
|
|
|
return new_status, raw_lovelace, total_value, received_assets, latest_tx_hash
|
|
|
|
|
|
# =============================================================================
|
|
# Main monitoring functions (called by scheduler)
|
|
# =============================================================================
|
|
|
|
async def check_pending_payments(db: AsyncSession) -> None:
|
|
"""
|
|
Check all pending payments that haven't expired yet.
|
|
|
|
Queries Koios for UTXOs at each address. Updates payment status in place.
|
|
"""
|
|
now = datetime.now(timezone.utc)
|
|
|
|
result = await db.execute(
|
|
select(CardanoPayment).where(
|
|
CardanoPayment.status == "pending",
|
|
CardanoPayment.expires_at > now,
|
|
)
|
|
)
|
|
payments = result.scalars().all()
|
|
|
|
if not payments:
|
|
return
|
|
|
|
logger.debug("[cardano-monitor] Checking %d pending payment(s)", len(payments))
|
|
|
|
for payment in payments:
|
|
try:
|
|
utxos = await _check_address_utxos(payment.address)
|
|
new_status, raw_lovelace, total_value, received_assets, tx_hash = await _evaluate_payment(payment, utxos)
|
|
|
|
if new_status == payment.status and raw_lovelace == 0:
|
|
# No change, no UTXOs — skip DB write
|
|
continue
|
|
|
|
payment.received_lovelace = raw_lovelace
|
|
payment.total_value_lovelace = total_value
|
|
payment.received_assets = received_assets
|
|
|
|
if tx_hash:
|
|
payment.tx_hash = tx_hash
|
|
|
|
if new_status != payment.status:
|
|
old_status = payment.status
|
|
payment.status = new_status
|
|
|
|
if new_status in ("confirmed", "overpaid"):
|
|
payment.confirmed_at = now
|
|
|
|
logger.info(
|
|
"[cardano-monitor] payment #%d invoice_id=%d: %s -> %s (%.6f ADA received, %.6f ADA total value)",
|
|
payment.id,
|
|
payment.invoice_id or 0,
|
|
old_status,
|
|
new_status,
|
|
raw_lovelace / 1_000_000,
|
|
total_value / 1_000_000,
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.exception(
|
|
"[cardano-monitor] Error checking payment #%d: %s", payment.id, e
|
|
)
|
|
|
|
await db.commit()
|
|
|
|
|
|
async def reprice_expired_payments(db: AsyncSession) -> None:
|
|
"""
|
|
Reprice payments whose window has expired.
|
|
|
|
Fetches the current ADA price, recalculates expected_lovelace, resets
|
|
expires_at to now + payment_window_minutes, and increments repriced_count.
|
|
Gives up after 3 repricings to avoid infinite loops.
|
|
"""
|
|
now = datetime.now(timezone.utc)
|
|
|
|
result = await db.execute(
|
|
select(CardanoPayment).where(
|
|
CardanoPayment.status == "pending",
|
|
CardanoPayment.expires_at <= now,
|
|
CardanoPayment.repriced_count < 3,
|
|
)
|
|
)
|
|
payments = result.scalars().all()
|
|
|
|
if not payments:
|
|
return
|
|
|
|
logger.info("[cardano-monitor] Repricing %d expired payment(s)", len(payments))
|
|
|
|
ada_price = await get_ada_usd_price()
|
|
if ada_price <= 0:
|
|
logger.warning("[cardano-monitor] Cannot reprice — ADA price unavailable")
|
|
return
|
|
|
|
# Read platform payment window
|
|
pc_result = await db.execute(
|
|
select(PlatformConfig).where(PlatformConfig.key == "cardano_payment_window_minutes")
|
|
)
|
|
pc = pc_result.scalar_one_or_none()
|
|
try:
|
|
window_minutes = int(pc.value) if pc and pc.value else 15
|
|
except (ValueError, TypeError):
|
|
window_minutes = 15
|
|
|
|
new_expires_at = now + timedelta(minutes=window_minutes)
|
|
|
|
for payment in payments:
|
|
try:
|
|
total_usd = float(payment.expected_usd or 0)
|
|
if total_usd <= 0:
|
|
payment.status = "expired"
|
|
logger.warning("[cardano-monitor] payment #%d has no expected_usd — marking expired", payment.id)
|
|
continue
|
|
|
|
new_lovelace = await convert_usd_to_lovelace(total_usd)
|
|
if new_lovelace == 0:
|
|
logger.warning("[cardano-monitor] payment #%d: lovelace conversion returned 0, skipping", payment.id)
|
|
continue
|
|
|
|
old_lovelace = payment.expected_lovelace
|
|
payment.expected_lovelace = new_lovelace
|
|
payment.ada_price_usd = Decimal(str(round(ada_price, 4)))
|
|
payment.expires_at = new_expires_at
|
|
payment.repriced_count += 1
|
|
|
|
logger.info(
|
|
"[cardano-monitor] Repriced payment #%d: %d -> %d lovelace (ADA=$%.4f, reprice #%d)",
|
|
payment.id, old_lovelace or 0, new_lovelace, ada_price, payment.repriced_count,
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.exception("[cardano-monitor] Error repricing payment #%d: %s", payment.id, e)
|
|
|
|
# Mark payments that have exceeded max repricings as expired
|
|
expired_result = await db.execute(
|
|
select(CardanoPayment).where(
|
|
CardanoPayment.status == "pending",
|
|
CardanoPayment.expires_at <= now,
|
|
CardanoPayment.repriced_count >= 3,
|
|
)
|
|
)
|
|
for payment in expired_result.scalars().all():
|
|
payment.status = "expired"
|
|
logger.info("[cardano-monitor] payment #%d marked expired after %d repricings", payment.id, payment.repriced_count)
|
|
|
|
await db.commit()
|