cardano-api: strip 'Fix #N:' audit-ticket prefixes from inline comments (was 50+ in main.py), drop hardening-pass changelog blocks from module docstring, rewrite README to drop deploy paths + marketing sections, keep tier/auth/TTL + policy IDs. cardano-checkout-py: drop TradeCraft lineage refs, swap chromaticcraft/tradecraft test fixtures for acme/globex, repository URL → git.sulkta.com.
167 lines
5 KiB
Markdown
167 lines
5 KiB
Markdown
# cardano-checkout
|
|
|
|
Merchant-side Cardano payment lifecycle in Python. Zero-custody.
|
|
|
|
Ships 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.
|
|
|
|
Does NOT ship Cardano primitives. Address derivation, chain context,
|
|
transaction building, native-script minting, signing — use
|
|
[pycardano](https://github.com/Python-Cardano/pycardano) directly.
|
|
This library slots next to it.
|
|
|
|
## 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, 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
|
|
|
|
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())
|
|
```
|
|
|
|
## Deriving addresses with pycardano
|
|
|
|
```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
|
|
|
|
Copy-paste builder for an on-chain cert per paid order. No dep.
|
|
|
|
```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 the dict to pycardano's `AuxiliaryData(Metadata({...}))` when
|
|
building the mint tx.
|
|
|
|
## 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 impl.
|
|
|
|
## 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 |
|
|
|
|
Two direct deps: `httpx`, `apscheduler`. No pycardano dep.
|
|
|
|
## Design
|
|
|
|
1. **Protocol-first.** Persistence, pricing, side-effects through
|
|
consumer-supplied interfaces.
|
|
2. **Use pycardano directly.** No wrapping of 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 + price oracles stubbed via fixture.
|
|
|
|
## License
|
|
|
|
Apache-2.0.
|