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
102 lines
4 KiB
Python
102 lines
4 KiB
Python
"""Invoice state machine for Cardano-native merchant payments.
|
|
|
|
An Invoice represents one payment intent: a unique receive address
|
|
derived from the merchant's xpub, an expected amount in lovelace, a
|
|
USD-denominated label, and a lifecycle state that transitions as the
|
|
chain confirms payment.
|
|
|
|
The Invoice is deliberately framework-agnostic — persistence is
|
|
delegated to an :class:`InvoiceStore` (see :mod:`cardano_checkout.store`).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass, field
|
|
from datetime import datetime, timezone
|
|
from enum import Enum
|
|
from typing import Optional
|
|
|
|
|
|
class InvoiceStatus(str, Enum):
|
|
"""Lifecycle states for a Cardano checkout invoice.
|
|
|
|
Valid transitions::
|
|
|
|
PENDING ──► MATCHED ──► CONFIRMED
|
|
│ │
|
|
│ └──► UNDERPAID
|
|
│ └──► OVERPAID (still moves to CONFIRMED but flagged)
|
|
│
|
|
├──► EXPIRED (no payment within window)
|
|
└──► CANCELLED (merchant-initiated)
|
|
|
|
CONFIRMED is terminal success; EXPIRED / CANCELLED are terminal failures.
|
|
UNDERPAID is recoverable if the customer sends the delta.
|
|
"""
|
|
|
|
PENDING = "pending"
|
|
MATCHED = "matched" # at least one UTxO landed, not yet k-confirmed
|
|
CONFIRMED = "confirmed" # enough confirmations, payment final
|
|
UNDERPAID = "underpaid" # delta < expected by more than tolerance
|
|
OVERPAID = "overpaid" # delta > expected by more than tolerance (non-fatal)
|
|
EXPIRED = "expired"
|
|
CANCELLED = "cancelled"
|
|
|
|
|
|
@dataclass
|
|
class Invoice:
|
|
"""One Cardano payment intent.
|
|
|
|
Attributes:
|
|
id: Stable per-merchant identifier (UUID or monotonic; caller's choice).
|
|
merchant_id: Opaque merchant namespace — the SDK never interprets it.
|
|
derivation_index: BIP-44 receive-chain index used to derive receive_address.
|
|
receive_address: Bech32 address the customer pays to. Derived from the
|
|
merchant's xpub at ``derivation_index``.
|
|
expected_lovelace: Target amount. Set at creation time from the USD ↔ ADA
|
|
oracle snapshot; does NOT float with market price once set.
|
|
usd_amount: Human-readable label for what the customer owes.
|
|
status: Current lifecycle state. See :class:`InvoiceStatus`.
|
|
created_at: UTC timestamp when the invoice was created.
|
|
expires_at: UTC timestamp when this invoice stops accepting payment.
|
|
Typically created_at + 15 minutes for live-price quotes.
|
|
tx_hashes: All observed inbound tx hashes. Empty until first UTxO lands.
|
|
received_lovelace: Sum of inbound UTxOs at ``receive_address``.
|
|
confirmed_at: UTC timestamp when status became CONFIRMED.
|
|
metadata: Arbitrary merchant-defined payload (order id, sku, etc.).
|
|
"""
|
|
|
|
id: str
|
|
merchant_id: str
|
|
derivation_index: int
|
|
receive_address: str
|
|
expected_lovelace: int
|
|
usd_amount: float
|
|
status: InvoiceStatus = InvoiceStatus.PENDING
|
|
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
|
expires_at: Optional[datetime] = None
|
|
tx_hashes: list[str] = field(default_factory=list)
|
|
received_lovelace: int = 0
|
|
confirmed_at: Optional[datetime] = None
|
|
metadata: dict = field(default_factory=dict)
|
|
|
|
@property
|
|
def ada_amount(self) -> float:
|
|
"""Expected amount in ADA (lovelace / 1_000_000)."""
|
|
return self.expected_lovelace / 1_000_000
|
|
|
|
@property
|
|
def is_terminal(self) -> bool:
|
|
"""True if the invoice has reached a state it can't leave."""
|
|
return self.status in {
|
|
InvoiceStatus.CONFIRMED,
|
|
InvoiceStatus.EXPIRED,
|
|
InvoiceStatus.CANCELLED,
|
|
}
|
|
|
|
def is_expired(self, now: Optional[datetime] = None) -> bool:
|
|
"""True if wall-clock is past expires_at and status is still PENDING."""
|
|
if self.expires_at is None:
|
|
return False
|
|
current = now or datetime.now(timezone.utc)
|
|
return current >= self.expires_at and self.status == InvoiceStatus.PENDING
|