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.
211 lines
8.3 KiB
Markdown
211 lines
8.3 KiB
Markdown
# 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.
|