v0.2: refactor monitor + scheduler around InvoiceStore Protocol
This commit is contained in:
parent
409005415e
commit
fb1e1b73a6
6 changed files with 952 additions and 596 deletions
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue