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
|