"""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", )