cardano-checkout-py/tests/test_mint_metadata.py
Kayos dd435a5e2d v0.2: add store, mint, and monitor integration tests
Three new modules land in tests/ alongside the v0.1 pure-module
tests:

- test_store_protocol.py: InMemoryStore round-trips + Protocol
  conformance. Covers create / get / update / list_by_status /
  next_derivation_index / record_tx idempotency + defensive-copy
  semantics.
- test_mint_metadata.py: mint_nft_cert end-to-end against a stubbed
  ChainContext (no live Ogmios). Exercises the 2-of-2 native-script
  policy shape, tx body construction, CIP-25 envelope CBOR
  round-trip, and the oversize-asset-name guard.
- test_monitor_with_inmemory_store.py: monitor loop driven against
  InMemoryStore with Koios + the oracle monkeypatched. Covers every
  status transition the scheduler cares about (confirm, overpay,
  underpay, stay-pending, record_tx, expiry skip) and the two
  reprice code paths (successful reprice bumps expected_lovelace +
  expires_at; max_repricings flips to EXPIRED).

All 42 tests pass on pycardano 0.16 with pytest-asyncio auto mode.
2026-04-23 19:58:46 -07:00

308 lines
10 KiB
Python

"""CIP-25 v2 envelope round-trips + unsigned-mint shape tests.
Complements ``test_cip25_metadata.py`` (the pure builder unit tests) by
checking:
- Round-tripping the envelope through pycardano's Metadata/AuxiliaryData
produces a well-formed CBOR blob (the wallet-visible thing).
- :func:`mint_nft_cert` returns a correctly-shaped :class:`UnsignedMint`
without hitting a live chain — we stub the ChainContext.
The chain-context stub mirrors just enough of pycardano's interface for
``TransactionBuilder.build`` to succeed. No live Ogmios calls.
"""
from __future__ import annotations
import pytest
from cardano_checkout import UnsignedMint, build_cip25_metadata, mint_nft_cert
# ---------------------------------------------------------------------------
# Fixtures — a deterministic test policy + a stub ChainContext
# ---------------------------------------------------------------------------
def _test_policy():
"""Return a fresh 2-of-2 NativeScript all-of policy + its hash + CBOR.
Uses fixed verification-key hashes (32-char hex, 28 bytes as
required by Cardano VKH). No cryptographic significance — just
deterministic filler for tests.
"""
from pycardano import (
NativeScript,
ScriptAll,
ScriptPubkey,
VerificationKeyHash,
)
vkh_cobb = VerificationKeyHash.from_primitive(bytes.fromhex("11" * 28))
vkh_kayos = VerificationKeyHash.from_primitive(bytes.fromhex("22" * 28))
script: NativeScript = ScriptAll(
[ScriptPubkey(vkh_cobb), ScriptPubkey(vkh_kayos)]
)
return script, [vkh_cobb.payload.hex(), vkh_kayos.payload.hex()]
def _stub_context():
"""Return a minimal ChainContext stub with just enough surface for builder.build()."""
from pycardano import (
Address,
AssetName,
MultiAsset,
ProtocolParameters,
TransactionId,
TransactionInput,
TransactionOutput,
UTxO,
Value,
)
class StubContext:
network = None
@property
def last_block_slot(self) -> int:
return 100_000_000
@property
def protocol_param(self) -> ProtocolParameters:
# Mainnet values as of 2025-ish — just enough to get fee math through.
return ProtocolParameters(
min_fee_constant=155_381,
min_fee_coefficient=44,
max_block_size=90_112,
max_tx_size=16_384,
max_block_header_size=1_100,
key_deposit=2_000_000,
pool_deposit=500_000_000,
pool_influence=0.3,
monetary_expansion=0.003,
treasury_expansion=0.2,
decentralization_param=0,
extra_entropy="",
protocol_major_version=9,
protocol_minor_version=0,
min_utxo=1_000_000,
min_pool_cost=340_000_000,
price_mem=0.0577,
price_step=0.0000721,
max_tx_ex_mem=14_000_000,
max_tx_ex_steps=10_000_000_000,
max_block_ex_mem=62_000_000,
max_block_ex_steps=20_000_000_000,
max_val_size=5_000,
collateral_percent=150,
max_collateral_inputs=3,
coins_per_utxo_byte=4_310,
coins_per_utxo_word=34_482,
cost_models={}, # Plutus cost models — not used by native-script mints.
)
@property
def genesis_param(self):
# Minimal stand-in — pycardano's builder uses this for slot math.
from pycardano import GenesisParameters
return GenesisParameters(
active_slots_coefficient=0.05,
update_quorum=5,
max_lovelace_supply=45_000_000_000_000_000,
network_magic=764_824_073,
epoch_length=432_000,
system_start=1_506_203_091,
slots_per_kes_period=129_600,
slot_length=1,
max_kes_evolutions=62,
security_param=2_160,
)
@property
def era(self):
from pycardano import Era
return Era.CONWAY
def utxos(self, address):
# Provide one fat UTxO so the builder has inputs to draw fees + min-ADA from.
addr = (
address
if isinstance(address, Address)
else Address.from_primitive(address)
)
tx_in = TransactionInput(
transaction_id=TransactionId.from_primitive(bytes.fromhex("cc" * 32)),
index=0,
)
# 1000 ADA, no native assets — plenty for fees + NFT min-UTxO.
output = TransactionOutput(addr, Value(1_000_000_000))
return [UTxO(tx_in, output)]
def submit_tx(self, tx): # pragma: no cover — not invoked in these tests
pass
return StubContext()
# ---------------------------------------------------------------------------
# Test vectors for the envelope
# ---------------------------------------------------------------------------
def test_envelope_from_chromaticcraft_order_vector() -> None:
"""A realistic chromaticcraft cert — image CID + studio properties."""
md = build_cip25_metadata(
policy_id="4a3b2c1d0e9f8a7b6c5d4e3f2a1b0c9d8e7f6a5b4c3d2e1f0a9b8c7d",
asset_name="ChromaticCraftCert0042",
name="Chromatic Craft Cert #0042",
image_cid="bafybeihkop... real cid would be 59 base32 chars",
description=(
"Certificate of authenticity for hand-stitched custom moth pendant "
"ordered by Abby, completed 2026-04-17 by Cobb."
),
media_type="image/png",
properties={
"studio": "chromaticcraft",
"artisan": "Cobb",
"order_id": "CC-2026-0042",
"edition": "1 of 1",
"material": "sterling silver + polymer clay",
},
)
label = md["721"]
nft = label[
"4a3b2c1d0e9f8a7b6c5d4e3f2a1b0c9d8e7f6a5b4c3d2e1f0a9b8c7d"
]["ChromaticCraftCert0042"]
assert label["version"] == "2.0"
assert nft["name"] == "Chromatic Craft Cert #0042"
assert nft["mediaType"] == "image/png"
assert nft["studio"] == "chromaticcraft"
assert nft["artisan"] == "Cobb"
assert nft["edition"] == "1 of 1"
def test_envelope_roundtrips_through_pycardano_metadata() -> None:
"""CBOR-encode and decode — the wallet-visible path."""
from pycardano import AuxiliaryData, Metadata
raw = build_cip25_metadata(
policy_id="ab" * 28,
asset_name="TestNFT",
name="Test NFT",
image_cid="bafybeitestcidforround-tripping",
description="round trip",
)
# pycardano's Metadata expects int keys at the top level.
inner = {int(k): v for k, v in raw.items()}
md = Metadata(inner)
aux = AuxiliaryData(md)
cbor_hex = aux.to_cbor_hex()
# Must be valid hex and non-trivially sized.
assert len(cbor_hex) > 40
assert all(c in "0123456789abcdef" for c in cbor_hex)
# Decoding back must yield the same metadata.
decoded = AuxiliaryData.from_cbor(bytes.fromhex(cbor_hex))
decoded_md = decoded.data if hasattr(decoded, "data") else decoded # pycardano compat
assert decoded_md is not None
def test_envelope_version_key_always_present() -> None:
md = build_cip25_metadata(
policy_id="a" * 56, asset_name="x", name="n", image_cid="c"
)
assert md["721"]["version"] == "2.0"
# ---------------------------------------------------------------------------
# mint_nft_cert — full tx body construction against a stubbed context
# ---------------------------------------------------------------------------
async def test_mint_nft_cert_returns_unsigned_bundle() -> None:
from pycardano import Address, Network, PaymentKeyPair, StakeKeyPair
script, signer_hashes = _test_policy()
policy_cbor_hex = script.to_cbor_hex()
# policy_id is blake2b-224 of the script CBOR — use pycardano to compute.
policy_id = script.hash().payload.hex()
# Build two throwaway addresses in testnet namespace.
pay_key = PaymentKeyPair.generate()
stk_key = StakeKeyPair.generate()
funding_addr = str(
Address(
payment_part=pay_key.verification_key.hash(),
staking_part=stk_key.verification_key.hash(),
network=Network.TESTNET,
)
)
recipient_pay = PaymentKeyPair.generate()
recipient_stk = StakeKeyPair.generate()
recipient_addr = str(
Address(
payment_part=recipient_pay.verification_key.hash(),
staking_part=recipient_stk.verification_key.hash(),
network=Network.TESTNET,
)
)
from cardano_checkout import MintPolicy
policy = MintPolicy(
policy_id=policy_id,
script_cbor_hex=policy_cbor_hex,
required_signer_hashes=signer_hashes,
)
metadata = build_cip25_metadata(
policy_id=policy_id,
asset_name="TestCert01",
name="Test Cert 01",
image_cid="bafybeitest",
)
result = await mint_nft_cert(
policy=policy,
asset_name="TestCert01",
metadata=metadata,
recipient_address=recipient_addr,
funding_address=funding_addr,
context=_stub_context(),
network="testnet",
)
assert isinstance(result, UnsignedMint)
assert len(result.tx_id) == 64 # hex-encoded 32-byte blake2b hash
assert result.tx_body_cbor_hex
assert all(c in "0123456789abcdef" for c in result.tx_body_cbor_hex)
assert result.auxiliary_data_cbor_hex
assert result.native_script_cbor_hex == policy_cbor_hex
assert result.required_signer_hashes == signer_hashes
assert policy_id in result.summary
assert recipient_addr in result.summary
async def test_mint_nft_cert_rejects_oversize_asset_name() -> None:
from cardano_checkout import MintPolicy
policy = MintPolicy(
policy_id="aa" * 28,
script_cbor_hex="82008200581c" + "11" * 28, # doesn't matter — builder never reached
required_signer_hashes=[],
)
with pytest.raises(ValueError, match="asset_name"):
await mint_nft_cert(
policy=policy,
asset_name="X" * 33, # 33 > 32 byte limit
metadata={"721": {}},
recipient_address="addr_test1...",
funding_address="addr_test1...",
context=_stub_context(),
network="testnet",
)