pyrxd.hd — BIP-32/39/44 HD wallets¶
- class pyrxd.hd.AccountDescriptors[source]¶
Bases:
objectThe pair of ranged descriptors that fully describe one BIP44 account.
Both chains are always present. A watch-only import that takes only the receive descriptor silently under-reports the balance, because change outputs land on the internal chain and would never be scanned.
- __init__(receive, change, master_fingerprint, account_path)¶
- class pyrxd.hd.AddressRecord[source]¶
Bases:
objectAddressRecord(address: ‘str’, change: ‘int’, index: ‘int’, used: ‘bool’)
- __init__(address, change, index, used)¶
- class pyrxd.hd.DiscoveryHit[source]¶
Bases:
objectOne derived address that has on-chain history.
- __init__(coin_type, account, change, index, address, confirmed, unconfirmed)¶
- class pyrxd.hd.DiscoveryReport[source]¶
Bases:
objectResult of a multi-path scan.
- __init__(hits, scanned, total_confirmed, total_unconfirmed)¶
- hits: list[DiscoveryHit]¶
- class pyrxd.hd.HdWallet[source]¶
Bases:
objectBIP44 HD wallet for Radiant with gap-limit discovery and encrypted persistence.
- coin_type¶
BIP44 coin type (read-only property; back-store
_coin_typeis set at construction and never mutated). 512 is SLIP-0044 spec for Radiant (default, also Tangem); 0 matches Photonic and Electron-Radiant; 236 matches pre-#14 pyrxd. Persisted in the wallet file and validated on load. Read-only because mutating it post-construction would desync from the already-derived_xprvand silently route subsequent addresses to a different path (closes SEV-2 red-team finding).
- addresses¶
{path_key: AddressRecord}where path_key isf"{change}/{index}".- Type:
- __init__(_seed, account=0, _coin_type=<factory>, external_tip=0, internal_tip=0, addresses=<factory>)¶
- Parameters:
_seed (SecretBytes)
account (int)
_coin_type (int)
external_tip (int)
internal_tip (int)
addresses (dict[str, AddressRecord])
- Return type:
None
- property account_path: str¶
This wallet’s BIP44 account path, e.g.
m/44'/512'/0'.Single source of truth for the string several callers used to build inline. The
_xprvproperty derives from exactly this path.
- build_send_max_tx(triples, to_address, *, fee_rate=10000, allow_below_relay_floor=False, allow_overpay=False)[source]¶
Sweep all triples to to_address minus fee. No change output.
fee_rateis refused below Radiant’s effective relay floor unlessallow_below_relay_flooris set, and above the overpay ceiling unlessallow_overpayis — seebuild_send_tx(). A sweep has NO change output, so an unintended overpay leaves entirely with the miner, which is why the ceiling exists; it is also why the override has to be reachable.
- build_send_tx(triples, to_address, photons, *, fee_rate=10000, allow_below_relay_floor=False, allow_overpay=False, change_address=None)[source]¶
Build and sign a P2PKH transfer from HD UTXOs to to_address.
Pure offline operation. Mirrors
RxdWallet.build_send_tx()but accepts (utxo, address, privkey) triples so each input is signed by the correct HD-derived key.change_addressdefaults to the next unused internal index; callers can override (e.g. to keep change on the external chain for a single-address-style wallet).fee_rateis refused below Radiant’s effective relay floor unlessallow_below_relay_flooris set — the deliberate opt-out for regtest and chains you control. UnlikeRxdWallet, the rate arrives per CALL here, so this is where it has to be judged.allow_overpayis the mirror opt-out for a rate above the overpay ceiling. A ceiling with no reachable override is its own fund-safety bug: a caller who genuinely means a high rate would be refused outright, and Radiant has neither RBF nor CPFP, so a refusal during a timelock race costs the funds the ceiling was protecting.
- property coin_type: int¶
BIP44 coin type this wallet was constructed with. Read-only.
Read-only because mutating it post-construction would desync from the already-derived
_xprv; subsequent address derivations would still happen at the original path while the persisted JSON would advertise the new path. The__setattr__override blockswallet._coin_type = X; the property blockswallet.coin_type = X.
- async collect_spendable(client, *, strict=False)[source]¶
Return
(utxo, address, privkey)triples for every UTXO across known addresses.Address→key mapping is preserved so signing works correctly per UTXO.
A per-address fetch that fails contributes nothing rather than crashing the whole collection — the caller decides whether the resulting balance is enough — but it is now LOGGED rather than dropped in silence, and
strict=Truerefuses the partial result outright. Usestrictwhen the answer is a claim about all the funds;send_max()does.- Parameters:
client (ElectrumXClient)
strict (bool)
- Return type:
- descriptors(*, checksum=False)[source]¶
Output-script descriptors for this account’s receive + change chains.
Watch-only safe: the descriptors embed the account xpub, never the xprv or the seed. Note that an xpub still discloses every address this wallet will ever derive on both chains — a larger privacy surface than handing out a single address.
checksum appends the BIP380 suffix. Off by default because Radiant Core rejects the checksummed form; see
pyrxd.hd.descriptor.- Parameters:
checksum (bool)
- Return type:
- classmethod from_mnemonic(mnemonic, passphrase='', account=0, coin_type=None, *, normalize=True)[source]¶
Create a fresh wallet from a BIP39 mnemonic.
- coin_type selects the BIP44 derivation path:
None(default) uses the module-level configured coin type (env varRXD_PY_SDK_BIP44_DERIVATION_PATH, or SLIP-0044’s 512 if unset).512is SLIP-0044 Radiant (also Tangem).0matches Photonic and Electron-Radiant — pass this when restoring a mnemonic from those wallets.236matches pre-#14 pyrxd wallets.
The chosen coin type is recorded on the wallet and persisted in the wallet file; subsequent
load()calls validate it.normalize controls BIP39 NFKD normalization — see
seed_from_mnemonic(). Leave itTrueunless you are recovering funds from a wallet created before 0.12.0 using a non-ASCII passphrase, which pyrxd then hashed unnormalized. Wrong for every other case: it derives a wallet no other BIP39 implementation can reproduce.
- async get_balance(client, *, strict=False)[source]¶
Return total confirmed + unconfirmed satoshis across all known addresses.
Uses
ElectrumXClient.get_balanceper address. Callrefresh()first to ensure the address set is current.A per-address read that fails is logged and contributes zero — so the total is a LOWER BOUND, not a balance. Pass
strict=Truewhen the number is being shown to somebody or compared against a threshold.- Parameters:
client (ElectrumXClient)
strict (bool)
- Return type:
- async get_utxos(client, *, strict=False)[source]¶
Return all UTXOs across all known addresses.
A per-address read that fails is logged and contributes nothing; pass
strict=Trueto refuse a partial answer instead (see_read_per_address()).- Parameters:
client (ElectrumXClient)
strict (bool)
- Return type:
list[UtxoRecord]
- known_addresses(*, change=None)[source]¶
Return all known address records, optionally filtered by chain.
- Parameters:
change (int | None)
- Return type:
- classmethod load(path, mnemonic, passphrase='', coin_type=None, *, normalize=True)[source]¶
Load a previously saved wallet from path.
The mnemonic is needed to derive the decryption key. Raises
FileNotFoundErrorif path does not exist — a typo’d path will not silently produce an empty wallet that subsequently overwrites a real wallet on save. Callers that explicitly want the create-on-missing behavior should useload_or_create().coin_type (optional) is validated against the value persisted in the wallet file. A mismatch raises
ValidationError— this catches the silent-empty-wallet failure mode where a default change between pyrxd versions would otherwise have the loaded wallet derive at a different path than it was saved at. PassNone(default) to accept whatever was persisted.normalize controls BIP39 NFKD normalization of the mnemonic and passphrase — see
seed_from_mnemonic(). It matters here because the derived seed is also the wallet file’s AES-GCM decryption key: a wallet saved by pyrxd before 0.12.0 with a non-ASCII passphrase was encrypted under the old, unnormalized seed, and can only be decrypted by reproducing that seed withnormalize=False. Leave the defaultTruefor every other case. Loading never guesses the mode: a GCM failure raises rather than silently retrying with the other seed, so the legacy mode is only ever entered by explicit opt-in.
- classmethod load_or_create(path, mnemonic, passphrase='', account=0, coin_type=None, *, normalize=True)[source]¶
Load a wallet from path, or build a fresh one if the file is missing.
Spelled separately from
load()so the create-on-missing intent is explicit at the call site. A common safety failure with the old single-load API was that a typo in path would produce an empty wallet that subsequently overwrote the real wallet on save.coin_type applies to both branches: when loading, it is validated against the persisted value; when creating, it is the coin type the new wallet uses.
normalize also applies to the load branch — see
load()for why it matters there (the seed doubles as the wallet file’s decryption key).Falseis a fund-recovery escape for pre-0.12.0 wallets with non-ASCII passphrases.- Raises:
ValidationError – if path does not exist and
normalize=False. The legacy seed mode exists solely to reach funds already held under a pre-0.12.0 wallet; there is nothing to recover at a path that has no wallet on it. Creating one there instead would mint a brand-new, permanently non-conformant wallet whose mnemonic no other BIP39 implementation can restore — and the likeliest way to land in that branch is a typo in path, which is exactly the failureload_or_createwas split out to make visible.- Parameters:
- Return type:
- master_fingerprint()[source]¶
The BIP32 master key fingerprint:
hash160(master pubkey)[:4].This is what an output-script descriptor’s key-origin field wants, and it is NOT
account_xpub().fingerprint— that attribute is the parent fingerprint (payload bytes 5:9), i.e. the fingerprint ofm/44'/<coin>', one level up. The two values differ for every account at depth > 1.The distinction matters because using the parent fingerprint produces a descriptor that still derives the correct addresses, so nothing appears broken — but it misidentifies the key’s origin, and any consumer that later tries to match the descriptor to a signing device (or to another descriptor from the same seed) will fail to.
Public (no private material leaves): the return value is a truncated hash of a public key.
- Return type:
- next_receive_address()[source]¶
Return the first external (change=0) address with no recorded history.
- Return type:
- privkey_for(change, index)[source]¶
Derive the signing key at
change/index(public seam over_privkey_for).
- privkey_for_address(address)[source]¶
Derive the signing key for a known address.
The derivation path is looked up in
self.addressesrather than searched for, so this is oneckdchain, not a scan.Added for
pyrxd.glyph.mint.GlyphMinter, which must re-derive the key that spends a Glyph commit output after a crash. It deliberately does not persist the key, only the funding address, so it needs address → key. Keeping that lookup here also keeps the minter’s wallet contract down to two methods (collect_spendable()and this one), which is what makes it practical to drive the minter with a non-HD wallet in a test or a dev script.- Raises:
ValidationError – if the address is not one this wallet derived — the caller has the wrong wallet, and signing with a key that hashes to a different PKH would produce a transaction the network rejects.
- Parameters:
address (str)
- Return type:
PrivateKey
- async refresh(client)[source]¶
Run BIP44 gap-limit scan on both external and internal chains.
Discovers which derived addresses have on-chain history. Stops after
_GAP_LIMIT(20) consecutive unused addresses per chain.Network errors (a transient ElectrumX outage, a server hangup mid-scan) propagate to the caller as
NetworkError— previously they were silently treated as “address unused”, which made a funded wallet look empty after a flaky lookup.Returns the count of newly discovered used addresses.
- Parameters:
client (ElectrumXClient)
- Return type:
- save(path)[source]¶
Encrypt and atomically save wallet state to path.
Atomicity & permissions¶
- Writes via mkstemp + fchmod(0o600) + fsync + os.replace, so:
The file is never visible at a wider mode than 0o600 — the mode is set on the fd before any bytes are written.
A crash mid-write cannot leave a half-encrypted blob in place — either the old file remains, or the new fully-fsynced file does.
Encryption¶
AES-256-GCM under a key derived from the BIP39 seed via scrypt with a per-file random salt. Tampering with the ciphertext breaks the GCM tag —
load()raises rather than returning attacker-shaped JSON.- Parameters:
path (Path)
- Return type:
None
- async send(client, to_address, photons, *, fee_rate=10000, allow_below_relay_floor=False, allow_overpay=False, change_address=None)[source]¶
Fetch UTXOs, build, sign, broadcast. Returns broadcast txid.
Raises
ValidationErroron bad inputs or insufficient funds,NetworkErroron RPC failure.
- async send_max(client, to_address, *, fee_rate=10000, allow_below_relay_floor=False, allow_overpay=False)[source]¶
Sweep all UTXOs to to_address minus fee. Returns broadcast txid.
Collection is
strict: “sweep everything” is a completeness claim, and a sweep built from a view that silently lost an address moves most of the funds while reporting that it moved all of them. RaisesNetworkErrorif any per-address read failed — nothing is broadcast, and a retry (or a different endpoint) sweeps the whole set. Usesend()for an amount, which does not make that claim.
- zeroize()[source]¶
Scrub the seed and mark the wallet dead; it cannot derive or sign after.
Hardening #8/H1: the account xprv is NO LONGER stored long-lived — the
_xprvproperty re-derives it transiently from the seed per operation — so the ONLY resident long-lived secret is this 64-byte seed, which lives in aSecretBytesand IS memset here. Setting_zeroed(matchingSecretBytes._zeroed) makes the_xprvproperty fail closed (rather than silently re-deriving a garbage key from the now-zeroed seed). Any account-xprv copies that existed only during an in-flight derivation are short-lived locals (GC-eligible immediately, never held across the unlock window); their residency until the pages are reused is bounded by the agent’s best-effort process hygiene (mlock/PR_SET_DUMPABLE 0/ no core dumps), NOT a guaranteed erase — do not over-state it as “erased”.- Return type:
None
- addresses: dict[str, AddressRecord]¶
- class pyrxd.hd.WordList[source]¶
Bases:
objectBIP39 word list
- files: dict[str, str] = {'en': '/home/runner/work/pyrxd/pyrxd/src/pyrxd/hd/wordlist/english.txt', 'zh-cn': '/home/runner/work/pyrxd/pyrxd/src/pyrxd/hd/wordlist/chinese_simplified.txt'}¶
- path = '/home/runner/work/pyrxd/pyrxd/src/pyrxd/hd/wordlist'¶
- class pyrxd.hd.Xkey[source]¶
Bases:
object[ : 4] prefix [ 4: 5] depth [ 5: 9] parent public key fingerprint [ 9:13] child index [13:45] chain code [45:78] key (private/public)
- class pyrxd.hd.Xprv[source]¶
Bases:
Xkey- classmethod from_seed(seed, network=Network.MAINNET)[source]¶
derive master extended private key from seed
- pyrxd.hd.account_descriptors(xpub, *, master_fingerprint, account_path, checksum=False)[source]¶
Build the receive + change descriptor pair for a BIP44 account.
- pyrxd.hd.append_checksum(descriptor)[source]¶
Return descriptor with its BIP380
#checksumsuffix appended.Radiant Core rejects the result — see the module docstring. Use this only for Bitcoin-Core-lineage consumers.
- pyrxd.hd.bip32_derive_xkeys_from_xkey(xkey, index_start, index_end, path='m/', change=0)[source]¶
Derive a range of extended keys from Xprv and Xpub keys using BIP32 path structure.
- pyrxd.hd.bip32_derive_xprv_from_mnemonic(mnemonic, lang='en', passphrase='', prefix='mnemonic', path='m/', network=Network.MAINNET, *, normalize=True)[source]¶
Derive the subtree root extended private key from mnemonic and path.
- pyrxd.hd.bip32_derive_xprvs_from_mnemonic(mnemonic, index_start, index_end, lang='en', passphrase='', prefix='mnemonic', path='m/', change=0, network=Network.MAINNET, *, normalize=True)[source]¶
Derive a range of extended keys from a nmemonic using BIP32 format
- pyrxd.hd.bip44_derive_xprv_from_mnemonic(mnemonic, lang='en', passphrase='', prefix='mnemonic', path="m/44'/512'/0'", network=Network.MAINNET, *, normalize=True)[source]¶
Derives extended private key using BIP44 format- it is a subset of BIP32. Inherits from BIP32, only changing the default path value.
- pyrxd.hd.bip44_derive_xprvs_from_mnemonic(mnemonic, index_start, index_end, lang='en', passphrase='', prefix='mnemonic', path="m/44'/512'/0'", change=0, network=Network.MAINNET, *, normalize=True)[source]¶
Derive a range of extended keys from a nmemonic using BIP44 format
- pyrxd.hd.ckd(xkey, path)[source]¶
ckd = “Child Key Derivation” derive an extended key according to path like “m/44’/512’/1’/0/10” (absolute) or “./0/10” (relative)
512 is Radiant’s SLIP-0044 coin type and is what
pyrxd.constants.BIP44_DERIVATION_PATHuses. The examples here used to show coin type 0, which is BITCOIN’s — following them derives a wallet whose addresses are not the ones Photonic >= v3.0.0 or Tangem will show for the same mnemonic. Coin types 0 (Photonic <= v2.x, Electron-Radiant, Chainbow) and 236 (pre-#14 pyrxd) are also in use in the Radiant ecosystem for historical reasons — seepyrxd.hd.discovery, which scans all three — but 512 is the one to derive NEW wallets at.
- pyrxd.hd.coin_type_label(coin_type)[source]¶
Return a human label for coin_type, or a generic note if unknown.
- pyrxd.hd.derive_xkeys_from_xkey(xkey, index_start, index_end, change=0)[source]¶
- [DEPRECATED] Use bip32_derive_xkeys_from_xkey instead.
This function name is kept for backward compatibility.
- pyrxd.hd.derive_xprv_from_mnemonic(mnemonic, lang='en', passphrase='', prefix='mnemonic', path="m/44'/512'/0'", network=Network.MAINNET, *, normalize=True)[source]¶
- [DEPRECATED] Use bip44_derive_xprv_from_mnemonic instead.
This function name is kept for backward compatibility.
- pyrxd.hd.derive_xprvs_from_mnemonic(mnemonic, index_start, index_end, lang='en', passphrase='', prefix='mnemonic', path="m/44'/512'/0'", change=0, network=Network.MAINNET, *, normalize=True)[source]¶
- [DEPRECATED] Use bip44_derive_xprvs_from_mnemonic instead.
This function name is kept for backward compatibility.
- pyrxd.hd.descriptor_checksum(descriptor)[source]¶
Return the 8-character BIP380 checksum for descriptor (no
#).- Raises:
ValidationError – if descriptor already carries a checksum, or contains a character outside
INPUT_CHARSET.- Parameters:
descriptor (str)
- Return type:
- async pyrxd.hd.discover(client, mnemonic, *, passphrase='', coin_types=(0, 512, 236), accounts=(0, 1, 2), normalize=True)[source]¶
Scan
coin_types x accountsfor derived addresses with on-chain history.For each
(coin_type, account)pair, builds the account wallet, runs the standard BIP44 gap-limit scan (gap 20, both chains) viaHdWallet.refresh(), and records every used address together with its confirmed/unconfirmed balance and full derivation path.normalize controls BIP39 NFKD normalization of the mnemonic and passphrase — see
seed_from_mnemonic(). A scan with the defaultTruederives the spec-conformant seed and therefore cannot see funds sitting on a seed pyrxd derived before 0.12.0 from a non-ASCII passphrase; passnormalize=Falseto scan that legacy seed’s paths instead. Recovery-only — never for new wallets.mnemonic is used only to derive keys locally; it is never sent to the server. Only derived addresses (as scripthashes) reach the network.
Raises whatever
HdWallet.refresh()/ElectrumXClient.get_balance()raise on network failure — a partial scan is not silently reported as empty. The caller decides how to surface an aborted scan.Returns a
DiscoveryReport;report.foundisFalsewhen no scanned path had any history (the caller should then suggest widening the ranges or supplying the funded address directly).- Parameters:
- Return type:
- pyrxd.hd.key_origin(master_fingerprint, path)[source]¶
Build the descriptor key-origin field, e.g.
[1a2b3c4d/44h/512h/0h].master_fingerprint must be the master key’s fingerprint —
hash160(master_pubkey)[:4]for the key atm— NOT the parent fingerprint stored inside the account xpub. Those differ for any account at depth > 1, and getting it wrong yields a descriptor that misidentifies its own origin: it still derives the right addresses, so nothing looks broken, but any consumer trying to match it to a signing device will fail to.pyrxd.hd.wallet.HdWallet.master_fingerprint()returns the right value.- Raises:
ValidationError – if master_fingerprint is not exactly 4 bytes.
- Parameters:
- Return type:
- pyrxd.hd.pkh_descriptor(xpub, *, master_fingerprint, account_path, chain, checksum=False)[source]¶
Build a ranged
pkh()descriptor for one BIP44 chain.pkh([1a2b3c4d/44h/512h/0h]xpub6.../0/*)xpub is the account-level extended public key at account_path. chain is
EXTERNAL_CHAIN(0, receive) orINTERNAL_CHAIN(1, change). The trailing/*makes it a ranged descriptor, so the consumer walks indices itself.checksum appends the BIP380 suffix. Defaults to False because Radiant Core rejects the checksummed form — see the module docstring.
- pyrxd.hd.seed_from_mnemonic(mnemonic, lang='en', passphrase='', prefix='mnemonic', *, normalize=True)[source]¶
Derive the 64-byte BIP39 seed from a mnemonic (+ optional passphrase).
BIP39 requires the mnemonic sentence and the passphrase to be NFKD normalized before they enter PBKDF2. Without it, two spellings that a user cannot tell apart – “café” with a precomposed U+00E9 versus the same word as “e” + combining U+0301 – derive different seeds, and therefore entirely different wallets.
- Parameters:
normalize (bool) – Leave
True(the default) for spec-conformant, cross-wallet-compatible seeds. PassFalseonly to reproduce the non-conformant seed pyrxd produced before 0.12.0, which is the recovery path for anyone who funded a wallet using a non-ASCII passphrase under the old behavior. It is not interoperable with any other BIP39 implementation – seedocs/how-to/recover-funds-across-wallet-paths.md.mnemonic (str)
lang (str)
passphrase (str)
prefix (str)
- Return type:
Note
normalize=Falseis inert for a passphrase that is already in NFKD form, which includes every pure-ASCII passphrase (the overwhelmingly common case) and both wordlists pyrxd ships. For those inputs the two modes return byte-identical seeds.
- pyrxd.hd.step_to_index(step)[source]¶
convert step (sub path) normal derivation or hardened derivation into child index
- pyrxd.hd.verify_checksum(descriptor)[source]¶
Return True if descriptor ends in a valid BIP380
#checksum.False for an unchecksummed descriptor, a malformed suffix, or a payload that does not match its checksum. Never raises — this is a predicate for validating untrusted input.
Exactly one
#is required.#is a member ofINPUT_CHARSET, so a doubly checksummed string such asraw(deadbeef)#89f8spxm#4x0avkn4polymods correctly and used to verify True here — while Bitcoin Core’sdescsum_checkrejects it, anddescriptor_checksum()already refuses to create one. Accepting what we will not emit, and what the consumer will not take, is the wrong half of that pair to be lenient in.