cardano-checkout-py/cardano_checkout/monitor.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

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()