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.
8.3 KiB
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:
- 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. - 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
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:
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:
# 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:
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
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
scpa 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_slotis 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_hostat a third party you don't run. - Dry-run against preprod. Every new policy gets smoke-tested on
preprodfirst. 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.