v1.0.0-dev: slim to the real product — merchant state machine only

This commit is contained in:
Sulkta 2026-04-23 21:58:26 -07:00
parent c31518309d
commit b4a81e0ab8
15 changed files with 286 additions and 2503 deletions

View file

@ -30,19 +30,23 @@ from __future__ import annotations
import logging
from datetime import datetime, timedelta, timezone
from typing import Optional
from typing import Awaitable, Callable, Optional
import httpx
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
# Consumer-supplied pricing callable: takes a USD amount (float),
# returns the current-market lovelace equivalent as int. Invoked by
# :func:`reprice_expired_invoices` to generate fresh quotes when an
# invoice's quote window lapses without payment.
#
# SDK intentionally does NOT ship an oracle. Consumers wire whatever
# price source they trust (CoinGecko, Koios ticker, their own DEX feed,
# or a constant for tests).
PriceFn = Callable[[float], Awaitable[int]]
logger = logging.getLogger(__name__)
KOIOS_URL = "https://api.koios.rest/api/v1/address_utxos"
@ -164,33 +168,12 @@ async def evaluate_utxos(
if qty > 0:
received_assets[asset_id] = received_assets.get(asset_id, 0) + qty
# 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)
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
# ADA-only matching. Any native tokens landed in the same UTxOs are
# recorded in received_assets for visibility but do NOT contribute to
# the payment-matched total. Consumers who want to accept stablecoins
# or other native tokens wrap this function with their own asset-to-
# lovelace converter before comparing against expected_lovelace.
total_value = raw_lovelace
if expected_lovelace == 0:
# Degenerate case — any payment at all counts.
@ -313,23 +296,35 @@ async def check_pending_invoices(
async def reprice_expired_invoices(
store: InvoiceStore,
*,
price_fn: PriceFn,
window_minutes: int = DEFAULT_PAYMENT_WINDOW_MINUTES,
max_repricings: int = DEFAULT_MAX_REPRICINGS,
limit: int = 100,
) -> int:
"""Reprice PENDING invoices whose expiry has passed.
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`.
Calls the consumer-supplied ``price_fn(usd_amount) -> lovelace`` to
recompute ``expected_lovelace`` at current market. Resets ``expires_at``
to ``now + window_minutes`` and increments ``invoice.metadata["repriced_count"]``.
After ``max_repricings`` the invoice transitions 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.
price_fn: Async callable that takes a USD amount and returns the
current lovelace equivalent. Consumer-supplied the SDK does
not ship an oracle. A simple wiring looks like::
from cardano_checkout.monitor import reprice_expired_invoices
async def my_price_fn(usd: float) -> int:
rate = await coingecko_fetch_ada_usd() # your code
return int(round(usd / rate * 1_000_000))
await reprice_expired_invoices(store, price_fn=my_price_fn)
window_minutes: New expiry window per reprice. the host app default 15.
max_repricings: Give-up threshold. the host app default 3.
limit: Max pending invoices to process per call.
Returns:
@ -350,13 +345,6 @@ async def reprice_expired_invoices(
"[cardano-monitor] Repricing %d expired invoice(s)", len(expired_candidates)
)
ada_price = await get_ada_usd_price()
if ada_price <= 0:
logger.warning(
"[cardano-monitor] Cannot reprice — ADA price unavailable"
)
return 0
new_expires_at = now + timedelta(minutes=window_minutes)
updated = 0
@ -386,11 +374,20 @@ async def reprice_expired_invoices(
)
continue
new_lovelace = await convert_usd_to_lovelace(usd_amount)
if new_lovelace == 0:
try:
new_lovelace = await price_fn(usd_amount)
except Exception as e:
logger.warning(
"[cardano-monitor] invoice %s: lovelace conversion returned 0, skipping",
"[cardano-monitor] invoice %s: price_fn raised %s, skipping",
invoice.id,
e,
)
continue
if new_lovelace <= 0:
logger.warning(
"[cardano-monitor] invoice %s: price_fn returned %d, skipping",
invoice.id,
new_lovelace,
)
continue
@ -398,18 +395,15 @@ async def reprice_expired_invoices(
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 invoice %s: %d -> %d lovelace "
"(ADA=$%.4f, reprice #%d)",
"[cardano-monitor] Repriced invoice %s: %d -> %d lovelace (reprice #%d)",
invoice.id,
old_lovelace or 0,
new_lovelace,
ada_price,
repriced_count + 1,
)