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
218 lines
6.6 KiB
Python
218 lines
6.6 KiB
Python
"""
|
|
Cardano HD address derivation service.
|
|
|
|
Derives Cardano base addresses from an account-level extended public key (xpub)
|
|
exported from wallets such as Eternl or Lace. Uses BIP-44 derivation via pycardano.
|
|
|
|
Key derivation path: m / 1852' / 1815' / account' / chain / index
|
|
- chain 0 = external (receive) addresses
|
|
- chain 2 = staking key (always index 0 for the account)
|
|
|
|
The xpub accepted here is the *account* public key — the root has already been
|
|
hardened away by the wallet. We only perform soft derivation from account level
|
|
down, so no private key material is ever needed or touched.
|
|
"""
|
|
import logging
|
|
from typing import Optional
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Public API
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def derive_address(xpub_hex: str, index: int, network: str = "mainnet") -> str:
|
|
"""
|
|
Derive a Cardano base address at the given receive-address index.
|
|
|
|
The address is a Shelley-era base address combining:
|
|
- payment key: account_xpub / 0 (external chain) / index
|
|
- staking key: account_xpub / 2 (staking chain) / 0
|
|
|
|
Args:
|
|
xpub_hex: Hex-encoded account extended public key (64 bytes raw or
|
|
96 bytes with chain code, as exported by most CIP-1852 wallets).
|
|
index: Receive address index (0-based). Must be >= 0.
|
|
network: "mainnet" or "testnet" (preprod / preview). Defaults to mainnet.
|
|
|
|
Returns:
|
|
Bech32-encoded Cardano base address (addr1... or addr_test1...).
|
|
|
|
Raises:
|
|
ValueError: If xpub_hex is malformed, index is negative, or network is invalid.
|
|
RuntimeError: If pycardano is not installed or derivation fails unexpectedly.
|
|
"""
|
|
_require_pycardano()
|
|
|
|
if index < 0:
|
|
raise ValueError(f"Address index must be non-negative, got {index}")
|
|
|
|
net = _parse_network(network)
|
|
acct_pub = _parse_xpub(xpub_hex)
|
|
|
|
try:
|
|
# External receive chain (0) / address index
|
|
addr_pub = acct_pub.derive(0).derive(index)
|
|
# Staking chain (2) / always index 0 for the account
|
|
stake_pub = acct_pub.derive(2).derive(0)
|
|
except Exception as exc:
|
|
logger.exception("[cardano] Key derivation failed at index %d", index)
|
|
raise RuntimeError(f"Key derivation failed: {exc}") from exc
|
|
|
|
from pycardano import Address
|
|
|
|
address = Address(
|
|
payment_part=addr_pub.hash(),
|
|
staking_part=stake_pub.hash(),
|
|
network=net,
|
|
)
|
|
|
|
return str(address)
|
|
|
|
|
|
def validate_xpub(xpub_hex: str) -> bool:
|
|
"""
|
|
Validate that an xpub string is well-formed and parseable.
|
|
|
|
Checks:
|
|
- Is a non-empty string
|
|
- Is valid hex
|
|
- Is a valid pycardano HDPublicKey (correct byte length, valid point on curve)
|
|
|
|
Args:
|
|
xpub_hex: Hex-encoded account extended public key.
|
|
|
|
Returns:
|
|
True if the xpub is valid, False otherwise. Never raises.
|
|
"""
|
|
if not xpub_hex or not isinstance(xpub_hex, str):
|
|
return False
|
|
|
|
# Quick hex sanity before paying the crypto cost
|
|
stripped = xpub_hex.strip()
|
|
if not _is_hex(stripped):
|
|
return False
|
|
|
|
try:
|
|
_require_pycardano()
|
|
_parse_xpub(stripped)
|
|
return True
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def get_address_preview(xpub_hex: str, network: str = "mainnet") -> str:
|
|
"""
|
|
Derive the address at index 0 for settings UI preview.
|
|
|
|
Thin wrapper around derive_address — exists so callers don't have to
|
|
know or care about the index convention.
|
|
|
|
Args:
|
|
xpub_hex: Hex-encoded account extended public key.
|
|
network: "mainnet" or "testnet". Defaults to mainnet.
|
|
|
|
Returns:
|
|
Bech32-encoded Cardano base address at index 0.
|
|
|
|
Raises:
|
|
ValueError: If xpub_hex is malformed or network is invalid.
|
|
RuntimeError: If derivation fails unexpectedly.
|
|
"""
|
|
return derive_address(xpub_hex, index=0, network=network)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Internal helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _require_pycardano() -> None:
|
|
"""Raise a clear RuntimeError if pycardano is not installed."""
|
|
try:
|
|
import pycardano # noqa: F401
|
|
except ImportError as exc:
|
|
raise RuntimeError(
|
|
"pycardano is required for Cardano address derivation. "
|
|
"Add pycardano>=0.11.0 to requirements.txt and reinstall."
|
|
) from exc
|
|
|
|
|
|
def _parse_network(network: str):
|
|
"""
|
|
Parse a network string into a pycardano Network enum value.
|
|
|
|
Args:
|
|
network: "mainnet" or "testnet".
|
|
|
|
Returns:
|
|
pycardano.Network enum member.
|
|
|
|
Raises:
|
|
ValueError: If network is not one of the accepted values.
|
|
"""
|
|
from pycardano import Network
|
|
|
|
if network == "mainnet":
|
|
return Network.MAINNET
|
|
if network == "testnet":
|
|
return Network.TESTNET
|
|
raise ValueError(
|
|
f"Invalid network '{network}'. Expected 'mainnet' or 'testnet'."
|
|
)
|
|
|
|
|
|
def _parse_xpub(xpub_hex: str):
|
|
"""
|
|
Parse a hex-encoded extended public key into an HDPublicKey.
|
|
|
|
pycardano's HDPublicKey.from_primitive expects 64 raw bytes
|
|
(32-byte Ed25519 public key + 32-byte chain code). Some wallets
|
|
export 96 bytes; if so, we strip the trailing 32 bytes which are
|
|
the public key repeated.
|
|
|
|
Args:
|
|
xpub_hex: Hex-encoded extended public key string.
|
|
|
|
Returns:
|
|
pycardano.HDPublicKey instance.
|
|
|
|
Raises:
|
|
ValueError: If the byte length is unexpected or the key is invalid.
|
|
"""
|
|
from pycardano import HDPublicKey
|
|
|
|
try:
|
|
raw = bytes.fromhex(xpub_hex.strip())
|
|
except ValueError as exc:
|
|
raise ValueError(f"xpub_hex is not valid hex: {exc}") from exc
|
|
|
|
# Standard CIP-1852 account xpub is 64 bytes (pubkey || chain_code).
|
|
# Some export formats prepend 32 zeroed or duplicated bytes — handle both.
|
|
if len(raw) == 64:
|
|
pass # Expected format
|
|
elif len(raw) == 96:
|
|
# Strip the first 32 bytes (typically a duplicate or empty prefix)
|
|
raw = raw[32:]
|
|
else:
|
|
raise ValueError(
|
|
f"Unexpected xpub length: {len(raw)} bytes. "
|
|
"Expected 64 bytes (pubkey + chain_code)."
|
|
)
|
|
|
|
try:
|
|
return HDPublicKey.from_primitive(raw)
|
|
except Exception as exc:
|
|
raise ValueError(f"xpub is not a valid extended public key: {exc}") from exc
|
|
|
|
|
|
def _is_hex(value: str) -> bool:
|
|
"""Return True if every character in value is a valid hex digit."""
|
|
if not value:
|
|
return False
|
|
try:
|
|
bytes.fromhex(value)
|
|
return True
|
|
except ValueError:
|
|
return False
|