v1.0.0-dev: slim to the real product — merchant state machine only
This commit is contained in:
parent
c31518309d
commit
b4a81e0ab8
15 changed files with 286 additions and 2503 deletions
329
README.md
329
README.md
|
|
@ -1,220 +1,209 @@
|
|||
# cardano-checkout
|
||||
|
||||
Python SDK for merchant-side Cardano payments + NFT certificate-of-authenticity minting.
|
||||
Merchant-side Cardano payment lifecycle in Python. Zero-custody by design.
|
||||
|
||||
**Zero-custody by design:** the merchant provides a wallet xpub. The SDK derives
|
||||
unique receive addresses per invoice, polls the chain for payment, and optionally
|
||||
mints a CIP-25 NFT cert on confirmation. The platform never holds or moves funds.
|
||||
**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.
|
||||
|
||||
Extracted from [the host app](https://git.sulkta.com/example/host-app)'s
|
||||
`services/cardano_*.py` modules (2,400+ lines of production code running on the
|
||||
Cardano mainnet) and packaged for reuse across the Sulkta Coop product family.
|
||||
**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.
|
||||
|
||||
## Status
|
||||
## Why this exists
|
||||
|
||||
**v0.2.0-dev — Protocol-first core + live mint path.** Monitor and scheduler
|
||||
have been refactored off SQLAlchemy and onto the `InvoiceStore` Protocol.
|
||||
Mint builds real transaction bodies against a local Ogmios endpoint and
|
||||
returns an `UnsignedMint` for cold-signing.
|
||||
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:
|
||||
|
||||
| Module | Status | Notes |
|
||||
|---|---|---|
|
||||
| `addresses` | ✅ stable | CIP-1852 HD derivation via pycardano `HDWallet` soft derive |
|
||||
| `oracles` | ✅ stable | ADA/USD price via CoinGecko + DexHunter, 5-min cache |
|
||||
| `invoice` + `store` | ✅ stable | Framework-agnostic invoice + `InMemoryStore` reference impl |
|
||||
| `mint` | ✅ v0.2 | CIP-25 v2 metadata + real tx body → `UnsignedMint` bundle |
|
||||
| `ipfs` | ✅ stable | kubo HTTP API client w/ optional mirror-pin |
|
||||
| `monitor` | ✅ v0.2 | Operates purely through `InvoiceStore` — no ORM coupling |
|
||||
| `scheduler` | ✅ v0.2 | `InvoiceScheduler` drives check + reprice against the store |
|
||||
| `hostapp_compat` | 🟡 compat shim | Keeps the host app's subscription + grace-period jobs alive during migration |
|
||||
| `txbuild` | ✅ v0.2 | OgmiosChainContext wiring + submit_signed_tx + address UTxO queries |
|
||||
- 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
|
||||
|
||||
**Migration status for the host app:** still imports the old module paths. See
|
||||
the [v0.2 migration guide](#v02-migration-guide-for-hostapp) below.
|
||||
|
||||
## Design
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────┐
|
||||
│ Merchant App │
|
||||
│ (the host app / example-studio / your-product) │
|
||||
└──────────────┬───────────────────────┬─────────────────┘
|
||||
│ │
|
||||
uses │ implements │ imports
|
||||
▼ ▼
|
||||
┌──────────────┐ ┌────────────────────────┐
|
||||
│ InvoiceStore │ ◄────── │ cardano_checkout SDK │
|
||||
│ (your DB) │ │ │
|
||||
└──────────────┘ │ addresses ← pure │
|
||||
│ oracles ← pure │
|
||||
│ invoice ← dataclass │
|
||||
│ store ← Protocol + InMemoryStore │
|
||||
│ monitor ← polls chain via store │
|
||||
│ scheduler ← bg loop │
|
||||
│ mint ← NFT cert (cold-signer) │
|
||||
│ ipfs ← upload │
|
||||
│ txbuild ← Ogmios wrappers │
|
||||
└────────────────────────┘
|
||||
│
|
||||
talks to │
|
||||
▼
|
||||
┌────────────────────────┐
|
||||
│ Koios + Ogmios + kubo │
|
||||
└────────────────────────┘
|
||||
```
|
||||
|
||||
The merchant app provides:
|
||||
|
||||
1. A wallet xpub (account-level extended public key).
|
||||
2. An `InvoiceStore` implementation (SQLAlchemy, Postgres, SQLite, in-memory — whatever).
|
||||
|
||||
The SDK provides:
|
||||
|
||||
1. Address derivation from the xpub.
|
||||
2. Per-invoice payment monitoring against Koios.
|
||||
3. ADA ↔ USD price conversion.
|
||||
4. CIP-25 v2 NFT cert minting with a cold-signer hand-off.
|
||||
5. IPFS upload + pinning for NFT image metadata.
|
||||
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 cardano_checkout import addresses, oracles
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
# Derive a receive address for invoice #42
|
||||
addr = addresses.derive_address(
|
||||
xpub_hex="<your wallet xpub>",
|
||||
index=42,
|
||||
network="mainnet",
|
||||
from cardano_checkout import (
|
||||
Invoice, InvoiceStatus, InMemoryStore, InvoiceScheduler,
|
||||
)
|
||||
|
||||
# Convert a USD price to lovelace at current market
|
||||
|
||||
# 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:
|
||||
lovelace = await oracles.convert_usd_to_lovelace(99.00)
|
||||
ada = lovelace / 1_000_000
|
||||
print(f"Customer owes {ada:.4f} ADA for $99")
|
||||
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="example-studio",
|
||||
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())
|
||||
```
|
||||
|
||||
## Payment monitoring
|
||||
## Deriving addresses with pycardano
|
||||
|
||||
We used to wrap this. You don't need the wrapper.
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
from cardano_checkout import InMemoryStore, Invoice, InvoiceStatus, InvoiceScheduler
|
||||
from pycardano import HDWallet, Address, Network
|
||||
|
||||
store = InMemoryStore() # swap for your real SQLAlchemy / asyncpg / SQLite adapter
|
||||
# Your merchant's account-level xpub — the xpub is public, not a secret.
|
||||
xpub_hex = "..."
|
||||
|
||||
# Create an invoice (typically you'd derive the address here via addresses.derive_address)
|
||||
invoice = Invoice(
|
||||
id="ord-0042",
|
||||
merchant_id="example-studio",
|
||||
derivation_index=42,
|
||||
receive_address="addr1q...",
|
||||
expected_lovelace=5_000_000,
|
||||
usd_amount=2.50,
|
||||
)
|
||||
asyncio.run(store.create(invoice))
|
||||
account = HDWallet.from_xpub(bytes.fromhex(xpub_hex))
|
||||
|
||||
# Wire the background scheduler — same 15s check / 60s reprice cadence as the host app.
|
||||
scheduler = InvoiceScheduler(store=store)
|
||||
asyncio.run(scheduler.start())
|
||||
# ... your app runs ...
|
||||
asyncio.run(scheduler.stop())
|
||||
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)
|
||||
```
|
||||
|
||||
## IPFS: bake-then-mirror pattern
|
||||
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.
|
||||
|
||||
The SDK's `IPFSClient` expects a local kubo daemon (typically in the same
|
||||
Docker image as the web app) for upload and primary pin, and takes an
|
||||
optional list of mirror endpoints to `pin add` the CID on a second node
|
||||
for archival redundancy.
|
||||
## NFT cert-of-authenticity: CIP-25 v2 metadata
|
||||
|
||||
Typical example-studio deployment:
|
||||
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
|
||||
from cardano_checkout import ipfs
|
||||
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.
|
||||
|
||||
client = ipfs.IPFSClient(
|
||||
api_url="http://127.0.0.1:5001", # local kubo in the same container
|
||||
mirror_api_urls=["http://mirror-node.example:5001"], # the cold host's kubo over the LAN/VPN
|
||||
)
|
||||
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)]
|
||||
|
||||
cid = await client.add(photo_bytes, filename="order-0001.jpg")
|
||||
# Image now served by the hot host (low latency) AND pinned on the cold host (durability)
|
||||
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",
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## NFT cert-of-authenticity design
|
||||
Hand that dict to pycardano's `AuxiliaryData(Metadata({...}))` when you
|
||||
build the mint tx. Straight pycardano from there on.
|
||||
|
||||
One minting policy per merchant studio. Policy is a native script (no Plutus
|
||||
required), optionally time-locked to make "no more editions after X" a
|
||||
cryptographically verifiable claim.
|
||||
## Implementing your own InvoiceStore
|
||||
|
||||
CIP-25 v2 metadata. Single NFT per order. Policy skey never leaves the custody
|
||||
host (the cold host in Sulkta's pattern — 2-of-2 native script: signer 1 + signer 2). The SDK
|
||||
builds the metadata envelope + tx body on the hot node and returns an
|
||||
`UnsignedMint` bundle; an external offline signer provides the vkey witnesses;
|
||||
the hot node submits the assembled CBOR.
|
||||
The SDK's `InvoiceStore` is a Protocol — implement the six methods
|
||||
against whatever backend you want (SQLAlchemy, asyncpg, SQLite,
|
||||
in-memory for tests).
|
||||
|
||||
The full operator runbook — including the exact byte-movement sequence,
|
||||
verification checklist, and preprod dry-run procedure — lives in
|
||||
[`docs/minting-workflow.md`](docs/minting-workflow.md).
|
||||
```python
|
||||
from cardano_checkout import Invoice, InvoiceStatus, InvoiceStore
|
||||
|
||||
## v0.2 migration guide for the host app
|
||||
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: ...
|
||||
```
|
||||
|
||||
The generic invoice jobs moved to a Protocol-based API. The
|
||||
subscription + grace-period jobs stayed the host app-specific and live in
|
||||
`cardano_checkout.hostapp_compat`.
|
||||
See `InMemoryStore` in `cardano_checkout/store.py` for a 90-line
|
||||
reference implementation.
|
||||
|
||||
**Import changes when the host app adopts the SDK:**
|
||||
## Status (1.0.0-dev)
|
||||
|
||||
| Was | Becomes |
|
||||
| Module | Purpose |
|
||||
|---|---|
|
||||
| `from services.cardano_monitor import check_pending_payments, reprice_expired_payments` | `from cardano_checkout.monitor import check_pending_invoices, reprice_expired_invoices` |
|
||||
| `from services.cardano_monitor import _check_address_utxos, _evaluate_payment` | `from cardano_checkout.monitor import check_address_utxos, evaluate_utxos` (or import from `hostapp_compat` for the exact old names) |
|
||||
| `from services.cardano_scheduler import start_cardano_scheduler, stop_cardano_scheduler` | `from cardano_checkout.scheduler import InvoiceScheduler` (instantiate with your store) |
|
||||
| `from services.cardano_scheduler import _check_subscription_payments, _reprice_subscription_payments, _enforce_grace_period` | `from cardano_checkout.hostapp_compat import check_subscription_payments, reprice_subscription_payments, enforce_grace_period` (verbatim jobs — the host app still drives them directly) |
|
||||
| `from services.cardano_price import *` | `from cardano_checkout.oracles import *` |
|
||||
| `from services.cardano_addresses import derive_address` | `from cardano_checkout.addresses import derive_address` |
|
||||
| `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 the host app's used in production since 2025 |
|
||||
|
||||
**What the host app still needs to write:**
|
||||
All tests offline, 26/26 green. Two direct deps: `httpx` (Koios calls),
|
||||
`apscheduler` (background scheduling). No pycardano dep — that's the
|
||||
consumer's pairing.
|
||||
|
||||
A SQLAlchemy adapter implementing `InvoiceStore` against the existing
|
||||
`CardanoPayment` table. `list_by_status` maps to a `SELECT ... WHERE status = :s`,
|
||||
`next_derivation_index` to a `SELECT MAX(derivation_index) + 1`, etc.
|
||||
That's 80-ish lines of wrapper code — nothing exotic. Once that's
|
||||
landed, the generic jobs run through `InvoiceScheduler(store=SQLAlchemyInvoiceStore(...))`
|
||||
and the subscription jobs keep running through `hostapp_compat` unchanged.
|
||||
## Design principles
|
||||
|
||||
**TODO for future sprints:**
|
||||
|
||||
- Ship a `cardano_checkout.adapters.sqlalchemy.SQLAlchemyInvoiceStore` so
|
||||
the host app doesn't have to write the adapter from scratch.
|
||||
- Once the host app's subscription jobs are migrated to a subscription-
|
||||
specific Protocol, delete `hostapp_compat`.
|
||||
- Refund-path `build_payment_tx` in `txbuild.py` (v0.3).
|
||||
- Batched mints (sell-sheet of 10 NFTs at once).
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
pip install -e '.[test]'
|
||||
pytest # 42 tests, all offline
|
||||
```
|
||||
|
||||
The test suite mocks the chain context for mint-tx construction and
|
||||
monkey-patches Koios + the oracle for monitor tests — CI never touches
|
||||
a live node. The address-derivation tests use a deterministic test-vector
|
||||
xpub from the standard "test ... junk" mnemonic so they can't drift.
|
||||
|
||||
## Installation
|
||||
|
||||
```
|
||||
pip install 'cardano-checkout[sqlalchemy]' # if you're using SQLAlchemy
|
||||
pip install cardano-checkout # core only
|
||||
```
|
||||
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 upstream Cardano tooling.
|
||||
Apache-2.0 — matches the broader Cardano tooling ecosystem.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue