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