cardano-checkout-py/cardano_checkout/addresses.py

236 lines
7.4 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 — soft (non-hardened) derivation.
addr_node = acct_pub.derive(0, private=False).derive(index, private=False)
# Staking chain (2) / always index 0 for the account.
stake_node = acct_pub.derive(2, private=False).derive(0, private=False)
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,
PaymentVerificationKey,
StakeVerificationKey,
)
pay_vk = PaymentVerificationKey.from_primitive(addr_node.public_key)
stake_vk = StakeVerificationKey.from_primitive(stake_node.public_key)
address = Address(
payment_part=pay_vk.hash(),
staking_part=stake_vk.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()
node = _parse_xpub(stripped)
# Soft-derive a single child to prove the key is usable — HDWallet
# construction is lazy, so we actually exercise the BIP32 math.
node.derive(0, private=False)
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 a public-only HDWallet node.
pycardano exposes soft-derivation through :class:`pycardano.HDWallet`.
An account-level xpub is 64 bytes (32-byte Ed25519 public key +
32-byte chain code). Some wallets export 96 bytes; if so, we strip
the first 32 bytes which are typically a zeroed / duplicated prefix.
Args:
xpub_hex: Hex-encoded extended public key string.
Returns:
pycardano.HDWallet node rooted at the account level, with private
key fields unset. ``node.derive(index, private=False)`` performs
the soft CIP-1852 derivation we need.
Raises:
ValueError: If the byte length is unexpected or the key is invalid.
"""
from pycardano import HDWallet
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:
raw = raw[32:]
else:
raise ValueError(
f"Unexpected xpub length: {len(raw)} bytes. "
"Expected 64 bytes (pubkey + chain_code)."
)
public_key = raw[:32]
chain_code = raw[32:]
try:
return HDWallet(
public_key=public_key,
chain_code=chain_code,
path="m/1852'/1815'/0'",
)
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