Python SDK for merchant-side Cardano payments + NFT cert minting (zero-custody)
Find a file
Sulkta 9620d1278e
Some checks failed
gitleaks / scan (push) Failing after 1s
license: relicense to AGPL-3.0-or-later
2026-06-28 21:16:17 -07:00
.forgejo/workflows ci: add gitleaks workflow (Sulkta canonical) 2026-05-27 22:14:35 -07:00
cardano_checkout Cleanup: remove internal references and scaffolding 2026-05-27 11:15:03 -07:00
tests Cleanup: remove internal references and scaffolding 2026-05-27 11:15:03 -07:00
.gitignore v0.1.0-dev: initial public extraction + new abstractions 2026-04-23 18:04:00 -07:00
LICENSE license: relicense to AGPL-3.0-or-later 2026-06-28 21:16:17 -07:00
pyproject.toml license: relicense to AGPL-3.0-or-later 2026-06-28 21:16:17 -07:00
README.md license: relicense to AGPL-3.0-or-later 2026-06-28 21:16:17 -07:00

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 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'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 for the pairing pattern.

Quick start

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.

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.

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).

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.