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.
308 lines
10 KiB
Python
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",
|
|
)
|