cardano-checkout-py/docs/minting-workflow.md
Kayos 68cb535c0f 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.
2026-04-23 20:00:49 -07:00

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:

  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

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 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.