# cardano-checkout Merchant-side Cardano payment lifecycle for Python. Zero-custody. `cardano-checkout` ships the invoice state machine, an on-chain UTxO watcher, and a quote-reprice loop for accepting ADA payments at per-invoice, HD-derived receive addresses. It polls [Koios](https://koios.rest) for payment, classifies each invoice as confirmed / underpaid / overpaid within a tolerance, and reprices expired quotes against a price oracle you supply. It does **not** reimplement Cardano primitives. Address derivation, chain context, transaction building, native-script minting, and signing are all [pycardano](https://github.com/Python-Cardano/pycardano)'s job — this library slots in next to it and owns only the merchant payment lifecycle. ## Why Accepting on-chain payments for a shop is mostly bookkeeping, not cryptography: - one fresh receive address per order (derived from your wallet xpub), - watch the chain until the expected amount lands, - decide confirmed / underpaid / overpaid within a tolerance, - if the quote window lapses before payment, reprice and try again. `cardano-checkout` is that bookkeeping, behind small consumer-supplied interfaces — a persistence `Protocol` and a pricing callable — so it drops into any stack: SQLAlchemy, asyncpg, SQLite, or plain in-memory. ## Install ``` pip install cardano-checkout # core pip install 'cardano-checkout[sqlalchemy]' # + SQLAlchemy extra ``` Two runtime dependencies: `httpx` (Koios HTTP) and `apscheduler` (background loop). There is **no** `pycardano` dependency — see [Deriving addresses with pycardano](#deriving-addresses-with-pycardano) for the pairing pattern. ## Quick start ```python import asyncio from datetime import datetime, timedelta, timezone from cardano_checkout import ( Invoice, InvoiceStatus, InMemoryStore, InvoiceScheduler, ) # Your oracle. Anything async returning int lovelace works. async def my_price_fn(usd: float) -> int: rate = await fetch_ada_usd_somewhere() # CoinGecko, a DEX, a fixed rate, etc. return int(round(usd / rate * 1_000_000)) async def main() -> None: store = InMemoryStore() # swap for your SQLAlchemy / asyncpg / sqlite adapter invoice = Invoice( id="ord-0042", merchant_id="my-shop", derivation_index=42, receive_address="addr1q...", # derive via pycardano expected_lovelace=5_000_000, usd_amount=2.50, expires_at=datetime.now(timezone.utc) + timedelta(minutes=15), ) await store.create(invoice) scheduler = InvoiceScheduler(store=store, price_fn=my_price_fn) await scheduler.start() # ... app runs ... await scheduler.stop() asyncio.run(main()) ``` If you price your invoices in fixed ADA you can omit `price_fn` — the reprice job becomes a no-op and invoices simply expire at `expires_at`. ## Deriving addresses with pycardano The receive address for each invoice is derived from your **account-level xpub** (a public key — not a secret). Customer funds flow directly to your wallet; this library never touches keys. ```python from pycardano import HDWallet, Address, Network # Account-level xpub — public, not a secret. xpub_hex = "..." account = HDWallet.from_xpub(bytes.fromhex(xpub_hex)) def derive_address(account: HDWallet, index: int, network=Network.MAINNET) -> str: payment = account.derive(0).derive(index) # external chain, address index staking = account.derive(2).derive(0) # staking chain, always index 0 addr = Address( payment_part=payment.public_key.hash(), staking_part=staking.public_key.hash(), network=network, ) return str(addr) addr = derive_address(account, index=42) ``` ## NFT cert: CIP-25 v2 metadata Need a certificate-of-authenticity NFT per paid order? Here is a dependency-free builder for the CIP-25 v2 metadata envelope. Hand the result to pycardano's `AuxiliaryData(Metadata({...}))` when you build the mint transaction. ```python def build_cip25_metadata( *, policy_id: str, asset_name: str, name: str, image_cid: str, description: str = "", media_type: str = "image/jpeg", properties: dict | None = None, ) -> dict: """Build a CIP-25 v2 metadata envelope. Returns a dict ready to submit as transaction metadatum label 721. Handles the 64-char chunking rule for long descriptions. """ def chunk64(s: str) -> list[str]: if len(s) <= 64: return [s] return [s[i:i + 64] for i in range(0, len(s), 64)] body: dict = { "name": name, "image": f"ipfs://{image_cid}", "mediaType": media_type, } if description: body["description"] = description if len(description) <= 64 else chunk64(description) if properties: body.update(properties) return { "721": { policy_id: {asset_name: body}, "version": "2.0", } } ``` ## Implementing your own InvoiceStore `InvoiceStore` is a `Protocol` — implement six methods against whatever backend you want (SQLAlchemy, asyncpg, SQLite, in-memory). ```python from cardano_checkout import Invoice, InvoiceStatus, InvoiceStore class MySqliteStore: async def create(self, invoice: Invoice) -> None: ... async def get(self, invoice_id: str) -> Invoice | None: ... async def list_by_status(self, status: InvoiceStatus, limit: int = 100) -> list[Invoice]: ... async def update(self, invoice: Invoice) -> None: ... async def next_derivation_index(self, merchant_id: str) -> int: ... async def record_tx(self, invoice_id: str, tx_hash: str, lovelace_delta: int) -> None: ... ``` See `InMemoryStore` in `cardano_checkout/store.py` for a reference implementation (it also backs the test suite). ## Modules | Module | Purpose | |---|---| | `invoice.py` | `Invoice` dataclass + `InvoiceStatus` enum | | `store.py` | `InvoiceStore` Protocol + `InMemoryStore` reference impl | | `monitor.py` | `check_address_utxos` (Koios), `evaluate_utxos`, `check_pending_invoices`, `reprice_expired_invoices` | | `scheduler.py` | `InvoiceScheduler` — APScheduler wrapper, 15s check + 60s reprice | ## Design 1. **Protocol-first.** Persistence, pricing, and side-effects all go through consumer-supplied interfaces. 2. **Use pycardano directly.** No wrapping of Cardano primitives. 3. **Zero-custody.** Merchant keys never touch this code. xpub-derived addresses, UTxO observation, state transitions. Funds flow directly between customer and merchant wallets. 4. **Offline-first tests.** Koios and price oracles are stubbed via fixtures — the suite never touches a live node. ## Contributing Issues and pull requests are welcome. A couple of house rules keep the library focused: - **Keep Cardano primitives out.** Anything pycardano already does belongs in the consumer, not here. - **Tests stay offline.** Koios and any price oracle must be stubbed via fixtures so CI never hits a live node or a real wallet. Run the suite before opening a PR: ``` pip install -e '.[test]' pytest ``` ## License AGPL-3.0-or-later. See [LICENSE](LICENSE).