v0.2: README rewrite + docs/minting-workflow.md cold-signer runbook
README status table moves everything green except the TradeCraft compat shim (still yellow, documented sunset path). Adds a migration guide section mapping every old services/cardano_*.py import to its new cardano_checkout.* equivalent so TradeCraft can adopt in one atomic diff once the SQLAlchemyInvoiceStore adapter lands. docs/minting-workflow.md: step-by-step runbook for the cold-signer pattern — hot host builds UnsignedMint, operator ships three CBOR hex files to Lucy, offline signer produces a signed tx, hot host submits via submit_signed_tx. Covers the tx-id sanity check, skey hygiene rules, time-locked-policy TTL clamp, and the preprod dry-run requirement for every new policy.
This commit is contained in:
parent
dd435a5e2d
commit
68cb535c0f
2 changed files with 317 additions and 20 deletions
126
README.md
126
README.md
|
|
@ -12,21 +12,25 @@ Cardano mainnet) and packaged for reuse across the Sulkta Coop product family.
|
|||
|
||||
## Status
|
||||
|
||||
**v0.1.0-dev — alpha extraction.** Pure modules lifted verbatim from TradeCraft.
|
||||
DB-coupled modules (monitor, scheduler) ship with a `TODO: refactor to Store
|
||||
protocol` marker — they work as-is when paired with TradeCraft's SQLAlchemy models
|
||||
but will be refactored to the generic `InvoiceStore` Protocol in v0.2.
|
||||
**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.
|
||||
|
||||
| Module | Status | Notes |
|
||||
|---|---|---|
|
||||
| `addresses` | ✅ stable | CIP-1852 HD derivation; pure pycardano |
|
||||
| `oracles` | ✅ stable | ADA/USD price via Koios with 5-min cache |
|
||||
| `invoice` + `store` | ✅ new | Framework-agnostic invoice + persistence Protocol |
|
||||
| `mint` | ⏳ stub | CIP-25 v2 metadata builder works; tx submission in v0.2 |
|
||||
| `ipfs` | ✅ working | kubo HTTP API client w/ optional mirror-pin |
|
||||
| `monitor` | 🟡 SQLAlchemy-coupled | v0.2 target: refactor around `InvoiceStore` |
|
||||
| `scheduler` | 🟡 SQLAlchemy-coupled | v0.2 target: same |
|
||||
| `txbuild` | ❌ v0.2 | Full PyCardano tx construction via Ogmios |
|
||||
| `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 |
|
||||
| `tradecraft_compat` | 🟡 compat shim | Keeps TradeCraft's subscription + grace-period jobs alive during migration |
|
||||
| `txbuild` | ✅ v0.2 | OgmiosChainContext wiring + submit_signed_tx + address UTxO queries |
|
||||
|
||||
**Migration status for TradeCraft:** still imports the old module paths. See
|
||||
the [v0.2 migration guide](#v02-migration-guide-for-tradecraft) below.
|
||||
|
||||
## Design
|
||||
|
||||
|
|
@ -44,11 +48,12 @@ but will be refactored to the generic `InvoiceStore` Protocol in v0.2.
|
|||
└──────────────┘ │ addresses ← pure │
|
||||
│ oracles ← pure │
|
||||
│ invoice ← dataclass │
|
||||
│ monitor ← polls chain │
|
||||
│ scheduler ← bg loop │
|
||||
│ mint ← NFT cert │
|
||||
│ ipfs ← upload │
|
||||
│ txbuild ← PyCardano wrappers │
|
||||
│ store ← Protocol + InMemoryStore │
|
||||
│ monitor ← polls chain via store │
|
||||
│ scheduler ← bg loop │
|
||||
│ mint ← NFT cert (cold-signer) │
|
||||
│ ipfs ← upload │
|
||||
│ txbuild ← Ogmios wrappers │
|
||||
└────────────────────────┘
|
||||
│
|
||||
talks to │
|
||||
|
|
@ -59,14 +64,16 @@ but will be refactored to the generic `InvoiceStore` Protocol in v0.2.
|
|||
```
|
||||
|
||||
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 (v0.2).
|
||||
4. CIP-25 v2 NFT cert minting with a cold-signer hand-off.
|
||||
5. IPFS upload + pinning for NFT image metadata.
|
||||
|
||||
## Quick start
|
||||
|
|
@ -91,6 +98,32 @@ async def main() -> None:
|
|||
asyncio.run(main())
|
||||
```
|
||||
|
||||
## Payment monitoring
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
from cardano_checkout import InMemoryStore, Invoice, InvoiceStatus, InvoiceScheduler
|
||||
|
||||
store = InMemoryStore() # swap for your real SQLAlchemy / asyncpg / SQLite adapter
|
||||
|
||||
# Create an invoice (typically you'd derive the address here via addresses.derive_address)
|
||||
invoice = Invoice(
|
||||
id="ord-0042",
|
||||
merchant_id="chromaticcraft",
|
||||
derivation_index=42,
|
||||
receive_address="addr1q...",
|
||||
expected_lovelace=5_000_000,
|
||||
usd_amount=2.50,
|
||||
)
|
||||
asyncio.run(store.create(invoice))
|
||||
|
||||
# Wire the background scheduler — same 15s check / 60s reprice cadence as TradeCraft.
|
||||
scheduler = InvoiceScheduler(store=store)
|
||||
asyncio.run(scheduler.start())
|
||||
# ... your app runs ...
|
||||
asyncio.run(scheduler.stop())
|
||||
```
|
||||
|
||||
## IPFS: bake-then-mirror pattern
|
||||
|
||||
The SDK's `IPFSClient` expects a local kubo daemon (typically in the same
|
||||
|
|
@ -119,8 +152,61 @@ required), optionally time-locked to make "no more editions after X" a
|
|||
cryptographically verifiable claim.
|
||||
|
||||
CIP-25 v2 metadata. Single NFT per order. Policy skey never leaves the custody
|
||||
host (Lucy in Sulkta's pattern). The SDK builds the metadata envelope + tx;
|
||||
external signer does the signature.
|
||||
host (Lucy in Sulkta's pattern — 2-of-2 native script: Cobb + Kayos). 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 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).
|
||||
|
||||
## v0.2 migration guide for TradeCraft
|
||||
|
||||
The generic invoice jobs moved to a Protocol-based API. The
|
||||
subscription + grace-period jobs stayed TradeCraft-specific and live in
|
||||
`cardano_checkout.tradecraft_compat`.
|
||||
|
||||
**Import changes when TradeCraft adopts the SDK:**
|
||||
|
||||
| Was | Becomes |
|
||||
|---|---|
|
||||
| `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 `tradecraft_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.tradecraft_compat import check_subscription_payments, reprice_subscription_payments, enforce_grace_period` (verbatim jobs — TradeCraft 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` |
|
||||
|
||||
**What TradeCraft still needs to write:**
|
||||
|
||||
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 `tradecraft_compat` unchanged.
|
||||
|
||||
**TODO for future sprints:**
|
||||
|
||||
- Ship a `cardano_checkout.adapters.sqlalchemy.SQLAlchemyInvoiceStore` so
|
||||
TradeCraft doesn't have to write the adapter from scratch.
|
||||
- Once TradeCraft's subscription jobs are migrated to a subscription-
|
||||
specific Protocol, delete `tradecraft_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
|
||||
|
||||
|
|
|
|||
211
docs/minting-workflow.md
Normal file
211
docs/minting-workflow.md
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
# NFT Cert-of-Authenticity Minting Workflow
|
||||
|
||||
This document is the operator runbook for minting a CIP-25 v2 NFT
|
||||
certificate with `cardano-checkout`. It describes the hot/cold split,
|
||||
what each host is responsible for, and the exact sequence of bytes that
|
||||
move between them.
|
||||
|
||||
## Architectural shape
|
||||
|
||||
```
|
||||
hot host (Rackham) cold host (Lucy)
|
||||
────────────────────── ────────────────────────
|
||||
┌────────────────────┐ ┌──────────────────────┐
|
||||
│ cardano-node │ │ policy skey files │
|
||||
│ (mainnet, n2n 6000)│ │ - Cobb.skey │
|
||||
│ ogmios 127:1337 │ │ - Kayos.skey │
|
||||
└──────────┬─────────┘ └──────────┬───────────┘
|
||||
│ │
|
||||
┌──────────▼─────────┐ (1) body CBOR │
|
||||
│ cardano-checkout │ ──────────────────────► │
|
||||
│ mint_nft_cert() │ ▼
|
||||
│ returns UnsignedMint│ ┌──────────────────────┐
|
||||
└──────────┬─────────┘ │ cardano-cli / offline│
|
||||
│ │ signer: │
|
||||
│ (2) signed tx CBOR │ transaction witness │
|
||||
│ ◄───────────────────────────────┤ transaction assemble│
|
||||
┌──────────▼─────────┐ └──────────────────────┘
|
||||
│ submit_signed_tx() │
|
||||
│ context.submit_tx │
|
||||
└──────────┬─────────┘
|
||||
│
|
||||
▼
|
||||
on-chain confirmation
|
||||
```
|
||||
|
||||
Two separable boundaries:
|
||||
|
||||
1. **Chain ↔ cold.** The hot host (the one running `cardano-node` +
|
||||
`ogmios`) has *no* policy signing keys. It can observe the chain,
|
||||
build transaction bodies, and submit signed blobs — it cannot mint.
|
||||
2. **Cold ↔ operator.** The cold host holds the policy skeys and
|
||||
nothing else. No network access, no daemons. Its only job is: take
|
||||
the unsigned body CBOR, produce a witness, hand the signed CBOR
|
||||
back.
|
||||
|
||||
## Step-by-step
|
||||
|
||||
### 1. Build the unsigned tx on the hot host
|
||||
|
||||
```python
|
||||
from cardano_checkout import mint_nft_cert, build_cip25_metadata, MintPolicy
|
||||
|
||||
policy = MintPolicy(
|
||||
policy_id="<hex blake2b-224 of the native script CBOR>",
|
||||
script_cbor_hex="<hex cbor of the ScriptAll 2-of-2>",
|
||||
required_signer_hashes=[
|
||||
"<cobb_payment_vkh_hex>", # 28 bytes, hex
|
||||
"<kayos_payment_vkh_hex>",
|
||||
],
|
||||
locked_after_slot=None, # or a generous future slot if time-locked
|
||||
)
|
||||
|
||||
metadata = build_cip25_metadata(
|
||||
policy_id=policy.policy_id,
|
||||
asset_name="ChromaticCraftCert0042",
|
||||
name="Chromatic Craft Cert #0042",
|
||||
image_cid="bafybei...", # IPFS CID from IPFSClient.add()
|
||||
description="Hand-stitched custom moth pendant",
|
||||
media_type="image/png",
|
||||
properties={
|
||||
"studio": "chromaticcraft",
|
||||
"order_id": "CC-2026-0042",
|
||||
"edition": "1 of 1",
|
||||
},
|
||||
)
|
||||
|
||||
unsigned = await mint_nft_cert(
|
||||
policy=policy,
|
||||
asset_name="ChromaticCraftCert0042",
|
||||
metadata=metadata,
|
||||
recipient_address="addr1q... (customer wallet)",
|
||||
funding_address="addr1q... (chromaticcraft hot wallet)",
|
||||
ogmios_host="127.0.0.1",
|
||||
ogmios_port=1337,
|
||||
network="mainnet",
|
||||
)
|
||||
```
|
||||
|
||||
Inspect `unsigned.summary` before sending the body anywhere — it's a
|
||||
plaintext dump of what the mint is about to do. Operators should
|
||||
eyeball the recipient address, the tx_id, the policy id, and the
|
||||
required signers every single time. **This is the last chance to catch
|
||||
a wrong asset name or recipient before the chain sees it.**
|
||||
|
||||
Then materialise the three hex strings to disk:
|
||||
|
||||
```bash
|
||||
printf '%s' "$BODY_CBOR_HEX" > /tmp/mint-${TX_ID}.body.hex
|
||||
printf '%s' "$AUX_CBOR_HEX" > /tmp/mint-${TX_ID}.aux.hex
|
||||
printf '%s' "$SCRIPT_CBOR" > /tmp/mint-${TX_ID}.script.hex
|
||||
```
|
||||
|
||||
Transfer those three files to the cold host. SCP over the wireguard
|
||||
tunnel is fine. USB keys work too; QR codes work if the body is small
|
||||
enough and the threat model demands strict air-gap.
|
||||
|
||||
### 2. Sign on the cold host
|
||||
|
||||
Reassemble a full transaction on cold, compute the witness, and emit
|
||||
the signed CBOR. Using `cardano-cli`:
|
||||
|
||||
```bash
|
||||
# On the cold host, with Cobb.skey + Kayos.skey available.
|
||||
cardano-cli transaction assemble \
|
||||
--tx-body-file /tmp/mint-${TX_ID}.body.json \
|
||||
--witness-file cobb.witness \
|
||||
--witness-file kayos.witness \
|
||||
--out-file /tmp/mint-${TX_ID}.signed.json
|
||||
```
|
||||
|
||||
(Converting the `.hex` → `.json` form is `jq`-level — the SDK emits raw
|
||||
CBOR because that's the smallest byte-shape to move around, but
|
||||
`cardano-cli` expects the `{"type":"Witnessed Tx ConwayEra","cborHex":"..."}`
|
||||
envelope.)
|
||||
|
||||
A PyCardano-based offline signer can do the same thing without shelling
|
||||
out:
|
||||
|
||||
```python
|
||||
from pycardano import (
|
||||
AuxiliaryData, NativeScript, PaymentExtendedSigningKey,
|
||||
Transaction, TransactionBody, TransactionWitnessSet,
|
||||
VerificationKeyWitness,
|
||||
)
|
||||
|
||||
body = TransactionBody.from_cbor(bytes.fromhex(body_hex))
|
||||
aux = AuxiliaryData.from_cbor(bytes.fromhex(aux_hex))
|
||||
script = NativeScript.from_cbor(bytes.fromhex(script_hex))
|
||||
|
||||
# Load skeys from disk — never log or print.
|
||||
cobb_skey = PaymentExtendedSigningKey.load("./Cobb.skey")
|
||||
kayos_skey = PaymentExtendedSigningKey.load("./Kayos.skey")
|
||||
|
||||
# Each signer produces a vkey witness over the tx id.
|
||||
witnesses = [
|
||||
VerificationKeyWitness(
|
||||
vkey=skey.to_verification_key(),
|
||||
signature=skey.sign(body.hash()),
|
||||
)
|
||||
for skey in (cobb_skey, kayos_skey)
|
||||
]
|
||||
|
||||
tx = Transaction(
|
||||
transaction_body=body,
|
||||
transaction_witness_set=TransactionWitnessSet(
|
||||
vkey_witnesses=witnesses,
|
||||
native_scripts=[script],
|
||||
),
|
||||
auxiliary_data=aux,
|
||||
)
|
||||
|
||||
signed_cbor_hex = tx.to_cbor_hex()
|
||||
with open(f"/tmp/mint-{tx.id}.signed.hex", "w") as f:
|
||||
f.write(signed_cbor_hex)
|
||||
```
|
||||
|
||||
Confirm the tx id on cold matches the `unsigned.tx_id` the hot host
|
||||
reported — the body is immutable so the two must match byte-for-byte.
|
||||
If they don't, **stop**. Something rewrote the body between the two
|
||||
hosts and signing it would broadcast a tx that doesn't match what was
|
||||
intended.
|
||||
|
||||
Transfer the signed CBOR back to the hot host.
|
||||
|
||||
### 3. Submit from the hot host
|
||||
|
||||
```python
|
||||
from cardano_checkout import submit_signed_tx
|
||||
|
||||
tx_hash = submit_signed_tx(
|
||||
signed_tx_cbor_hex=signed_cbor_hex,
|
||||
ogmios_host="127.0.0.1",
|
||||
ogmios_port=1337,
|
||||
network="mainnet",
|
||||
)
|
||||
# tx_hash matches unsigned.tx_id. Track it on cardanoscan or via Koios tx_info.
|
||||
```
|
||||
|
||||
Ogmios returns immediately once the tx hits the mempool. Confirm on
|
||||
chain by polling Koios `/tx_info` with the hash — typically lands in
|
||||
the next 20-second block cycle.
|
||||
|
||||
## Security notes
|
||||
|
||||
- **Skey hygiene.** Skeys never leave the cold host. The hot host
|
||||
never sees them. If you ever find yourself about to `scp` a skey,
|
||||
stop and call it a day — there is no legitimate reason to move a
|
||||
skey file.
|
||||
- **Wipe temp files.** After submission, shred both the body and the
|
||||
signed CBOR from any shared-storage mount. `shred -u`.
|
||||
- **Time-locked policies.** If `policy.locked_after_slot` is set, the
|
||||
SDK clamps the tx TTL to stay well inside the lock window. If the
|
||||
chain tip has already passed the lock slot the node will reject the
|
||||
witness — by design; that's what the time-lock buys you.
|
||||
- **Ogmios is trusted.** The hot host trusts its local Ogmios to
|
||||
surface the real chain tip. This is fine for a merchant mint path;
|
||||
do *not* point `ogmios_host` at a third party you don't run.
|
||||
- **Dry-run against preprod.** Every new policy gets smoke-tested on
|
||||
`preprod` first. Build against a preprod xpub + preprod Ogmios, sign
|
||||
on cold, submit. When you see the cert land in a preprod wallet,
|
||||
flip over to mainnet.
|
||||
Loading…
Add table
Add a link
Reference in a new issue