|
|
||
|---|---|---|
| .forgejo/workflows | ||
| cardano_checkout | ||
| tests | ||
| .gitignore | ||
| LICENSE | ||
| pyproject.toml | ||
| README.md | ||
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
- Protocol-first. Persistence, pricing, and side-effects all go through consumer-supplied interfaces.
- Use pycardano directly. No wrapping of Cardano primitives.
- Zero-custody. Merchant keys never touch this code. xpub-derived addresses, UTxO observation, state transitions. Funds flow directly between customer and merchant wallets.
- 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.