v0.1.0-dev: initial public extraction + new abstractions
This commit is contained in:
commit
e38120cf11
17 changed files with 2429 additions and 0 deletions
218
cardano_checkout/addresses.py
Normal file
218
cardano_checkout/addresses.py
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
"""
|
||||
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
|
||||
Loading…
Add table
Add a link
Reference in a new issue