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
465 lines
17 KiB
Python
465 lines
17 KiB
Python
"""
|
|
Cardano Payment Monitoring Scheduler
|
|
|
|
APScheduler integration that runs five recurring jobs:
|
|
- check_pending_payments — every 15 seconds
|
|
- reprice_expired_payments — every 60 seconds
|
|
- _check_subscription_payments — every 60 seconds
|
|
- _reprice_subscription_payments — every 6 hours
|
|
- _enforce_grace_period — daily at 06:00 UTC
|
|
|
|
Usage in main.py lifespan:
|
|
from services.cardano_scheduler import start_cardano_scheduler, stop_cardano_scheduler
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI):
|
|
await start_cardano_scheduler()
|
|
yield
|
|
await stop_cardano_scheduler()
|
|
"""
|
|
import logging
|
|
from datetime import datetime, timedelta, timezone
|
|
from decimal import Decimal
|
|
from typing import Optional
|
|
|
|
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
|
from apscheduler.triggers.cron import CronTrigger
|
|
from apscheduler.triggers.interval import IntervalTrigger
|
|
from sqlalchemy import select
|
|
|
|
from database import async_session_maker
|
|
from services.cardano_monitor import (
|
|
_check_address_utxos,
|
|
_evaluate_payment,
|
|
check_pending_payments,
|
|
reprice_expired_payments,
|
|
)
|
|
from services.cardano_price import convert_usd_to_lovelace, get_ada_usd_price
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_scheduler: Optional[AsyncIOScheduler] = None
|
|
|
|
|
|
# =============================================================================
|
|
# Scheduled job wrappers — invoice payments
|
|
# =============================================================================
|
|
|
|
async def _job_check_pending() -> None:
|
|
"""Scheduler wrapper for check_pending_payments."""
|
|
try:
|
|
async with async_session_maker() as db:
|
|
await check_pending_payments(db)
|
|
except Exception:
|
|
logger.exception("[cardano-scheduler] check_pending_payments job failed")
|
|
|
|
|
|
async def _job_reprice_expired() -> None:
|
|
"""Scheduler wrapper for reprice_expired_payments."""
|
|
try:
|
|
async with async_session_maker() as db:
|
|
await reprice_expired_payments(db)
|
|
except Exception:
|
|
logger.exception("[cardano-scheduler] reprice_expired_payments job failed")
|
|
|
|
|
|
# =============================================================================
|
|
# Scheduled job wrappers — subscription payments
|
|
# =============================================================================
|
|
|
|
async def _job_check_subscription_payments() -> None:
|
|
"""
|
|
Poll Koios for UTXOs at awaiting_payment subscription addresses.
|
|
|
|
Reuses _check_address_utxos and _evaluate_payment from cardano_monitor.
|
|
On confirmation, advances subscription status to 'active' and updates
|
|
company.subscription_tier to match the subscription's tier.
|
|
"""
|
|
from models import Company, Subscription, SubscriptionPayment
|
|
|
|
try:
|
|
async with async_session_maker() as db:
|
|
now = datetime.now(timezone.utc)
|
|
|
|
result = await db.execute(
|
|
select(SubscriptionPayment).where(
|
|
SubscriptionPayment.status.in_(["awaiting_payment", "underpaid"]),
|
|
SubscriptionPayment.expires_at > now,
|
|
)
|
|
)
|
|
payments = result.scalars().all()
|
|
|
|
if not payments:
|
|
return
|
|
|
|
logger.debug(
|
|
"[cardano-scheduler] Checking %d subscription payment(s)", len(payments)
|
|
)
|
|
|
|
for sp in payments:
|
|
try:
|
|
utxos = await _check_address_utxos(sp.address)
|
|
|
|
# _evaluate_payment expects a CardanoPayment-like object.
|
|
# SubscriptionPayment has the same fields it needs.
|
|
new_status, raw_lovelace, total_value, received_assets, tx_hash = (
|
|
await _evaluate_payment(sp, utxos)
|
|
)
|
|
|
|
# Map monitor statuses to subscription payment statuses
|
|
status_map = {
|
|
"pending": "awaiting_payment",
|
|
"confirmed": "confirmed",
|
|
"overpaid": "overpaid",
|
|
"underpaid": "underpaid",
|
|
}
|
|
mapped_status = status_map.get(new_status, new_status)
|
|
|
|
if mapped_status == sp.status and raw_lovelace == 0:
|
|
continue
|
|
|
|
sp.received_lovelace = raw_lovelace
|
|
sp.total_value_lovelace = total_value
|
|
sp.received_assets = received_assets
|
|
|
|
if tx_hash:
|
|
sp.tx_hash = tx_hash
|
|
|
|
if mapped_status != sp.status:
|
|
old_status = sp.status
|
|
sp.status = mapped_status
|
|
|
|
if mapped_status in ("confirmed", "overpaid"):
|
|
sp.confirmed_at = now
|
|
|
|
# Advance subscription + company tier
|
|
if sp.subscription_id:
|
|
sub_result = await db.execute(
|
|
select(Subscription).where(
|
|
Subscription.id == sp.subscription_id
|
|
)
|
|
)
|
|
sub = sub_result.scalar_one_or_none()
|
|
if sub:
|
|
sub.status = "active"
|
|
sub.updated_at = now
|
|
|
|
# Apply any pending tier downgrade
|
|
if sub.pending_tier and sp.period_end and sp.period_end <= now.date():
|
|
sub.tier = sub.pending_tier
|
|
sub.pending_tier = None
|
|
sub.pending_tier_at = None
|
|
|
|
company_result = await db.execute(
|
|
select(Company).where(Company.id == sp.company_id)
|
|
)
|
|
company = company_result.scalar_one_or_none()
|
|
if company:
|
|
# Get current sub tier
|
|
sub_result2 = await db.execute(
|
|
select(Subscription).where(
|
|
Subscription.company_id == sp.company_id
|
|
)
|
|
)
|
|
sub2 = sub_result2.scalar_one_or_none()
|
|
if sub2:
|
|
company.subscription_tier = sub2.tier
|
|
company.subscription_status = "active"
|
|
|
|
logger.info(
|
|
"[cardano-scheduler] sub_payment #%d company_id=%d: %s -> %s"
|
|
" (%.6f ADA received)",
|
|
sp.id,
|
|
sp.company_id,
|
|
old_status,
|
|
mapped_status,
|
|
raw_lovelace / 1_000_000,
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.exception(
|
|
"[cardano-scheduler] Error checking sub_payment #%d: %s", sp.id, e
|
|
)
|
|
|
|
await db.commit()
|
|
|
|
except Exception:
|
|
logger.exception("[cardano-scheduler] _check_subscription_payments job failed")
|
|
|
|
|
|
async def _job_reprice_subscription_payments() -> None:
|
|
"""
|
|
Reprice awaiting_payment subscription records whose 24-hour window has expired.
|
|
|
|
Fetches the current ADA price, recalculates expected_lovelace, resets expires_at
|
|
to now + 24 hours, and increments repriced_count. Gives up after 3 repricings.
|
|
"""
|
|
from models import SubscriptionPayment
|
|
|
|
try:
|
|
async with async_session_maker() as db:
|
|
now = datetime.now(timezone.utc)
|
|
|
|
result = await db.execute(
|
|
select(SubscriptionPayment).where(
|
|
SubscriptionPayment.status == "awaiting_payment",
|
|
SubscriptionPayment.expires_at <= now,
|
|
SubscriptionPayment.repriced_count < 3,
|
|
)
|
|
)
|
|
payments = result.scalars().all()
|
|
|
|
if not payments:
|
|
return
|
|
|
|
logger.info(
|
|
"[cardano-scheduler] Repricing %d subscription payment(s)", len(payments)
|
|
)
|
|
|
|
ada_price = await get_ada_usd_price()
|
|
if ada_price <= 0:
|
|
logger.warning(
|
|
"[cardano-scheduler] Cannot reprice subscriptions — ADA price unavailable"
|
|
)
|
|
return
|
|
|
|
new_expires_at = now + timedelta(hours=24)
|
|
|
|
for sp in payments:
|
|
try:
|
|
total_usd = float(sp.expected_usd or 0)
|
|
if total_usd <= 0:
|
|
sp.status = "expired"
|
|
logger.warning(
|
|
"[cardano-scheduler] sub_payment #%d has no expected_usd — expired",
|
|
sp.id,
|
|
)
|
|
continue
|
|
|
|
new_lovelace = await convert_usd_to_lovelace(total_usd)
|
|
if new_lovelace == 0:
|
|
logger.warning(
|
|
"[cardano-scheduler] sub_payment #%d: lovelace conversion returned 0, skipping",
|
|
sp.id,
|
|
)
|
|
continue
|
|
|
|
old_lovelace = sp.expected_lovelace
|
|
sp.expected_lovelace = new_lovelace
|
|
sp.ada_price_usd = Decimal(str(round(ada_price, 4)))
|
|
sp.expires_at = new_expires_at
|
|
sp.repriced_count += 1
|
|
|
|
logger.info(
|
|
"[cardano-scheduler] Repriced sub_payment #%d: %d -> %d lovelace"
|
|
" (ADA=$%.4f, reprice #%d)",
|
|
sp.id,
|
|
old_lovelace or 0,
|
|
new_lovelace,
|
|
ada_price,
|
|
sp.repriced_count,
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.exception(
|
|
"[cardano-scheduler] Error repricing sub_payment #%d: %s", sp.id, e
|
|
)
|
|
|
|
# Mark max-repriced payments as expired
|
|
expired_result = await db.execute(
|
|
select(SubscriptionPayment).where(
|
|
SubscriptionPayment.status == "awaiting_payment",
|
|
SubscriptionPayment.expires_at <= now,
|
|
SubscriptionPayment.repriced_count >= 3,
|
|
)
|
|
)
|
|
for sp in expired_result.scalars().all():
|
|
sp.status = "expired"
|
|
logger.info(
|
|
"[cardano-scheduler] sub_payment #%d expired after %d repricings",
|
|
sp.id,
|
|
sp.repriced_count,
|
|
)
|
|
|
|
await db.commit()
|
|
|
|
except Exception:
|
|
logger.exception("[cardano-scheduler] _reprice_subscription_payments job failed")
|
|
|
|
|
|
async def _job_enforce_grace_period() -> None:
|
|
"""
|
|
Daily enforcement of subscription grace periods (runs at 06:00 UTC).
|
|
|
|
Rules:
|
|
- If due_date has passed and payment is not confirmed: mark subscription past_due
|
|
- If grace_deadline has passed and payment still not confirmed: suspend subscription
|
|
and update company.subscription_status accordingly
|
|
"""
|
|
from models import Company, Subscription, SubscriptionPayment
|
|
|
|
try:
|
|
async with async_session_maker() as db:
|
|
today = datetime.now(timezone.utc).date()
|
|
|
|
# Find subscriptions that are active/past_due and have an overdue payment
|
|
overdue_result = await db.execute(
|
|
select(SubscriptionPayment).where(
|
|
SubscriptionPayment.status.in_(["awaiting_payment", "underpaid", "expired"]),
|
|
SubscriptionPayment.due_date < today,
|
|
)
|
|
)
|
|
overdue_payments = overdue_result.scalars().all()
|
|
|
|
for sp in overdue_payments:
|
|
try:
|
|
sub_result = await db.execute(
|
|
select(Subscription).where(
|
|
Subscription.company_id == sp.company_id
|
|
)
|
|
)
|
|
sub = sub_result.scalar_one_or_none()
|
|
if not sub or sub.status in ("cancelled", "suspended"):
|
|
continue
|
|
|
|
company_result = await db.execute(
|
|
select(Company).where(Company.id == sp.company_id)
|
|
)
|
|
company = company_result.scalar_one_or_none()
|
|
|
|
# Grace deadline passed → suspend
|
|
if sp.grace_deadline and today > sp.grace_deadline:
|
|
if sub.status != "suspended":
|
|
sub.status = "suspended"
|
|
sub.updated_at = datetime.now(timezone.utc)
|
|
if company:
|
|
company.subscription_status = "suspended"
|
|
logger.info(
|
|
"[cardano-scheduler] company_id=%d subscription suspended"
|
|
" (grace deadline %s passed)",
|
|
sp.company_id,
|
|
sp.grace_deadline,
|
|
)
|
|
|
|
# Due date passed but within grace → mark past_due
|
|
elif sub.status == "active":
|
|
sub.status = "past_due"
|
|
sub.updated_at = datetime.now(timezone.utc)
|
|
if company:
|
|
company.subscription_status = "past_due"
|
|
logger.info(
|
|
"[cardano-scheduler] company_id=%d subscription past_due"
|
|
" (due_date %s passed)",
|
|
sp.company_id,
|
|
sp.due_date,
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.exception(
|
|
"[cardano-scheduler] Error enforcing grace period for sub_payment #%d: %s",
|
|
sp.id,
|
|
e,
|
|
)
|
|
|
|
await db.commit()
|
|
|
|
except Exception:
|
|
logger.exception("[cardano-scheduler] _enforce_grace_period job failed")
|
|
|
|
|
|
# =============================================================================
|
|
# Lifecycle
|
|
# =============================================================================
|
|
|
|
async def start_cardano_scheduler() -> None:
|
|
"""
|
|
Start the Cardano payment monitoring scheduler.
|
|
|
|
Registers five jobs:
|
|
- check_pending_payments: every 15 seconds
|
|
- reprice_expired_payments: every 60 seconds
|
|
- check_subscription_payments: every 60 seconds
|
|
- reprice_subscription_payments: every 6 hours
|
|
- enforce_grace_period: daily at 06:00 UTC
|
|
|
|
Safe to call multiple times — skips if already running.
|
|
"""
|
|
global _scheduler
|
|
|
|
if _scheduler and _scheduler.running:
|
|
logger.debug("[cardano-scheduler] Already running — skipping start")
|
|
return
|
|
|
|
_scheduler = AsyncIOScheduler()
|
|
|
|
_scheduler.add_job(
|
|
_job_check_pending,
|
|
trigger=IntervalTrigger(seconds=15),
|
|
id="cardano_check_pending",
|
|
name="Cardano: Check Pending Payments",
|
|
replace_existing=True,
|
|
max_instances=1,
|
|
coalesce=True,
|
|
)
|
|
|
|
_scheduler.add_job(
|
|
_job_reprice_expired,
|
|
trigger=IntervalTrigger(seconds=60),
|
|
id="cardano_reprice_expired",
|
|
name="Cardano: Reprice Expired Payments",
|
|
replace_existing=True,
|
|
max_instances=1,
|
|
coalesce=True,
|
|
)
|
|
|
|
_scheduler.add_job(
|
|
_job_check_subscription_payments,
|
|
trigger=IntervalTrigger(seconds=60),
|
|
id="cardano_check_sub_payments",
|
|
name="Cardano: Check Subscription Payments",
|
|
replace_existing=True,
|
|
max_instances=1,
|
|
coalesce=True,
|
|
)
|
|
|
|
_scheduler.add_job(
|
|
_job_reprice_subscription_payments,
|
|
trigger=IntervalTrigger(hours=6),
|
|
id="cardano_reprice_sub_payments",
|
|
name="Cardano: Reprice Subscription Payments",
|
|
replace_existing=True,
|
|
max_instances=1,
|
|
coalesce=True,
|
|
)
|
|
|
|
_scheduler.add_job(
|
|
_job_enforce_grace_period,
|
|
trigger=CronTrigger(hour=6, minute=0, timezone="UTC"),
|
|
id="cardano_enforce_grace",
|
|
name="Cardano: Enforce Subscription Grace Periods",
|
|
replace_existing=True,
|
|
max_instances=1,
|
|
coalesce=True,
|
|
)
|
|
|
|
_scheduler.start()
|
|
|
|
logger.info(
|
|
"[cardano-scheduler] Started — check_pending every 15s, reprice_expired every 60s,"
|
|
" check_sub_payments every 60s, reprice_sub_payments every 6h,"
|
|
" enforce_grace_period daily at 06:00 UTC"
|
|
)
|
|
|
|
|
|
async def stop_cardano_scheduler() -> None:
|
|
"""
|
|
Gracefully stop the Cardano scheduler.
|
|
|
|
Call this in the FastAPI lifespan shutdown block.
|
|
"""
|
|
global _scheduler
|
|
|
|
if _scheduler:
|
|
_scheduler.shutdown(wait=False)
|
|
_scheduler = None
|
|
logger.info("[cardano-scheduler] Stopped")
|