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