236 lines
7.4 KiB
Python
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
|