diff --git a/README.md b/README.md index 41e12f6..636ad42 100644 --- a/README.md +++ b/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 diff --git a/docs/minting-workflow.md b/docs/minting-workflow.md new file mode 100644 index 0000000..0dfccf7 --- /dev/null +++ b/docs/minting-workflow.md @@ -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="", + script_cbor_hex="", + required_signer_hashes=[ + "", # 28 bytes, 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.