v0.2: refactor monitor + scheduler around InvoiceStore Protocol

This commit is contained in:
Sulkta 2026-04-23 19:55:28 -07:00
parent 409005415e
commit fb1e1b73a6
6 changed files with 952 additions and 596 deletions

View file

@ -1,123 +1,158 @@
"""
Cardano UTXO Monitoring Service
"""Cardano UTXO monitoring — framework-agnostic polling against :class:`InvoiceStore`.
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.
Polls Koios for on-chain payments at the receive addresses of invoices that
are still in a non-terminal state, updates invoice status through the
:class:`InvoiceStore` Protocol, and optionally reprices expired invoices
against the current ADA/USD oracle snapshot.
Called by :mod:`cardano_checkout.scheduler` every 15 seconds for pending
payments, and every 60 seconds to reprice expired ones.
Koios endpoint used::
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)
Status transitions applied here::
PENDING CONFIRMED (received >= expected * CONFIRM_TOLERANCE)
PENDING UNDERPAID (received > 0 but below tolerance)
PENDING OVERPAID (received >= expected * OVERPAY_THRESHOLD)
PENDING EXPIRED (after reprice_count exhausts see reprice_expired_invoices)
Behavioral shape is identical to the original the host app ``services/cardano_monitor.py``:
same polling intervals, same Koios URL, same 2% confirm / overpay tolerances.
The only change is that persistence is now delegated to the store Protocol
instead of being welded to SQLAlchemy + the ``CardanoPayment`` model.
"""
from __future__ import annotations
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,
from cardano_checkout.invoice import Invoice, InvoiceStatus
from cardano_checkout.oracles import (
KNOWN_TOKENS,
convert_token_to_lovelace,
convert_usd_to_lovelace,
get_ada_usd_price,
)
from cardano_checkout.store import InvoiceStore
logger = logging.getLogger(__name__)
KOIOS_URL = "https://api.koios.rest/api/v1/address_utxos"
KOIOS_TIMEOUT = 15 # seconds
# Tolerance for confirming payment (2%)
# Tolerance for confirming payment (2%) — unchanged from v0.1 / the host app.
CONFIRM_TOLERANCE = 0.98
OVERPAY_THRESHOLD = 1.02
# Default reprice cap + window (matches the host app defaults).
DEFAULT_MAX_REPRICINGS = 3
DEFAULT_PAYMENT_WINDOW_MINUTES = 15
# =============================================================================
# Koios API
# =============================================================================
async def _check_address_utxos(address: str) -> list[dict]:
"""
Query Koios for all UTXOs at the given Cardano address.
# ---------------------------------------------------------------------------
# Koios UTXO query
# ---------------------------------------------------------------------------
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.
async def check_address_utxos(
address: str, koios_url: str = KOIOS_URL, timeout: float = KOIOS_TIMEOUT
) -> list[dict]:
"""Query Koios for all UTXOs at ``address``.
Returns a list of UTXO dicts (each with ``tx_hash``, ``tx_index``,
``value``, ``asset_list``) or an empty list on any error. Never raises.
"""
try:
async with httpx.AsyncClient(timeout=KOIOS_TIMEOUT) as client:
async with httpx.AsyncClient(timeout=timeout) as client:
resp = await client.post(
KOIOS_URL,
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])
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],
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)
except Exception as e: # pragma: no cover — defensive
logger.error(
"[cardano-monitor] Koios unexpected error for %s: %s", address[:20], e
)
return []
# =============================================================================
# Payment evaluation
# =============================================================================
# Backwards-compatible alias — monitor.py in the host app imports the private name.
# Keeping a leading-underscore alias so the the host app shim can still reach it.
_check_address_utxos = check_address_utxos
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.
# ---------------------------------------------------------------------------
# UTXO evaluation
# ---------------------------------------------------------------------------
async def evaluate_utxos(
expected_lovelace: int, utxos: list[dict]
) -> tuple[InvoiceStatus, int, int, dict, Optional[str]]:
"""Classify a batch of UTXOs against an expected-lovelace target.
Returns:
(new_status, received_lovelace, total_value_lovelace, received_assets, tx_hash)
Tuple of ``(status, raw_lovelace, total_value_lovelace, received_assets, latest_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"
- ``status`` :class:`InvoiceStatus` the invoice should transition into.
``PENDING`` means "no change, keep polling".
- ``raw_lovelace`` pure ADA received (excluding native-asset value).
- ``total_value_lovelace`` ADA + ADA-equivalent of native assets via DexHunter.
- ``received_assets`` ``{policy_id.asset_name_hex: quantity}``.
- ``latest_tx_hash`` most recent observed tx hash, or None if no UTXOs.
Status rules mirror the host app exactly:
- 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
return InvoiceStatus.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", "")
@ -129,14 +164,13 @@ async def _evaluate_payment(payment: CardanoPayment, utxos: list[dict]) -> tuple
if qty > 0:
received_assets[asset_id] = received_assets.get(asset_id, 0) + qty
# Convert native assets to lovelace equivalent
# Convert native assets to lovelace equivalent via DexHunter.
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:
@ -144,174 +178,244 @@ async def _evaluate_payment(payment: CardanoPayment, utxos: list[dict]) -> tuple
break
try:
lv = await convert_token_to_lovelace(policy_id, asset_name_hex, qty, decimals)
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)
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"
if expected_lovelace == 0:
# Degenerate case — any payment at all counts.
new_status = InvoiceStatus.CONFIRMED if total_value > 0 else InvoiceStatus.PENDING
elif total_value >= expected_lovelace * OVERPAY_THRESHOLD:
new_status = InvoiceStatus.OVERPAID
elif total_value >= expected_lovelace * CONFIRM_TOLERANCE:
new_status = InvoiceStatus.CONFIRMED
elif total_value > 0:
new_status = "underpaid"
new_status = InvoiceStatus.UNDERPAID
else:
new_status = "pending"
new_status = InvoiceStatus.PENDING
return new_status, raw_lovelace, total_value, received_assets, latest_tx_hash
# =============================================================================
# Main monitoring functions (called by scheduler)
# =============================================================================
# Backwards-compatible alias.
_evaluate_utxos = evaluate_utxos
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.
# ---------------------------------------------------------------------------
# Main monitoring entrypoints (scheduled jobs call these)
# ---------------------------------------------------------------------------
async def check_pending_invoices(
store: InvoiceStore,
koios_url: str = KOIOS_URL,
limit: int = 100,
) -> int:
"""Check every :class:`InvoiceStatus.PENDING` invoice for on-chain payment.
For each pending invoice whose expiry has not yet passed, query Koios
for the UTXOs at its receive address, evaluate them against
``expected_lovelace``, and transition the invoice state accordingly.
Args:
store: Persistence backend.
koios_url: Koios base URL. Override for testnet / custom gateways.
limit: Max pending invoices to process per poll.
Returns:
Number of invoices updated this cycle (for logging / metrics).
"""
now = datetime.now(timezone.utc)
pending = await store.list_by_status(InvoiceStatus.PENDING, limit=limit)
if not pending:
return 0
result = await db.execute(
select(CardanoPayment).where(
CardanoPayment.status == "pending",
CardanoPayment.expires_at > now,
)
)
payments = result.scalars().all()
# Filter out already-expired invoices — those get picked up by reprice_expired_invoices.
active = [
inv for inv in pending if inv.expires_at is None or inv.expires_at > now
]
if not active:
return 0
if not payments:
return
logger.debug("[cardano-monitor] Checking %d pending invoice(s)", len(active))
logger.debug("[cardano-monitor] Checking %d pending payment(s)", len(payments))
for payment in payments:
updates = 0
for invoice in active:
try:
utxos = await _check_address_utxos(payment.address)
new_status, raw_lovelace, total_value, received_assets, tx_hash = await _evaluate_payment(payment, utxos)
utxos = await check_address_utxos(invoice.receive_address, koios_url=koios_url)
(
new_status,
raw_lovelace,
total_value,
_received_assets,
tx_hash,
) = await evaluate_utxos(invoice.expected_lovelace, utxos)
if new_status == payment.status and raw_lovelace == 0:
# No change, no UTXOs — skip DB write
if new_status == invoice.status and raw_lovelace == 0:
# No change and no UTXOs — nothing to persist.
continue
payment.received_lovelace = raw_lovelace
payment.total_value_lovelace = total_value
payment.received_assets = received_assets
invoice.received_lovelace = raw_lovelace
if tx_hash and tx_hash not in invoice.tx_hashes:
invoice.tx_hashes.append(tx_hash)
# record_tx is idempotent per contract — call it so the store
# can persist the per-utxo history however it wants.
try:
await store.record_tx(
invoice.id, tx_hash, lovelace_delta=raw_lovelace
)
except Exception as e:
logger.warning(
"[cardano-monitor] record_tx failed for %s/%s: %s",
invoice.id,
tx_hash[:12],
e,
)
if tx_hash:
payment.tx_hash = tx_hash
if new_status != invoice.status:
old_status = invoice.status
invoice.status = new_status
if new_status != payment.status:
old_status = payment.status
payment.status = new_status
if new_status in ("confirmed", "overpaid"):
payment.confirmed_at = now
if new_status in (InvoiceStatus.CONFIRMED, InvoiceStatus.OVERPAID):
invoice.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,
"[cardano-monitor] invoice=%s merchant=%s: %s -> %s "
"(%.6f ADA received, %.6f ADA total value)",
invoice.id,
invoice.merchant_id,
old_status.value,
new_status.value,
raw_lovelace / 1_000_000,
total_value / 1_000_000,
)
await store.update(invoice)
updates += 1
except Exception as e:
logger.exception(
"[cardano-monitor] Error checking payment #%d: %s", payment.id, e
"[cardano-monitor] Error checking invoice %s: %s", invoice.id, e
)
await db.commit()
return updates
async def reprice_expired_payments(db: AsyncSession) -> None:
"""
Reprice payments whose window has expired.
async def reprice_expired_invoices(
store: InvoiceStore,
window_minutes: int = DEFAULT_PAYMENT_WINDOW_MINUTES,
max_repricings: int = DEFAULT_MAX_REPRICINGS,
limit: int = 100,
) -> int:
"""Reprice PENDING invoices whose expiry has passed.
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.
Pulls the current ADA/USD oracle price, recalculates ``expected_lovelace``
from the invoice's ``usd_amount``, resets ``expires_at`` to
``now + window_minutes``, and tracks reprice count in ``invoice.metadata``
under the key ``repriced_count``. After ``max_repricings`` the invoice
is transitioned to :class:`InvoiceStatus.EXPIRED`.
Args:
store: Persistence backend.
window_minutes: New expiry window per reprice. Matches the host app's
platform-config-driven value of 15 minutes by default.
max_repricings: Give-up threshold. the host app default is 3.
limit: Max pending invoices to process per call.
Returns:
Number of invoices updated (repriced or expired) this cycle.
"""
now = datetime.now(timezone.utc)
pending = await store.list_by_status(InvoiceStatus.PENDING, limit=limit)
if not pending:
return 0
result = await db.execute(
select(CardanoPayment).where(
CardanoPayment.status == "pending",
CardanoPayment.expires_at <= now,
CardanoPayment.repriced_count < 3,
)
expired_candidates = [
inv for inv in pending if inv.expires_at is not None and inv.expires_at <= now
]
if not expired_candidates:
return 0
logger.info(
"[cardano-monitor] Repricing %d expired invoice(s)", len(expired_candidates)
)
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
logger.warning(
"[cardano-monitor] Cannot reprice — ADA price unavailable"
)
return 0
new_expires_at = now + timedelta(minutes=window_minutes)
updated = 0
for payment in payments:
for invoice in expired_candidates:
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)
repriced_count = int(invoice.metadata.get("repriced_count", 0))
if repriced_count >= max_repricings:
invoice.status = InvoiceStatus.EXPIRED
await store.update(invoice)
updated += 1
logger.info(
"[cardano-monitor] invoice %s expired after %d repricings",
invoice.id,
repriced_count,
)
continue
new_lovelace = await convert_usd_to_lovelace(total_usd)
usd_amount = float(invoice.usd_amount or 0)
if usd_amount <= 0:
invoice.status = InvoiceStatus.EXPIRED
await store.update(invoice)
updated += 1
logger.warning(
"[cardano-monitor] invoice %s has no usd_amount — marking expired",
invoice.id,
)
continue
new_lovelace = await convert_usd_to_lovelace(usd_amount)
if new_lovelace == 0:
logger.warning("[cardano-monitor] payment #%d: lovelace conversion returned 0, skipping", payment.id)
logger.warning(
"[cardano-monitor] invoice %s: lovelace conversion returned 0, skipping",
invoice.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
old_lovelace = invoice.expected_lovelace
invoice.expected_lovelace = new_lovelace
invoice.expires_at = new_expires_at
invoice.metadata["repriced_count"] = repriced_count + 1
invoice.metadata["ada_price_usd"] = round(ada_price, 4)
await store.update(invoice)
updated += 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,
"[cardano-monitor] Repriced invoice %s: %d -> %d lovelace "
"(ADA=$%.4f, reprice #%d)",
invoice.id,
old_lovelace or 0,
new_lovelace,
ada_price,
repriced_count + 1,
)
except Exception as e:
logger.exception("[cardano-monitor] Error repricing payment #%d: %s", payment.id, e)
logger.exception(
"[cardano-monitor] Error repricing invoice %s: %s", invoice.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()
return updated