v1.0.0-dev: slim to the real product — merchant state machine only

This commit is contained in:
Sulkta 2026-04-23 21:58:26 -07:00
parent c31518309d
commit b4a81e0ab8
15 changed files with 286 additions and 2503 deletions

View file

@ -1,71 +0,0 @@
"""Deterministic address-derivation smoke test.
Uses a known test-vector xpub (the one shipped in the pycardano docs)
to assert the derived addresses are stable and reproducible across SDK
versions. If this test ever changes output, we have a backwards-compat
problem that would break every merchant's receive-address history.
"""
from __future__ import annotations
import pytest
from cardano_checkout import addresses
# Public test vector — a CIP-1852 account extended public key.
# 64 bytes = 32 bytes Ed25519 pubkey || 32 bytes chain code, hex encoded.
#
# Derived deterministically from the well-known test mnemonic
# "test test test test test test test test test test test junk"
# at path m/1852'/1815'/0' via pycardano's HDWallet. Using a real,
# on-curve account xpub here (as opposed to random hex) is what lets
# validate_xpub + derive_address actually exercise the BIP32 math.
TEST_XPUB_HEX = (
"f2cdeef60dfc2c00cd1d4c0def0ce3f7b0328f5badd2fd771f48ff207ca7eaa8"
"500a3c3d556f995e79c4a75e64d13ab12772f46e6c05fed1d9698b7e12a533f7"
)
def test_validate_xpub_accepts_well_formed_key() -> None:
assert addresses.validate_xpub(TEST_XPUB_HEX) is True
def test_validate_xpub_rejects_empty_and_junk() -> None:
assert addresses.validate_xpub("") is False
assert addresses.validate_xpub("notreallyhex!!") is False
assert addresses.validate_xpub("deadbeef") is False # wrong length
# Note: a correct-length random-hex string IS accepted — BIP32-ED25519
# soft derivation over a 64-byte input doesn't require the public key
# half to be a point on the curve. We only catch shape errors here.
def test_derive_address_is_deterministic() -> None:
a0 = addresses.derive_address(TEST_XPUB_HEX, index=0, network="mainnet")
a0_again = addresses.derive_address(TEST_XPUB_HEX, index=0, network="mainnet")
assert a0 == a0_again
assert a0.startswith("addr1")
def test_derive_address_distinct_per_index() -> None:
a0 = addresses.derive_address(TEST_XPUB_HEX, index=0, network="mainnet")
a1 = addresses.derive_address(TEST_XPUB_HEX, index=1, network="mainnet")
a42 = addresses.derive_address(TEST_XPUB_HEX, index=42, network="mainnet")
assert a0 != a1 != a42
def test_derive_address_network_switch_changes_prefix() -> None:
mainnet = addresses.derive_address(TEST_XPUB_HEX, index=0, network="mainnet")
testnet = addresses.derive_address(TEST_XPUB_HEX, index=0, network="testnet")
assert mainnet.startswith("addr1")
assert testnet.startswith("addr_test1")
def test_derive_address_rejects_negative_index() -> None:
with pytest.raises(ValueError, match="non-negative"):
addresses.derive_address(TEST_XPUB_HEX, index=-1)
def test_derive_address_rejects_bad_network() -> None:
with pytest.raises(ValueError, match="Invalid network"):
addresses.derive_address(TEST_XPUB_HEX, index=0, network="preprod")

View file

@ -1,56 +0,0 @@
"""CIP-25 v2 metadata envelope construction — pure unit tests, no network."""
from __future__ import annotations
from cardano_checkout.mint import build_cip25_metadata
def test_basic_envelope_shape() -> None:
md = build_cip25_metadata(
policy_id="abc123",
asset_name="ExampleStudio-Order-0001",
name="Example Studio — Custom Order #0001",
image_cid="bafybeibgen",
description="Hand-stitched moth pendant",
properties={"order_id": "0001", "edition": "1 of 1"},
)
assert md["721"]["version"] == "2.0"
assert "abc123" in md["721"]
nft = md["721"]["abc123"]["ExampleStudio-Order-0001"]
assert nft["name"] == "Example Studio — Custom Order #0001"
assert nft["image"] == "ipfs://bafybeibgen"
assert nft["mediaType"] == "image/jpeg"
assert nft["description"] == "Hand-stitched moth pendant"
assert nft["order_id"] == "0001"
assert nft["edition"] == "1 of 1"
def test_description_under_64_chars_stays_a_string() -> None:
md = build_cip25_metadata(
policy_id="abc", asset_name="x", name="n",
image_cid="c", description="short",
)
assert md["721"]["abc"]["x"]["description"] == "short"
def test_description_over_64_chars_chunks_to_list() -> None:
long = "x" * 150
md = build_cip25_metadata(
policy_id="abc", asset_name="x", name="n",
image_cid="c", description=long,
)
desc = md["721"]["abc"]["x"]["description"]
assert isinstance(desc, list)
assert all(len(chunk) <= 64 for chunk in desc)
assert "".join(desc) == long
def test_image_uri_has_ipfs_prefix() -> None:
md = build_cip25_metadata(
policy_id="abc", asset_name="x", name="n",
image_cid="bafybeitestcid",
)
assert md["721"]["abc"]["x"]["image"].startswith("ipfs://")
assert "bafybeitestcid" in md["721"]["abc"]["x"]["image"]

View file

@ -1,308 +0,0 @@
"""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_signer1 = VerificationKeyHash.from_primitive(bytes.fromhex("11" * 28))
vkh_signer2 = VerificationKeyHash.from_primitive(bytes.fromhex("22" * 28))
script: NativeScript = ScriptAll(
[ScriptPubkey(vkh_signer1), ScriptPubkey(vkh_signer2)]
)
return script, [vkh_signer1.payload.hex(), vkh_signer2.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_example-studio_order_vector() -> None:
"""A realistic example-studio cert — image CID + studio properties."""
md = build_cip25_metadata(
policy_id="4a3b2c1d0e9f8a7b6c5d4e3f2a1b0c9d8e7f6a5b4c3d2e1f0a9b8c7d",
asset_name="ExampleStudioCert0042",
name="Example Studio Cert #0042",
image_cid="bafybeihkop... real cid would be 59 base32 chars",
description=(
"Certificate of authenticity for hand-stitched custom moth pendant "
"ordered by a customer, completed by Sulkta Studio."
),
media_type="image/png",
properties={
"studio": "example-studio",
"artisan": "Sulkta Studio",
"order_id": "CC-2026-0042",
"edition": "1 of 1",
"material": "sterling silver + polymer clay",
},
)
label = md["721"]
nft = label[
"4a3b2c1d0e9f8a7b6c5d4e3f2a1b0c9d8e7f6a5b4c3d2e1f0a9b8c7d"
]["ExampleStudioCert0042"]
assert label["version"] == "2.0"
assert nft["name"] == "Example Studio Cert #0042"
assert nft["mediaType"] == "image/png"
assert nft["studio"] == "example-studio"
assert nft["artisan"] == "Sulkta"
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",
)

View file

@ -53,23 +53,31 @@ def _utxo(lovelace: int, tx_hash: str = "aa" * 32) -> dict:
@pytest.fixture(autouse=True)
def _patch_koios_and_oracle(monkeypatch):
"""Default: no UTxOs, oracle returns $0.45/ADA. Individual tests override."""
def _patch_koios(monkeypatch):
"""Default: Koios returns no UTxOs. Individual tests override."""
async def fake_utxos(address, koios_url=None, timeout=None):
return []
async def fake_price():
return 0.45
monkeypatch.setattr(monitor, "check_address_utxos", fake_utxos)
async def fake_convert(usd):
@pytest.fixture
def price_fn_at_45c():
"""A deterministic price_fn for tests — USD priced at $0.45/ADA."""
async def _convert(usd: float) -> int:
if usd <= 0:
return 0
return int((usd / 0.45) * 1_000_000)
return _convert
monkeypatch.setattr(monitor, "check_address_utxos", fake_utxos)
monkeypatch.setattr(monitor, "get_ada_usd_price", fake_price)
monkeypatch.setattr(monitor, "convert_usd_to_lovelace", fake_convert)
@pytest.fixture
def price_fn_zero():
"""A price_fn that returns 0 — stand-in for oracle unavailability."""
async def _zero(usd: float) -> int:
return 0
return _zero
# ---------------------------------------------------------------------------
@ -184,14 +192,14 @@ async def test_already_expired_invoices_are_skipped(monkeypatch) -> None:
# ---------------------------------------------------------------------------
async def test_reprice_updates_expected_lovelace_and_extends_expiry() -> None:
async def test_reprice_updates_expected_lovelace_and_extends_expiry(price_fn_at_45c) -> None:
store = InMemoryStore()
inv = _make(expected_lovelace=5_000_000)
inv.expires_at = datetime.now(timezone.utc) - timedelta(minutes=1)
await store.create(inv)
updated = await monitor.reprice_expired_invoices(
store, window_minutes=15, max_repricings=3
store, price_fn=price_fn_at_45c, window_minutes=15, max_repricings=3
)
assert updated == 1
@ -205,14 +213,14 @@ async def test_reprice_updates_expected_lovelace_and_extends_expiry() -> None:
assert fetched.metadata["repriced_count"] == 1
async def test_reprice_gives_up_after_max_repricings() -> None:
async def test_reprice_gives_up_after_max_repricings(price_fn_at_45c) -> None:
store = InMemoryStore()
inv = _make(expected_lovelace=5_000_000, repriced_count=3)
inv.expires_at = datetime.now(timezone.utc) - timedelta(minutes=1)
await store.create(inv)
await monitor.reprice_expired_invoices(
store, window_minutes=15, max_repricings=3
store, price_fn=price_fn_at_45c, window_minutes=15, max_repricings=3
)
fetched = await store.get("inv")
@ -220,26 +228,21 @@ async def test_reprice_gives_up_after_max_repricings() -> None:
assert fetched.status == InvoiceStatus.EXPIRED
async def test_reprice_noop_when_nothing_expired() -> None:
async def test_reprice_noop_when_nothing_expired(price_fn_at_45c) -> None:
store = InMemoryStore()
await store.create(_make(expired_in_minutes=15) if False else _make())
await store.create(_make())
updated = await monitor.reprice_expired_invoices(store)
updated = await monitor.reprice_expired_invoices(store, price_fn=price_fn_at_45c)
assert updated == 0
async def test_reprice_skips_when_oracle_unavailable(monkeypatch) -> None:
async def test_reprice_skips_when_oracle_returns_zero(price_fn_zero) -> None:
store = InMemoryStore()
inv = _make(expected_lovelace=5_000_000)
inv.expires_at = datetime.now(timezone.utc) - timedelta(minutes=1)
await store.create(inv)
async def zero_price():
return 0.0
monkeypatch.setattr(monitor, "get_ada_usd_price", zero_price)
updated = await monitor.reprice_expired_invoices(store)
updated = await monitor.reprice_expired_invoices(store, price_fn=price_fn_zero)
assert updated == 0
fetched = await store.get("inv")