Drop everything that duplicates PyCardano. The landscape scan done
2026-04-23 confirmed: no ecosystem gap for wallet/chain/tx-build —
pycardano 0.19.x covers all of it cleanly. The gap is the merchant
state machine, so that's all we ship.
Deleted:
- addresses.py → consumers call pycardano.HDWallet directly
- txbuild.py → consumers use pycardano.OgmiosChainContext directly
- oracles.py → consumers supply a price_fn callable
- mint.py → consumers build mint txs with pycardano;
CIP-25 v2 metadata builder shipped as a copy-paste
snippet in the README
- ipfs.py → py-ipfs-http-client covers it
- tradecraft_compat.py → no one was importing it; kill
- docs/minting-workflow.md → redundant with README pairing guidance
Refactored:
- monitor.evaluate_utxos: ADA-only. The DexHunter token-equivalent
block came out. Consumers who want stablecoin support wrap the
function with their own asset-to-lovelace converter.
- monitor.reprice_expired_invoices: takes a new required kwarg
price_fn: Callable[[float], Awaitable[int]]. No more ADA/USD
oracle shipped in the SDK.
- scheduler.InvoiceScheduler: takes an optional price_fn field;
if None, the reprice job is a no-op (works for fixed-ADA invoices).
Tests (26/26 passing, all offline):
- test_invoice.py — state-machine helpers, 3 tests
- test_store_protocol.py — Protocol conformance + InMemoryStore round-trips, 13 tests
- test_monitor_with_inmemory_store.py — all status transitions + reprice,
rewired to pass price_fn fixtures instead of monkeypatching oracle funcs
Deps dropped: pycardano (consumer pairing, not our dep).
Deps kept: httpx (Koios), apscheduler (background scheduler).
Package shape (1.0.0-dev, ~700 LOC src):
cardano_checkout/
invoice.py — Invoice + InvoiceStatus
store.py — InvoiceStore Protocol + InMemoryStore
monitor.py — Koios poll + UTxO matching + reprice driver
scheduler.py — APScheduler wrapper
README rewritten top-to-bottom: "what we ship", "what we don't ship",
why the niche exists, pycardano-directly examples for the delete-list,
CIP-25 builder as a 20-line copy-paste, InvoiceStore implementation
example. Apache-2.0 license unchanged.
209 lines
7.4 KiB
Markdown
209 lines
7.4 KiB
Markdown
# cardano-checkout
|
|
|
|
Merchant-side Cardano payment lifecycle in Python. Zero-custody by design.
|
|
|
|
**What we ship:** the invoice state machine + UTxO watcher + reprice
|
|
loop. Per-invoice HD-derived receive addresses, Koios polling, confirm
|
|
/ underpay / overpay classification, time-windowed repricing against
|
|
your own oracle.
|
|
|
|
**What we don't ship:** Cardano primitives. Address derivation, chain
|
|
context, transaction building, native-script minting, signing — those
|
|
are all [pycardano](https://github.com/Python-Cardano/pycardano)'s job.
|
|
pycardano is mature, actively maintained (0.19.x as of 2026), and
|
|
covers every primitive cleanly. This library slots next to it — no
|
|
wrapping, no leaky abstraction, no second API to learn.
|
|
|
|
## Why this exists
|
|
|
|
Nothing else in the Python ecosystem (or any ecosystem — we checked)
|
|
packages zero-custody merchant Cardano payments as a reusable library.
|
|
Closest adjacents are all one of:
|
|
|
|
- CIP-30 browser-wallet plugins (customer-signs, not server-watches)
|
|
- cardano-cli vending machines that watch a single static address (no
|
|
xpub, no per-invoice derivation)
|
|
- SaaS APIs (NMKR) — not libraries
|
|
- Dormant / pre-1.0 grabs from the 2021-2023 era
|
|
|
|
The merchant state machine — "derive an address, watch for payment,
|
|
confirm within tolerance, reprice if the quote lapses, emit a confirmed
|
|
callback" — is what we package. You keep full control of everything
|
|
else by using pycardano directly.
|
|
|
|
## Quick start
|
|
|
|
```python
|
|
import asyncio
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
from cardano_checkout import (
|
|
Invoice, InvoiceStatus, InMemoryStore, InvoiceScheduler,
|
|
)
|
|
|
|
|
|
# Your oracle — we don't ship one. Anything async returning int lovelace works.
|
|
async def my_price_fn(usd: float) -> int:
|
|
rate = await fetch_ada_usd_somewhere() # CoinGecko, Koios, fixed rate, etc.
|
|
return int(round(usd / rate * 1_000_000))
|
|
|
|
|
|
async def main() -> None:
|
|
store = InMemoryStore() # swap for your SQLAlchemy / asyncpg / sqlite adapter
|
|
|
|
# Create an invoice. In production you'd derive the receive address from
|
|
# your wallet xpub via pycardano — see the "Deriving addresses" section.
|
|
invoice = Invoice(
|
|
id="ord-0042",
|
|
merchant_id="chromaticcraft",
|
|
derivation_index=42,
|
|
receive_address="addr1q...", # derived via pycardano — your code
|
|
expected_lovelace=5_000_000,
|
|
usd_amount=2.50,
|
|
expires_at=datetime.now(timezone.utc) + timedelta(minutes=15),
|
|
)
|
|
await store.create(invoice)
|
|
|
|
# Run the background scheduler — Koios poll every 15s + reprice every 60s.
|
|
scheduler = InvoiceScheduler(store=store, price_fn=my_price_fn)
|
|
await scheduler.start()
|
|
|
|
# ... app runs ...
|
|
|
|
await scheduler.stop()
|
|
|
|
asyncio.run(main())
|
|
```
|
|
|
|
## Deriving addresses with pycardano
|
|
|
|
We used to wrap this. You don't need the wrapper.
|
|
|
|
```python
|
|
from pycardano import HDWallet, Address, Network
|
|
|
|
# Your merchant's account-level xpub — the xpub is 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)
|
|
```
|
|
|
|
Six lines of pycardano that read cleanly against
|
|
[their docs](https://pycardano.readthedocs.io/). Our old wrapper would
|
|
have added one function call but made you learn our API instead of
|
|
pycardano's. Skip it.
|
|
|
|
## NFT cert-of-authenticity: CIP-25 v2 metadata
|
|
|
|
If you want each paid order to ship with an on-chain NFT cert, here's
|
|
the CIP-25 v2 metadata builder as a copy-paste. It fits in your own
|
|
code — no dep, no wrapper.
|
|
|
|
```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",
|
|
}
|
|
}
|
|
```
|
|
|
|
Hand that dict to pycardano's `AuxiliaryData(Metadata({...}))` when you
|
|
build the mint tx. Straight pycardano from there on.
|
|
|
|
## Implementing your own InvoiceStore
|
|
|
|
The SDK's `InvoiceStore` is a Protocol — implement the six methods
|
|
against whatever backend you want (SQLAlchemy, asyncpg, SQLite,
|
|
in-memory for tests).
|
|
|
|
```python
|
|
from cardano_checkout import Invoice, InvoiceStatus, InvoiceStore
|
|
|
|
class MySqliteStore:
|
|
# Implement these six methods and you're a valid InvoiceStore.
|
|
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 90-line
|
|
reference implementation.
|
|
|
|
## Status (1.0.0-dev)
|
|
|
|
| Module | Purpose |
|
|
|---|---|
|
|
| `invoice.py` | `Invoice` dataclass + `InvoiceStatus` enum — payment lifecycle states |
|
|
| `store.py` | `InvoiceStore` Protocol + `InMemoryStore` reference impl |
|
|
| `monitor.py` | `check_address_utxos` (Koios), `evaluate_utxos` (ADA matching + tolerance), `check_pending_invoices`, `reprice_expired_invoices` (takes your `price_fn`) |
|
|
| `scheduler.py` | `InvoiceScheduler` — APScheduler wrapper, runs check/reprice on the same 15s/60s cadence TradeCraft's used in production since 2025 |
|
|
|
|
All tests offline, 26/26 green. Two direct deps: `httpx` (Koios calls),
|
|
`apscheduler` (background scheduling). No pycardano dep — that's the
|
|
consumer's pairing.
|
|
|
|
## Design principles
|
|
|
|
1. **Protocol-first.** Persistence, pricing, and any other side-effect
|
|
concern goes through a consumer-supplied interface. The SDK has no
|
|
opinion about your database, your oracle, or your ORM.
|
|
2. **Use pycardano directly.** We don't wrap primitives. If you need
|
|
address derivation, chain context, or transaction building, import
|
|
pycardano. Our package sits next to it, not on top.
|
|
3. **Zero-custody.** The merchant's keys never touch this code. We
|
|
handle xpub-derived addresses (public), UTxO observation (chain),
|
|
and state transitions (the store). Funds flow directly between
|
|
customer and merchant wallets. We are not a custodian.
|
|
4. **Offline-first tests.** Koios HTTP and price oracles are stubbed
|
|
or swapped via fixture. No network in CI. Live tests (preprod mint
|
|
round-trips, real Koios) are a consumer-side concern.
|
|
|
|
## License
|
|
|
|
Apache-2.0 — matches the broader Cardano tooling ecosystem.
|