Sulkta Coop's Python SDK for merchant-side Cardano payments + NFT certificate-of-authenticity minting. Zero-custody by design. Extracted from TradeCraft's services/cardano_*.py (2,400+ lines of production Cardano-mainnet code) and restructured as an installable Python package. Package layout (cardano_checkout/): - addresses.py — lifted verbatim: CIP-1852 HD derivation, pure pycardano - oracles.py — lifted from cardano_price.py: Koios ADA/USD feed w/ 5m cache - monitor.py — lifted verbatim (SQLAlchemy-coupled; v0.2 refactors to Store) - scheduler.py — lifted verbatim (same refactor note) - invoice.py — NEW: framework-agnostic Invoice dataclass + lifecycle enum - store.py — NEW: InvoiceStore Protocol for pluggable persistence - mint.py — NEW: CIP-25 v2 metadata builder (works); tx submission stub for v0.2 - ipfs.py — NEW: kubo HTTP client with primary-pin + mirror-pin pattern - txbuild.py — NEW: v0.2 stub for PyCardano / Ogmios tx construction Design: - Consumers provide xpub + InvoiceStore impl. SDK provides everything else. - IPFS: local kubo for upload + serve, optional mirror pins for archival. Chromaticcraft pattern: Rackham kubo primary, Lucy kubo mirror. - NFT: single native-script policy per merchant studio (CIP-25 v2, not CIP-68 — full wallet coverage, no mutability needed for static certs). Policy skey stays under Sulkta cold-custody (Lucy pattern); signing is an external hand-off like ADAMaps payouts. Tests: pure-module smoke tests pass for invoice, store-protocol, CIP-25 metadata envelope, IPFS client import, txbuild stub module. Address derivation tests ship but require pycardano + will exercise in CI. LICENSE: Apache-2.0 (matches upstream Cardano tooling). Next (v0.2 scope): - Refactor monitor + scheduler around InvoiceStore (drop SQLAlchemy coupling) - Wire mint.mint_nft_cert to PyCardano + local Ogmios on Rackham - txbuild: Ogmios chain-context + cold-signer hand-off shape - chromaticcraft Phase 2 imports the SDK as its first external consumer
156 lines
5.9 KiB
Python
156 lines
5.9 KiB
Python
"""CIP-25 v2 NFT certificate-of-authenticity minting.
|
|
|
|
This module produces the NFT cert attached to a confirmed merchant
|
|
order. One NFT per order, pinned-once metadata (image CID from IPFS
|
|
via :mod:`cardano_checkout.ipfs`), sent directly to the customer's
|
|
wallet in the same transaction.
|
|
|
|
Design decisions:
|
|
|
|
- **CIP-25 v2** (not CIP-68). CIP-25 is universally supported by
|
|
every Cardano wallet (Eternl, Lace, Yoroi, Vespr, Typhon). CIP-68
|
|
adds reference-NFT mutability we do not need for a static cert.
|
|
- **Single policy per merchant studio.** All of a studio's certs share
|
|
one policy_id so wallets group them cleanly. The policy key is a
|
|
native script under the studio's custody — Sulkta pattern is a
|
|
multi-sig native script stored on Lucy.
|
|
- **Policy has a time-lock** (invalid-after slot) so the "no more
|
|
editions can be minted after X" claim is cryptographically enforceable.
|
|
Recommended: generous lock (100 years) so policy_id stays stable,
|
|
but revokable in-contract via ``mint policy revoke`` flow.
|
|
- **No reference script, no Plutus.** Pure native scripts + standard
|
|
CIP-25 metadata keeps the tx cheap (~0.18 ADA fee + min-utxo for the
|
|
NFT output).
|
|
|
|
v0.1.0 ships the signature + stub. Full tx construction against a local
|
|
Ogmios endpoint lands in v0.2 once the store + monitor integration
|
|
tests pass.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass, field
|
|
from typing import Optional
|
|
|
|
|
|
@dataclass
|
|
class MintPolicy:
|
|
"""A native-script minting policy under the SDK's custody model.
|
|
|
|
Attributes:
|
|
policy_id: Hex blake2b-224 hash of the native script CBOR. Stable
|
|
for the life of the policy — shipped with every cert minted
|
|
under it. Becomes the Cardano ``policy_id`` of the NFT asset.
|
|
script_cbor_hex: Hex-encoded CBOR of the native script itself.
|
|
Submitted alongside the mint tx witness.
|
|
signing_keys: Paths to skey files needed to sign the tx (all of
|
|
them, for an all-of multi-sig). The SDK does not read these —
|
|
callers pass them to :mod:`cardano_checkout.txbuild` which
|
|
coordinates with an external signer (Lucy cold-store pattern).
|
|
locked_after_slot: Optional slot beyond which the policy rejects
|
|
further mints. None = no time lock (not recommended for
|
|
certificates — a lock makes the "no more editions" claim
|
|
mathematically verifiable).
|
|
"""
|
|
|
|
policy_id: str
|
|
script_cbor_hex: str
|
|
signing_keys: list[str] = field(default_factory=list)
|
|
locked_after_slot: Optional[int] = None
|
|
|
|
|
|
async def mint_nft_cert(
|
|
policy: MintPolicy,
|
|
asset_name: str,
|
|
metadata: dict,
|
|
recipient_address: str,
|
|
ogmios_url: str = "http://127.0.0.1:1337",
|
|
network: str = "mainnet",
|
|
) -> str:
|
|
"""Mint a CIP-25 v2 NFT cert and send it to the recipient.
|
|
|
|
Constructs a transaction that:
|
|
1. Mints exactly 1 of ``{policy.policy_id}.{asset_name}``.
|
|
2. Sends that single token to ``recipient_address`` in its own UTxO.
|
|
3. Attaches CIP-25 v2 metadata under metadatum label 721.
|
|
4. Witnesses with all signing keys required by the policy.
|
|
|
|
Args:
|
|
policy: Merchant's minting policy.
|
|
asset_name: UTF-8 asset name (will be hex-encoded per CIP-25). Max 32 bytes.
|
|
metadata: CIP-25 metadata dict. At minimum should include ``name``,
|
|
``image`` (ipfs://CID), ``mediaType``, and any studio-specific
|
|
properties. The SDK wraps this into the proper ``{721: {policy_id: {asset_name: ...}}}``
|
|
envelope automatically.
|
|
recipient_address: Bech32 address of the wallet that receives the NFT.
|
|
ogmios_url: Endpoint for chain queries + tx submission.
|
|
network: "mainnet" or "testnet".
|
|
|
|
Returns:
|
|
Transaction hash (hex) once successfully submitted.
|
|
|
|
Raises:
|
|
NotImplementedError: v0.1.0 stub. Full implementation lands in v0.2.
|
|
"""
|
|
# v0.1.0 surface lock — implementation lands in v0.2 alongside txbuild.py
|
|
raise NotImplementedError(
|
|
"mint_nft_cert is stubbed in v0.1.0. Use cardano-cli or PyCardano "
|
|
"directly until v0.2 ships the full Ogmios-backed mint path."
|
|
)
|
|
|
|
|
|
def build_cip25_metadata(
|
|
policy_id: str,
|
|
asset_name: str,
|
|
name: str,
|
|
image_cid: str,
|
|
description: str = "",
|
|
media_type: str = "image/jpeg",
|
|
properties: Optional[dict] = None,
|
|
) -> dict:
|
|
"""Assemble the ``{721: {...}}`` metadatum envelope for a single NFT.
|
|
|
|
CIP-25 v2 image field takes an ``ipfs://<CID>`` URI. Description, if
|
|
longer than 64 characters, is split into an array of ≤64-char chunks
|
|
(CIP-25 constraint from the Cardano metadata schema — strings larger
|
|
than 64 chars are encoded as a list of chunks).
|
|
|
|
Args:
|
|
policy_id: Hex policy id (same as on the asset).
|
|
asset_name: UTF-8 asset name — used as the dict key under policy_id.
|
|
name: Human-readable NFT title (shown in wallets).
|
|
image_cid: IPFS CID — the function prepends ``ipfs://``.
|
|
description: Optional longer text. Will be chunked if > 64 chars.
|
|
media_type: MIME type of the image. Default ``image/jpeg``.
|
|
properties: Additional key/value pairs merged into the metadata blob.
|
|
|
|
Returns:
|
|
Dict ready to submit as tx metadatum label 721.
|
|
"""
|
|
def chunk64(s: str) -> list[str]:
|
|
if len(s) <= 64:
|
|
return [s]
|
|
return [s[i : i + 64] for i in range(0, len(s), 64)]
|
|
|
|
desc: object = description
|
|
if isinstance(description, str) and len(description) > 64:
|
|
desc = chunk64(description)
|
|
|
|
body: dict = {
|
|
"name": name,
|
|
"image": f"ipfs://{image_cid}",
|
|
"mediaType": media_type,
|
|
}
|
|
if desc:
|
|
body["description"] = desc
|
|
if properties:
|
|
body.update(properties)
|
|
|
|
return {
|
|
"721": {
|
|
policy_id: {
|
|
asset_name: body,
|
|
},
|
|
"version": "2.0",
|
|
}
|
|
}
|