pyrxd threat model¶
Version: 1.0 (draft) Last updated: 2026-05-01 Applies to: pyrxd v0.3+ (library + CLI)
This document is the working threat model for pyrxd. It exists to:
Make explicit what pyrxd protects, and from whom.
Map every claimed protection to a concrete control in the codebase.
Surface gaps honestly so users, contributors, and (eventually) auditors can see what is and isn’t covered.
Provide a starting point that an external security review can build on rather than recreate.
This is the threat model for experimental open-source software, provided as-is under the LICENSE, that people can choose to use. It is not a substitute for an independent third-party audit. The README states cryptographic primitives have not been independently audited; that remains true.
Purpose & non-goals¶
What pyrxd is¶
A Python SDK + CLI for the Radiant blockchain. It performs:
Key generation, derivation, and signing (secp256k1, BIP32/39/44)
Transaction construction, serialization, and signing
Glyph token protocol operations (NFT mint, FT deploy, transfers)
Gravity cross-chain BTC↔RXD atomic swaps
SPV verification of Bitcoin transactions
ElectrumX networking and Bitcoin data-source queries
What pyrxd is NOT¶
Not a hardware wallet integration
Not a multi-signature coordination tool (single-sig only in v0.3)
Not a node implementation (does not validate the chain itself; relies on ElectrumX)
Not a custodial service or a smart-contract platform beyond what Radiant’s consensus rules support
Non-goals (explicit)¶
These are not protected by pyrxd controls:
Coercion attacks (rubber-hose, $5 wrench attacks)
Physical attackers with hands on the user’s machine
Side-channel attacks at the silicon level (Spectre/Meltdown class)
Compromise of the user’s terminal emulator, OS, or hardware
Compromise of the BIP39 wordlist file or scrypt KDF (we trust upstream implementations)
Long-term post-quantum security (secp256k1 itself is not post-quantum safe)
Assets¶
Ranked roughly by value to an attacker:
# |
Asset |
Form |
Where it lives |
|---|---|---|---|
A1 |
BIP39 mnemonic |
12 or 24 words |
User’s memory, paper, optionally encrypted in |
A2 |
BIP39 seed (PBKDF2 of A1) |
64 bytes |
In-memory |
A3 |
Account-level xprv |
base58check string |
Derived in-memory from A2; never persisted |
A4 |
Per-address private keys |
32-byte scalar |
Derived in-memory from A3; never persisted |
A5 |
Encrypted wallet file ( |
AES-GCM-encrypted JSON |
Disk at |
A6 |
Account-level xpub |
base58check string |
Watch-only-safe; can be exported via |
A7 |
An unsigned transaction |
bytes |
Transient; held in-memory during tx building |
A8 |
A signed transaction in flight |
bytes |
Transient; sent over wss to ElectrumX |
A9 |
UTXO ownership info |
tuples (txid, vout, value, owner) |
In-memory after |
A10 |
Network metadata (which addresses, balances, history) |
observable on-chain |
Public, but linkability matters for privacy |
A11 |
Unlocked wallet held by the signing agent |
in-memory |
The |
Control surfaces target the upper rows; A6 is intentionally exportable; A10 is unavoidable on a public chain.
Threat actors¶
TA1: Local post-compromise malware¶
Capabilities: read process memory, read files in $HOME with user permissions, tamper with stdin/stdout, modify dependencies on next install, exfiltrate over network.
Goals: A1, A2, A4, A5.
Reach: Once present, can do almost anything. pyrxd’s controls provide defense in depth at best, not prevention. Encrypted wallet file slows exfiltration; SecretBytes.zeroize() slightly reduces window; nothing makes a compromised host safe.
TA2: Local non-malicious user (footgun)¶
Capabilities: the user themselves making mistakes — pasting mnemonics into chat, running wallet new while screen-sharing, copying to clipboard, leaving terminal scrollback.
Goals: Not adversarial; a victim of accident.
Reach: Heavy. Most reported real-world key losses come from this category. pyrxd’s job is to make accidents harder.
TA3: Network passive observer¶
Capabilities: sniff packets between user and ElectrumX/BTC data sources.
Goals: A10 (link addresses to user’s IP), inputs/outputs for chain analytics.
Reach: Limited if wss:// is enforced (TLS). pyrxd defaults to wss and rejects ws:// without allow_insecure=True.
TA4: Network active MITM¶
Capabilities: intercept and modify traffic. Possible against ws:// (rejected by default) or against TLS with a CA compromise.
Goals: Substitute attacker addresses into broadcast txs, suppress balance/history results to confuse the wallet, force fee bumps.
Reach: Mostly mitigated by TLS but degrades to TA5 if the user is using a hostile ElectrumX.
TA5: Hostile ElectrumX operator¶
Capabilities: The remote endpoint pyrxd connects to. Can lie about anything it returns: balances, UTXO sets, transaction confirmations, headers. Cannot forge signatures or steal private keys.
Goals: Selective service denial (refuse to broadcast a tx, drop history queries), inducing wallet to derive new addresses (privacy attack), trickery to lure UTXOs into a malformed tx (limited by client-side validation).
Reach: Significant for privacy, limited for theft. The default config uses one public ElectrumX server (electrumx.radiant4people.com) which is a single point of trust.
TA6: Hostile Bitcoin data source¶
Capabilities: A BtcDataSource (mempool.space, blockstream.info, Bitcoin Core RPC) used by Gravity for SPV proofs. Can lie about BTC-side data.
Goals: Forge a “BTC was sent” proof that fools the RXD-side covenant into releasing funds.
Reach: A self-consistent forged chain is byte-identical from every source, so MultiSourceBtcDataSource quorum — which only detects disagreement between sources — does not catch it. The actual forgery defense is the on-chain covenant’s expectedNBits pin, now mirrored in the Python verifier (verify_chain enforces the nBits pin before PoW, audit F-01/F-03), with the Merkle-proof↔header binding (build(tx_block_height=…), F-18) and an offer-time difficulty floor (reject_low_difficulty/min_difficulty_nbits, F-02). For confirmation depth, a single source under-reporting block_height inflates burial; the [1,tip] floor on get_raw_tx plus the above-dust MultiSourceBtcFundingReader quorum (F-17) mitigate it. The primitive must not be the sole release authority on a value-bearing chain without a covenant pinning nBits. This is a caller obligation, not an enforced one: require_spv_sole_authority_cleared has been advisory since 0.9.0 and returns without raising (see gap #8). Full pitfall catalogue: docs/how-to/spv-verification-pitfalls.md.
TA8: Hostile counterparty (Gravity)¶
Capabilities: The other party in an atomic swap. Wants to take both legs of the trade.
Goals: Exploit covenant bugs, race conditions, or incorrect SPV verification to claim BTC and RXD without delivering their side.
Reach: Direct financial impact if a bug exists. This is the single most adversarial setting in pyrxd. Mitigated by the covenant tests in tests/test_gravity_red_team.py (1500+ lines), but the README flags Gravity as “still being hardened” and “covenant variants” as work in progress.
TA9: Supply-chain attacker¶
Capabilities: Compromise a release of coincurve, Cryptodome, click, cbor2, aiohttp, websockets; typosquat pyrxd on PyPI; or compromise pyrxd’s own release pipeline.
Goals: Inject signing-time backdoor, exfiltrate seeds via network, replace key derivation with attacker-controlled values.
Reach: Catastrophic if successful. pyrxd’s defenses are limited to: small dep tree, pip-audit for known CVEs, signed PyPI uploads, and trust in upstream maintainers. We do not pin transitive deps.
Trust boundaries¶
Listed roughly inside-out; each is a place where data changes from “untrusted” to “validated and used”:
CLI argv → parsed by click, validated by command handlers. Click handles type coercion for
int,float,Path,Choice. Custom validation (address shape, ref shape) is in command bodies.Stdin → mnemonic and passphrase input via
click.prompt(hide_input=True). Normalized via_normalize_mnemonic(whitespace collapse). Validated bybip39.validate_mnemonic. Never logged.Configuration files →
~/.pyrxd/config.tomlparsed by stdlibtomllib. Schema-checked byConfigdataclass. Mode permissions on parent dir checked.Wallet file (
wallet.dat) → AES-GCM authenticated decryption; tag mismatch raises before any post-decrypt code runs. File mode checked (0o600 required) before read.Metadata files (
metadata.json) → JSON parsed with stdlib. Protocol names mapped toGlyphProtocolints. Validated byGlyphMetadata.__post_init__. Cap on payload size enforced bydecode_payload.Network: WebSocket frames from ElectrumX → JSON-RPC framed, size-capped at 10 MB. Response correlation is per-id (concurrent calls don’t swap responses). Hex/bytes results validated as typed values (
Txid,RawTx,Hex32).Network: HTTP responses from Bitcoin data sources → Content-type checked, size-capped, hex-decoded with explicit length. URL construction uses
urllib.parse.quote.Library API surface (caller → pyrxd) → typed validation at constructors:
Hex32,Hex20,Txid,Satoshis,Photons,BlockHeight,Nbits,SighashFlagall reject malformed inputs at construction.PrivateKey,PublicKeyvalidate input bytes/strings.Internal: pyrxd → coincurve / Cryptodome → these libraries are the trust root for crypto primitives. We do not re-implement.
Threat scenarios¶
Each scenario lists actor → action → asset → control(s) → residual risk.
S1: Mnemonic exfiltration via JSON-mode redirect (TA2)¶
Action: User runs
pyrxd wallet new --json --yes | tee mnemonic.txtto “save” the output, mnemonic ends up unencrypted on disk.Asset: A1.
Control: README documents the pitfall explicitly. The default (interactive) flow shows the mnemonic with an Enter gate — no shell-redirect exposure.
Residual risk: User error remains possible. Mitigation is documentation, not enforcement. Documented at
README.md#security-scripting-wallet-new-with---json---yes.
S2: Mnemonic exposure via terminal scrollback (TA2)¶
Action: User runs interactive
pyrxd wallet newin tmux/screen with scrollback enabled.Asset: A1.
Control: README documents that interactive display still has terminal-history risks. Enter gate slows down accidental copy.
Residual risk: High. We cannot clear scrollback portably.
S3: Mnemonic exposure via clipboard manager (TA2)¶
Action: User copy-pastes the mnemonic from terminal display; clipboard manager retains history.
Asset: A1.
Control: None in v0.3.
Residual risk: Real. Tracked as issue #11 — add a clipboard-hygiene warning after the Enter gate.
S4: Wallet decryption attempt with wrong mnemonic (TA1)¶
Action: Attacker has wallet.dat (e.g., from backup leak). Tries to decrypt with random mnemonics.
Asset: A5 → A1.
Controls: scrypt KDF (n=2^14) imposes per-attempt CPU+memory cost; per-file salt prevents precomputed table reuse; AES-GCM tag detects all wrong guesses. Decrypt failure surfaces a single static message — never echoes attacker input.
Residual risk: scrypt parameters are tuned for “BIP39 seed has 128+ bits of entropy” — they slow brute force but do not save a mnemonic that’s been leaked elsewhere.
S5: World-readable wallet file post-restore (TA1)¶
Action: User restores wallet.dat via
cporrsync; file ends up at mode 0o644.Asset: A5.
Control: Load-time mode check refuses to read a wallet file with group/other read bits and prints the chmod fix. Test:
tests/test_hd_wallet.py::test_load_rejects_world_readable_wallet_file.Residual risk: macOS/Windows behavior may differ; check is gated to
os.name == "posix".
S6: Stale signature attack via fee-pass interleave (architectural)¶
Action: A bug in tx builder that signs trial outputs but builds final outputs differently. Attacker pays fee on user’s trial-tx not their actual one.
Asset: A4 + A8.
Control: Two-pass fee algorithm explicitly resets
unlocking_scriptbetween trial and final, then re-signs. Documented intests/test_preimage.py. Tested inRxdWalletandHdWalletsend/send_max paths.Residual risk: Any new tx-builder code path must follow the same pattern. Code review item.
S7: Hostile metadata.json owner_pkh substitution (TA7)¶
Action: User downloads
nft-metadata.jsonfrom chat. The file contains the attacker’sowner_pkh(or the metadata triggers a tx whose change goes to the attacker). User runspyrxd glyph mint-nft nft-metadata.jsonand broadcasts.Asset: A8, indirectly the minted NFT.
Controls:
Confirmation prompt before broadcast shows “funding utxo, funding value, commit value, network.” This summary does NOT currently surface the embedded
owner_pkhfrom the metadata.init-metadatascaffolds a clean template that the user fills in themselves.Out-of-band trust (user shouldn’t run hostile files).
Residual risk: Real. Open finding: the broadcast summary should display the resolved
owner_pkh(and ASCII-render the address) before the user confirms. Tracked as a follow-up; will become an issue.
S8: Hostile ElectrumX returns malformed UTXO record (TA5)¶
Action: ElectrumX returns a UTXO with a value that doesn’t match the on-chain truth. User signs a tx using that fake value.
Asset: A4 (signs a misweighted tx).
Control: None at the wallet layer; pyrxd does not independently re-fetch source-tx outputs to verify UTXO values for plain RXD sends. Gravity does this for BTC inputs via
MultiSourceBtcDataSourcequorum.Residual risk: Real but bounded. A lying ElectrumX can cause the user to overpay fees or build invalid txs (which the network rejects on broadcast — funds aren’t lost, just confused). Cannot induce theft directly because the locking script is what controls the funds, and pyrxd builds locking scripts itself.
S9: Hostile ElectrumX claims address is unused (TA5)¶
Action: During gap-limit scan, ElectrumX returns empty
get_historyfor an address that is actually funded. Wallet thinks address is unused; recommends it for next receive (or for change).Asset: A10 (privacy: linking sender to receiver).
Control: Library N5 fix:
_scan_chainre-raisesNetworkErroron lookup failure rather than silently treating as “unused.” Re-using a known-funded address is impossible because the gap-limit logic stops at consecutive empty results, and each empty result is verified.Residual risk: A consistently lying ElectrumX could still hide history. Mitigation is network-layer source diversity (use multiple servers); not implemented for ElectrumX queries (only for BTC data sources). Tracked as a future enhancement — multi-source ElectrumX.
S10: Hostile counterparty exploits a Gravity covenant bug (TA8)¶
Action: Counterparty crafts a swap proposal that, if executed, leaves them with both legs.
Asset: A8, real funds.
Controls:
SPV verification of BTC proofs against header chain
Multi-source BtcDataSource quorum
Covenant code structurally derived from audited Photonic Wallet patterns
1500+ lines of red-team tests in
test_gravity_red_team.pyREADME explicit “experimental” flag on covenant variants
Residual risk: Most concentrated risk in the codebase. Audit-recommended target.
S11: Supply-chain compromise of coincurve (TA9)¶
Action: Malicious
coincurverelease ships with backdoored signing.Asset: A4, every signature pyrxd produces.
Controls:
pip-auditin dev deps;coincurveis a high-attention package with multiple maintainers. We pin a major-version range, not a specific version.Residual risk: Catastrophic if exploited. Effective response would require upstream awareness or a security advisory, both of which we’d hear about via standard channels.
S12: Typosquat of pyrxd itself (TA9)¶
Action: Attacker publishes
py-rxdorpyrxd-toolswith malicious code; user installs the wrong package.Asset: Everything in the user’s environment.
Control: None (this is a PyPI registry concern). README links the canonical install path.
[project.urls]inpyproject.tomlpoints to the real repo.Residual risk: Outside pyrxd’s control.
S13: --debug traceback leaks frame locals (TA1, TA2)¶
Action: User encounters a wallet decrypt failure with
--debug; traceback is forwarded to a log aggregator that captures stderr; mnemonic local appears in the trace.Asset: A1.
Control:
errors.CliError.show()usestraceback.format_exception(...)only — nevercapture_locals=True. Source-line context contains variable names but never values. Tested intest_debug_emits_traceback_on_decrypt_failure: the user’s exact input never appears inresult.output.Residual risk: Source line text mentions
mnemonicandpassphraseas parameter names; an attacker reading the logs sees the names but not values. Acceptable.
S14: Fee-rate flag set to 0 builds an unmineable tx (TA2)¶
Action: User passes
--fee-rate 0(currently rejected by validation) or somehow ends up with effectively-zero fee. Tx is built and broadcast; never confirms; funds appear stuck.Asset: A4 + A8 (operational, not theft).
Control:
build_send_txandbuild_send_max_txvalidatefee_rate > 0. Default fee rate of 10,000 photons/byte is the documented mainnet relay minimum.Residual risk: Low — if fee is below relay minimum, the network rejects on broadcast. Funds are not lost; user can rebuild with a higher fee.
S15: Replay of a signed transaction (general)¶
Action: Attacker re-broadcasts a signed tx the user already broadcast.
Asset: A8 (already public, same tx confirms once).
Control: Bitcoin/Radiant transactions are inherently non-replayable: they spend specific UTXOs, and once spent those UTXOs are gone. A re-broadcast either confirms the same tx (no-op) or is rejected as conflicting.
Residual risk: None at this layer. Cross-chain Gravity introduces its own replay considerations, addressed by SPV-binding and counterparty-specific covenant params.
S16: Race condition mid-save() corrupts wallet file (TA2 timing)¶
Action:
wallet newis interrupted (Ctrl-C, power loss) mid-write.Asset: A5.
Control: Atomic write pattern:
mkstemp→fchmod 0o600→ write →fsync→os.replace. Either the old file remains intact, or the new fully-fsynced file does. No half-encrypted state.Residual risk: OS-level filesystem guarantees vary; we trust ext4/xfs/HFS+/APFS to honor
os.replaceatomicity.
S17: Mnemonic in pytest result.output captured by failing assertion (TA1, hypothetical)¶
Action: A test asserts on
result.output, fails for an unrelated reason, pytest’s traceback embeds the full output (including the mnemonic) in CI logs.Asset: A1 (synthetic — test mnemonics are random per run).
Control: Tests use disposable mnemonics. CI logs are private.
Residual risk: Low (synthetic mnemonics never hold real funds). Tracked as issue #9.
S18: Same-uid process abuses the signing agent to spend (TA1) — issue #8¶
Action: With the agent unlocked (A11), a malicious same-uid process connects to the socket and submits its own
SigningRequestto drain the wallet. It passesSO_PEERCRED(same uid) and the0600/0700filesystem checks — those gate other users, not a co-resident attacker.Asset: A11 (the unlocked wallet) → A8 (a signed, fund-moving tx).
Control (THE control): per-spend confirmation. The agent parses the tx, independently verifies each prevout (C1 — see S19), attributes every output (change re-derived and verified, the rest shown as external payees), and requires a human keypress on the daemon’s own controlling terminal (
/dev/tty) before signing — a channel the requesting process cannot drive. A detached daemon with no tty fails closed (declines). Small spends below an explicit, opt-in--auto-confirm-underthreshold skip the prompt; that threshold is documented as outside the trust boundary. The agent never returns key material (conformance-tested), so reaching the socket lets an attacker request a signature, never take the key.Residual risk: A user who blind-confirms, or who sets a high
--auto-confirm-under, is unprotected — by their own choice. The confirmation is the boundary; automation of it is out of scope. Idle auto-lock andagent lockbound the unlock window.
S19: Agent tricked into a fee-theft / fund-redirect signature (TA1)¶
Action: A request lies about an input’s prevout value (to burn the surplus to fees) or asks for a non-
ALL|FORKIDsighash (to recombine the signature into a different, fund-redirecting tx), while showing the user a benign-looking spend.Asset: A4/A11 → A8.
Control: Prevout authenticity (C1) — the agent requires the full source tx for each input, verifies it hashes to the input’s outpoint, and reads value/script from the real prevout (never the request’s claim); the displayed summary is derived from the verified tx (display == sign). The agent re-derives the signing key and refuses to sign an input it does not own. Sighash policy — v1 signs only
ALL|FORKID; any other type is refused (it would commit to fewer outputs than the confirmation showed). Partially-owned txs are refused (every input must be attributable).Residual risk: v1 is P2PKH-only and fully-owned-only by design; multi-party / mixed-owner signing is out of scope.
S20: Taker offline/censored during [reveal, t_rxd] — the R1 free-option residual (TA8)¶
Action: Maker and taker reach
BOTH_LOCKED. The maker claims the counter-leg (BTC/ETH), revealingp(SECRET_REVEALED). The honest taker is then offline, mempool-pinned, or censored across the window from that reveal tot_rxd. Att_rxdthe maker CSV-refunds the Radiant covenant →ASSET_VULNERABLE→ONE_SIDED_LOSS_TAKER(swap_state.py): the taker has paid the counter-leg and the maker holds both legs. This is the inherent HTLC “free option” of the reveal-on-the-long-leg shape.Asset: the taker’s funded counter-leg (A-cross) → maker.
Control(s) — what BOUNDS it (it is not eliminated): the cross-clock timelock margin sizes
t_rxdto open strictly before the counter-leg deadline minus the finality-stall-tolerant margin; the reorg-finality gate refuses an unsafe early claim (SAFE/WAIT/SQUEEZED, never a silent claim); the value-scaled claim burial (red-team 2026-06-12 HIGH, now enforced) requires the taker’s claim to bury deep enough that reorging it costs ≥ the value at stake; and the proactive-refund windowNis coupled to the finality+burial reserve so a reveal cannot be timed into a squeeze the taker could otherwise have acted in.What bounds the automation (correct the record): a
ClaimExecutornow exists (watch/claim_executor.py), but it is not an auto-claim hot wallet. It is keyless for the asset — it scrapes the maker’s already-public preimage and broadcasts the keyless covenant claim whoseoutput[0]is pinned to the taker holder PKH, so it cannot redirect the asset (the watchtower holds no value key; a stolen fee key can only burn dust fees). It is dormant-by-construction (declines unless an operator wires a resolver + writes a per-swap covenant sidecar), and as of 0.9.0 it is armed-by-exception: on a value-bearing network it broadcasts nothing unlessenable_autonomous_mainnet_custody=True. Autonomous claim size is bounded byclaim_dust_ceiling(a default the operator raises with explicit per-value consent; the bluntaccept_unbounded_reorg_riskflag cannot cross it). So R1’s closure for an un-armed or un-wired tower still rests on operator/taker liveness withint_rxd; an armed tower closes it autonomously within the consented value bound. The earlier “noClaimExecutorin v1” statement is superseded by this paragraph.Residual risk: REAL and ACCEPTED (same as the BTC↔RXD direction) — this is the inherent HTLC free option, not a pyrxd bug. Surfaced loudly (never a silent
COMPLETED). The autonomous closer above shrinks the operator-liveness window for armed towers within the consented value bound; outside that (un-armed, or value above the configured ceiling) sizet_rxdfor the worst-case pin/eviction window the taker must survive online. The external audit + a genuine two-party adversarial run remain the gates before relying on autonomous custody for non-dust value.
S21: Under-fee’d time-critical spend — the 8-hour irreversibility window (TA2, architectural)¶
Action: An honest, time-critical Radiant covenant spend (an HTLC claim, or a CSV refund) is broadcast with a fee below the node’s effective relay floor. It is not merely slow: it is unrepairable.
Asset: A-cross (the swap’s asset leg) — and the loss is total, to the counterparty, not merely delayed.
Why it is unrepairable — Radiant supports NEITHER RBF NOR CPFP (verified against
Radiant-Core@afdf57b1and the live mainnet node, Radiant Core 3.1.2, 2026-08-09):No RBF.
src/validation.cpp:667and:866reject any mempool conflict outright (txn-mempool-conflict,REJECT_DUPLICATE). The onlybip125string in the tree is a deprecated help label; there is nobumpfeeRPC. Radiant ships DSProof — it treats a conflict as fraud to broadcast, the exact opposite of replacement.No CPFP.
getmempoolancestors/getmempooldescendantsexist and make it look supported, butsrc/miner.cpp:404selects onGetModifiedFeeRate()=(nFee + feeDelta) / vsize— the transaction’s own fee over its own size — andbreaks atblockMinFeeRate. Thebacklogqueue is topological ordering only. A high-fee child cannot lift a low-fee parent into a block.The window.
DEFAULT_MEMPOOL_EXPIRY = 8hours (src/validation.h:82). The stuck transaction squats on its own inputs for up to 8 hours before they free for a rebuild. If the deadline falls inside that window there is no remedy — the claim never confirms,t_rxdelapses, and the counterparty refunds.Note the inverse of the Bitcoin intuition: BIP125 mempool pinning (the two-party run plan’s NA-2b) does not apply to Radiant, because nothing is replaceable. The risk that replaces it is worse and was previously unnamed.
Control: fee pre-sizing — the only control that exists, and therefore mandatory rather than nice-to-have.
pyrxd.gravity.fee_policy.DeadlineFeePolicyderives the requirement asceil(size × rate / 1000)from the node’s advertisedeffective_minrelaytxfee(0.10 RXD/kB on the reference node;minrelaytxfeeis 0.01 — the effective rate is what binds), with a deadline-scaled premium inside a 6-block horizon. The rate is injected, never hardcoded as a constant: it is node policy and it moves.htlc_spend.build_htlc_claim_tx/build_htlc_refund_txrefuse to return an under-fee’d transaction, sized againstlen(tx.serialize())— the exact wire bytes, after signing, never an estimate.radiant_leg.RadiantCovenantLegapplies the deadline-aware requirement immediately before broadcast: for a claim, blocks-to-deadline ist_rxd − covenant confirmations(the maker’s CSV refund branch opens at that depth), so a claim racing a closing window is fee’d for it. A shortfall refuses and PAGEs (InsufficientFundsError, logged at ERROR) rather than emitting an unfixable transaction;watch/claim_executor.pymaps it toDECLINED(which pages) and deliberately retries on the next tick, becauseCappedFeeWalletSourceis dispense-once and the next dispense may be a larger input.That retry is only safe because the cap charges spend, not dispense. The fee input has to be dispensed before the transaction can be built (its value and script are inputs to the build), so a refused build had already committed the input and charged the cumulative cap for a transaction that never reached a node. Measured: a pool of 7 × 500,000 + 1 × 20,000,000 photons under a 20,000,000 cap burned 3,500,000 of the cap on seven refusals that broadcast nothing, and the eighth dispense then raised
FeePoolExhaustedError— “dispensing 20000000 photons would exceed total_cap_photons=20000000 (already dispensed 3500000)” — with the covering input funded, unspent, and unreachable, while the asset ran out its deadline to the counterparty’s CSV refund.radiant_leg._unspent_on_failurenow reports the input back viaCappedFeeWalletSource.release_unspenton any pre-broadcast failure, which credits the cap without rewinding the dispense-once cursor (so no input can back two transactions, and a small head-of-line input cannot re-refuse forever).The historical flat 546-photon dust floor is retained as a floor but is not the requirement: at the reference rate a ~266-byte RXD claim needs ~2,660,000 photons, about 4,900× the dust floor.
Explicitly NOT built: RBF and CPFP fee-bump paths. Neither exists on this chain; a “just bump the fee” proposal is Bitcoin semantics assumed onto a BCH-lineage chain. The remedy is pre-sizing, not escalation.
Residual risk: an operator whose fee source is underfunded still cannot spend — but they now learn at build time, with an exact shortfall, instead of after an irreversible broadcast. The urgency multiplier is a policy choice, not a measured inclusion model: no Radiant fee/confirmation-time curve has been measured, so the premium buys headroom against relay-policy change and mempool competition and claims nothing about latency. Fee-source availability under a deadline remains an operator responsibility (see the watchtower runbook).
S22: Capital-lockup griefing — the accepted LIVENESS residual (TA8, economic)¶
Action: A counterparty repeatedly opens swaps, lets the victim lock capital on-chain, then simply never locks their own leg. The victim is made whole — eventually — but their funds are immobilised for the full timelock and they pay two on-chain fees. The attacker’s move is inaction: they never transact, so there is nothing to slash and nothing to observe on-chain.
Asset: none lost. Availability of the victim’s capital, and their fees.
Why the asymmetry is structural (verified against the shipped state machine, not inferred):
The taker locks first —
swap_state.py:NEGOTIATED --> BTC_LOCKED : taker funds BTC P2TR HTLC (locks FIRST).The taker’s own refund is the longer timelock —
assert_timelock_marginenforcest_btc - t_rxd >= margin(swap_coordinator.py:475). That direction is deliberate and correct (it is what prevents the far worse one-sided-loss race), but it means the party who commits first also waits longest to get out.Recourse works and is slow:
taker_refund_btcis valid fromBTC_LOCKEDoncet_btcelapses.Attacker cost to repeat: ~zero. Victim cost: linear in swaps accepted.
Why no test catches it: every safety oracle asks whether anyone ended with less than they started. Here nobody does — the both-complete-XOR-both-refund invariant holds and the record reaches
ABORTEDcleanly. A griefing campaign and a counterparty with flaky connectivity are indistinguishable to every check in the adversarial matrix. It survived a red-team pass and an eight-reviewer panel unflagged because it is not a defect; it is a property of HTLC swaps.Control: operational, not protocol. Order the legs by trust (taker-locks-first is a default, not a consensus rule — have an untrusted counterparty lock first); size
t_btcdeliberately, since everything above the safety floor is chosen and directly lengthens the griefing window; keep counterparty state at the negotiation layer; and do not auto-accept orders from unknown counterparties — automation is the risk multiplier, not the attack.Explicitly NOT built: a bond or deposit. It would change the protocol for every honest swap to price a threat with no observed instance; escrow needs adjudication, which is new consensus-adjacent surface on an already-unaudited stack; and slashing on abort cannot distinguish malice from a stalled node, punishing the users least able to absorb it. Full reasoning and the revisit trigger:
docs/solutions/design-decisions/griefing-is-a-liveness-residual-not-a-bond.md.Residual risk: REAL and ACCEPTED. Stated plainly: pyrxd’s swap stack defends SAFETY, not LIVENESS. It will not let a counterparty take your funds; it will not stop one wasting your time and immobilising your capital for a timelock at near-zero cost to themselves. Revisit on an actual incident, or when the orderbook carries untrusted counterparties at volume.
S23: Hostile taker under-funds the counter-leg HTLC (TA8)¶
Action: The runbook is taker-funds-the-counter-leg-first, maker-locks-the-asset-second. A hostile taker funds the correct counter-leg HTLC — the BTC funding address is a pure function of
terms, so it is freely derivable — with less thanterms.value_amount. The honest maker locks its Radiant asset, claims the under-funded counter leg (revealingpon chain), and the taker then claims the full asset with thatp. Both legs complete; the maker is simply paid less than the agreed price. The ETH shape of the same attack is a taker-deployed contract withclaimant = attackeror a short balance.Asset: A8, real funds. A one-sided maker loss, bounded by the shortfall the taker chose.
Why the obvious defences do not catch it: a P2TR scriptPubKey commits to the taptree, not the output value (and an ETH HTLC address commits to immutables, not the funded balance), so every scriptPubKey/address re-derivation in the handshake passes on a short-funded HTLC. The coordinator’s amount bind lives inside
taker_funds_btc— the taker’s own method — which a hostile taker never calls. And the locator JSON the taker hands the maker is entirely attacker-chosen: it can describe a correct HTLC tree, self-report the agreedamount_sats, and pointfunding_outpointat a decoy output.Controls:
SwapCoordinator.maker_verify_counter_funding— the maker’s independent, fail-closed gate. It takes only the untrusted outpoint (BTC) or contract address (ETH) and verifies the chain against the maker’s own re-derivation.BitcoinTaprootLeg.verify_counterparty_funded— reads the confirmed, unspent output authoritatively and binds its scriptPubKey (re-derived from the maker’s ownterms), its value againstterms.value_amountexactly (over-funding rejected too — the claim leaf does not cap value, so an over-funded HTLC is a one-sided taker loss), and its confirmation depth.EthLeg.verify_counterparty_fundedis the ETH twin.SwapCoordinator.post_asset_lock_revalidatemakes the gate non-skippable on both chains: no verified locator on the record ⇒ refuseBOTH_LOCKED; and the verification is re-run at asset-lock time, closing the verify→lock TOCTOU (a reorg, or a taker who funds only after the maker looked).Reorg pin from existing policy: a real-value (
MarginPolicy.is_measured) swap requiresbtc_claim_reorg_depthconfirmations on the BTC funding (PoW finality is a depth); the ETH twin pins to thefinalizedcheckpoint.Fail-closed everywhere: a reader that cannot report a confirmed output, a leg without the verification method, a missing locator, or an unreachable node all refuse the lock.
tests/test_btc_maker_counter_funding_adversarial.py(under/over-funded, decoy scriptPubKey, shallow, spent, verified-then-reorged) andtests/test_xchain_eth_adversarial_e2e.py::test_S7.
Residual risk: the gate is only as good as the chain source behind it. A maker reading a single lying/MITM’d endpoint can be told an output exists that does not — use
MultiSourceBtcFundingReader(quorum) or a local node for real value, per TA6. The BTC arm of this control has not been exercised in a live two-party run; only in tests.
S24: Maker never locks the asset and sweeps the taker’s counter leg (TA8)¶
Action: The mirror of S23, and the cheaper attack. The maker publishes the envelope — which fixes
H, the timelocks and the BTC claim key it holds — and then locks nothing. The taker funds its BTC (or ETH) HTLC. The maker immediately claims it with thepit has held since it generated the secret. There is no asset to claim back, and the taker’s own refund does not open untilt_btc.Asset: A8, real funds. A one-sided taker loss of the full
btc_sats— not a shortfall, the whole leg.Why consensus permits it: the BTC claim leaf is
<H> OP_SHA256 OP_EQUALVERIFY <makerClaimPk> OP_CHECKSIG. It carries no precondition that the asset was ever locked. The FSM’sNEGOTIATED --> BTC_LOCKED : taker funds FIRSTordering is bookkeeping, not a safety guarantee, andmaker_claims_btcrefuses the premature claim only for an honest maker driving its own coordinator — a hostile maker does not use a coordinator. Documented as hazard HZ-1 indocs/htlc-handshake-wire-format.md, whose normative rule is: a taker MUST NOT fund the counter leg until it has confirmed the maker’s asset lock on chain, at the agreed scriptPubKey, for the agreed value, at a depth the taker chose.Why it went unenforced: the check existed only inside
scripts/btc_swap_two_host.py/scripts/eth_swap_two_host.py. Any caller drivingSwapCoordinatordirectly gotpre_btc_lock_check(...) -> ok=Trueandtaker_funds_btc(...) -> BTC_LOCKEDhaving invoked zero methods on the Radiant leg — the library never read the Radiant chain at all.Controls:
SwapCoordinator.taker_verify_asset_funding— the taker’s independent, fail-closed gate, run as step 5 ofpre_btc_lock_checkand therefore on everytaker_funds_btc.RadiantCovenantLeg.verify_maker_asset_funded— re-derives the covenant scriptPubKey from the taker’s ownterms(never one the maker advertises), locates its funded UTXO, binds the on-chain value toterms.radiant_amountexactly, and requires a confirmation depth. “Funded” alone is not enough: ElectrumXlistunspentincludes mempool outputs, so a maker can fund with a replaceable transaction, wait for the lock, then double-spend the funding away.Re-run at lock time inside
taker_funds_btc, immediately before the counter-leg broadcast, closing the verify→lock TOCTOU. It sits before theSeenStorereserve so the reserve keeps its “last step before the only broadcast” property and a refusal does not burnH.Depth pin from existing policy: a real-value (
MarginPolicy.is_measured) swap requiresrxd_claim_burialconfirmations on the covenant funding — the same depth the claim-finality gate requires of the taker’s own Radiant claim. An estimated/test policy defers to the leg’smin_confirmations(the operator’s--taker-min-rxd-confs).Fail-closed everywhere: an unfunded SPK, a mis-valued or ambiguous covenant UTXO, a shallow funding, an unreachable node, and a
radiant_legthat does not implement the read all refuse to fund.tests/test_taker_asset_funding_gate_adversarial.py(never-funded, mis-valued, wrong script, 0-conf, measured-policy depth, unreachable node, leg without the capability, verified-then-vanished TOCTOU, honest control).
Residual risk: same chain-source caveat as S23 — the gate is only as good as the Radiant node behind it, and it reads a single ElectrumX endpoint. There is no RXD-side quorum reader equivalent to
MultiSourceBtcFundingReader; for real value the taker should read a node it controls. Not exercised in a live two-party run; only in tests.
Controls in place¶
Cross-reference of controls and the threats they address:
Control |
Threats addressed |
Code location |
|---|---|---|
AES-256-GCM wallet encryption |
S4, S5 |
|
scrypt KDF (n=2^14) |
S4 |
|
Mode 0o600 enforcement (save) |
S1, S5 |
|
Mode 0o600 verification (load) |
S5 |
|
Atomic write (mkstemp + replace) |
S16 |
|
|
TA1 (post-compromise mitigation) |
|
|
TA1 (best-effort) |
|
|
TA1 (no dict/set leakage) |
|
|
side-channel timing |
|
RFC 6979 deterministic signatures |
TA1 (no nonce reuse) |
via |
Low-s normalization |
tx malleability |
via |
|
TA3, TA4 |
|
Response size cap (10 MB) |
TA5 (memory DoS) |
|
Per-id JSON-RPC correlation |
TA5 (response-swap race) |
|
Typed boundary validation (Hex32, Txid, etc.) |
input validation everywhere |
|
Mnemonic input via |
TA2 (echo prevention) |
|
Mnemonic display Enter gate |
TA2 |
|
Mnemonic normalization before BIP39 validation |
TA2 |
|
|
TA2 (footgun in scripts) |
|
Confirmation summary before broadcast |
TA2, TA7 |
|
|
S13 |
|
Static “decrypt failed” message |
TA1 (no input echo) |
|
Wallet save refuses overwrite |
TA2 |
|
Library N5 fix: re-raise NetworkError on scan |
S9 |
|
Library N6 fix: load() raises FileNotFoundError |
TA2 (no silent overwrite) |
|
Two-pass fee with unlock-script reset |
S6 |
|
Multi-source data quorum (detects source disagreement, NOT a self-consistent forgery) |
TA6 |
|
Committed nBits pin enforced in the SPV verifier (the actual forgery defense) |
TA6 |
|
Merkle proof bound to the height-identified header |
TA6 |
|
Confirmation-depth |
TA6 |
|
Sole-authority audit gate — advisory since 0.9.0; does NOT block |
TA6 |
|
SPV verification (Gravity) |
TA6, TA8 |
|
Maker-side counter-funding gate: on-chain scriptPubKey + exact amount + depth, re-derived from the maker’s own terms (both chains) |
S23 |
|
Counter-funding verification re-run at asset-lock time (verify→lock TOCTOU) + non-skippable before |
S23 |
|
Taker-side asset-funding gate: covenant SPK re-derived from the taker’s own terms + exact on-chain value + depth, before any counter leg is locked |
S24 |
|
Asset-funding verification re-run at lock time inside |
S24 |
|
Fee-pool cap charges SPEND, not DISPENSE: a refused (never-broadcast) build returns its cap charge |
S21 |
|
Cold recovery refuses an unbounded fee overpay and a 0-conf covenant parent |
S21 |
|
|
TA6, S23, S24 |
|
An “already known” broadcast rejection is honored only when a read-back produces the same bytes |
TA6 |
|
Gravity red-team test suite |
TA8 |
|
Agent per-spend confirmation on |
S18 |
|
Agent refuses unattributable outputs (non-P2PKH/non-OP_RETURN) so the user always sees a verifiable destination |
S18 |
|
Agent bounds attacker-supplied derivation coords (change∈{0,1}, index≤cap) before any key derivation |
S18 (pre-confirm DoS) |
|
Agent prevout authenticity (source-tx verified, value/script from real prevout) |
S19 |
|
Agent |
S19 |
|
Agent never returns key material (conformance-tested) |
S18 |
|
Agent socket: |
TA1 (other-uid) |
|
Agent lock scrubs the seed (the only long-lived secret — the account xprv is re-derived transiently, never stored, #8/H1) and fails the derivation seam closed; idle auto-lock + on-demand |
A11 window |
|
Agent process hygiene (mlock, PR_SET_DUMPABLE 0, no core dumps; best-effort), applied before the mnemonic prompt so the seed is never in swappable memory |
A11 residency |
|
Min-relay fee floor derived from the REAL serialized size (not a flat dust constant) |
S21 |
|
Deadline-aware pre-broadcast affordability gate (refuse + page, never an unfixable broadcast) |
S21 |
|
CodeQL on every push |
static analysis |
|
Bandit on every push |
security smells |
|
ruff lint + format |
code hygiene |
|
|
known-CVE supply-chain |
|
detect-secrets pre-commit |
committed-secret prevention |
|
100% coverage on |
ensures security primitives are exercised |
CI coverage gate |
85% overall coverage |
structural confidence |
CI coverage gate |
Known gaps¶
Honest list. These are not vulnerabilities; they’re places where pyrxd’s defense ends.
Crypto / library¶
No third-party crypto audit of pyrxd’s integration of underlying primitives.
No formal verification of BIP32/39/44 vectors beyond unit tests. Test vectors come from the BIP specs themselves.
No fuzz testing of the CLI surface. Issue #10.
No timing-attack analysis of pyrxd-internal comparisons beyond known-good
hmac.compare_digestuse.Memory zeroization is best-effort — CPython does not guarantee secure memory. The signing agent’s only resident long-lived secret is the seed (a
SecretBytes), which ISmemseton lock. The account xprv is no longer stored long-lived (hardening #8/H1):HdWallet._xprvis now a property that re-derives the account key from the seed per operation, so on lock the seed is scrubbed and the property fails closed — there is no persistent xprv copy to leak across the unlock window. The residual is now only the transient per-operation copy: while a signature is actively being produced, an account xprv / libsecp256k1 key necessarily exists in memory for that moment (you cannot sign without the key), and CPython cannot overwrite those immutable/C copies in place before GC. 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”. This is the irreducible floor (the key must be usable to sign), not a design gap.
Network¶
Single-source RXD reads (accepted assumption, stated for the auditor). Three RXD-side reads trust a single source by design: (a) the default single ElectrumX endpoint for plain-RXD wallet ops (TA5 has unmitigated reach here — multi-source ElectrumX is not implemented; only the Bitcoin data sources have quorum); (b) the single RXinDexer that resolves Glyph reads and backs the Gravity REF-authenticity gate (
verify_ref_authenticity); (c) single-source RXD funding depth (a SPOF accepted only for dust). This is an accepted assumption, not a missing control: a self-consistent lie is byte-identical from every source, so adding a 2nd source — which only detects disagreement — has bounded value while the operating cost is real. The load-bearing defenses are the on-chain covenant pins (nBits /expectedNBits, the REF-uniqueness consensus rule), not read-side quorum. Standing up a 2nd independent RXD source is the right hardening at first non-dust real value; it is documented here so the audit reviews a stated single-source boundary up front rather than discovering it.Certificate pinning for ElectrumX TLS is opt-in, and off by default. A CA compromise enables TA4 against the default configuration, which relies on the system trust store. Since 0.14.0 an operator may pin SPKI hashes (
src/pyrxd/network/tls_pin.py), and failover holds every endpoint to the same pin set; a pin mismatch fails closed and is never routed around. Unpinned remains the default, so the residual stands for anyone who has not configured pins.The SPV primitive is not a self-sufficient sole authority. It enforces the committed nBits pin and per-header PoW but does not do most-cumulative-work selection or independent network-difficulty oracling (audit F-01). It is safe only behind an on-chain covenant pinning nBits.
require_spv_sole_authority_clearedis advisory and does not block: since 0.9.0 it returns unconditionally, matching the project’s posture that audit gates warn rather than hard-block (Radiant Core itself ships unaudited without blocking mainnet use). A covenant-less use (bridge-in/oracle/gate) is therefore restrained by documentation, not by code — the caller is responsible for not making Python the sole difficulty authority over real value. Seedocs/how-to/spv-verification-pitfalls.md.
CLI¶
Broadcast summary doesn’t show resolved
owner_pkhfrom metadata files. S7 residual risk. Should be addressed before v0.3.0 release.No clipboard hygiene warning. S3 residual risk. Issue #11.
Mnemonic re-entry per command is mitigated by the optional
pyrxd agent(issue #8, Path A′): a sign-on-behalf daemon holds the wallet for an unlock window so the mnemonic is typed once, and the key is removed from the short-lived CLI process entirely (the daemon signs). Residual: while unlocked, a same-uid process can request signatures — gated by per-spend confirmation (S18), never by taking the key. The agent is opt-in; with it off, the per-command prompt (and its S2/S3 residual risk) remains the default. The agent has not had a third-party audit (gap #1 applies).
Protocol¶
Gravity covenant variants flagged “still being hardened” in README. TA8 is the highest-stakes attacker; this is the highest-priority audit target.
dMint V1 deploy + PoW mint: now regtest-consensus-validated. The earlier “documented but not implemented” wording was stale — the builders + reference miner shipped; the real gap was node validation.
tests/test_dmint_v1_regtest_e2e.pyproves on a realradiant-corenode that a pyrxd-built V1 deploy (commit→reveal genesis) and a PoW-mined mint are accepted, a wrong nonce is rejected, and the contract recreates at height+1. Surfaced a consensus requirement the golden vectors missed: V1 contracts MUST be 1-photon singletons (covenant enforcesOP_OUTPUTVALUE==1);build_dmint_mint_txnow rejects non-1 carriers early. dMint V2 is now consensus-validated too (#219): the canonical-Photonic redesign is byte-matched to upstream and accepted onradiant-corev3.1.1 regtest (FIXED + LWMA, with on-chain difficulty advancement) AND Radiant mainnet 3.1.2: the first V2 FIXED deploy + PoW mint confirmed on mainnet (deploy95335028…bb16fb09, mint1239f64a…e0cd6c67), plus an LWMA mint that lowered the recreated target on-chain (MAX → ~MAX/8) exactly matching the off-chain DAA (deploydea3beb9…, minte7b52f16…) — so adaptive difficulty is proven on mainnet, not just regtest. The per-callV2UnvalidatedWarningis no longer emitted, and as of 0.9.0 V2 is the default deploy format:allow_v2_deploydefaults toTrueand the historicalallow_v2_deploy=Falseopt-out is itself deprecated (soft-warns). All five DAA modes are now ported and byte-matched to canonical Photonic, and theglyph deploy-dmint --v2/claim-dmintCLI verbs expose V2 deploy + PoW mint. EPOCH int64-overflow: found, fixed upstream, re-enabled. Differential testing of the ported modes surfaced an int64-overflow in the canonical Photonic EPOCH (and LWMA) bytecode — the on-chain retarget computedtarget × clampedDelta(multiply-first, output capped atMAX_TARGETnot2^48), which exceeds int64 (CScriptNum) for ordinary parameters and aborts the mint withINVALID_NUMBER_RANGE_64_BIT(OP_MUL → safeMul), permanently bricking the contract (a liveness bug, not a theft vector — confirmed againstradiant-coreinterpreter.cpp). EPOCH was temporarily refused at deploy while the canonical bytecode was broken; the fix is now merged upstream (Radiant-Core/Photonic-Wallet#2— EPOCH clamps the target toEPOCH_MAX_SAFE_TARGET(2^48) on both sides of the multiply and divides first, so the intermediate stays ≤ 2^52 for any reachable state; LWMA floorstimeDeltaat 0 viaOP_0 OP_MAX). pyrxd byte-matches the merged canonical (EPOCH+LWMA golden vectors regenerated; the off-chain miner replicascompute_next_target_epoch/_linearupdated to match), andDmintV2DeployParams/ the CLI now acceptDaaMode.EPOCHagain (difficulty ≥ 32768 for the 2^48 cap). The off-chaincurrent_timevalidation (reject backwards/post-2038 locktimes before grinding) is retained as defence-in-depth. Residual: this newer surface is unaudited — verify it yourself before moving real value.No multi-signature support. Single-sig only; users wanting m-of-n must build it themselves.
Supply chain¶
No pinned transitive dependency hashes. A compromised release of
coincurve,Cryptodome, etc. would propagate.pip-auditcatches known CVEs but not zero-days. (Deliberate for a library — pinning transitive hashes over-constrains downstreams.)SBOM now generated. Each GitHub Release attaches a CycloneDX SBOM (
pyrxd-<version>.cdx.json) built from the resolved dependency tree by the publish workflow (.github/workflows/publish.yml).Release artifacts now carry PEP 740 attestations. PyPI 2FA + OIDC Trusted Publishing are on, and the publish action emits per-artifact Sigstore digital attestations (verifiable on the PyPI project page). A gpg-signed git tag / GitHub Release signature is still optional and not set up.
Process¶
Incident-response runbook now exists.
docs/runbooks/incident-response.mddocuments the triage → fix-branch → GitHub Security Advisory / CVE → release → notify flow for a report tosecurity@mudwoodlabs.com.~~No coordinated-disclosure SLA.~~ Resolved:
SECURITY.mdstates the SLA — acknowledge within 2 business days, initial assessment within 7, coordinated disclosure typically within 90 (Project Zero norms).No external eyes. Solo developer; nothing has been reviewed by anyone else. An independent audit is the natural next step before relying on the swap stack for non-dust real value — verify it yourself until then.
Out of scope (explicit non-coverage)¶
We do not protect against:
Coercion / wrench attacks
Physical access to an unlocked machine
Compromised OS, firmware, BIOS, hypervisor
Side channels at the silicon level
Quantum computers (secp256k1 is not post-quantum safe; no chain currently is)
User running the wrong binary (typosquats, malicious forks)
User leaking the mnemonic via channels pyrxd doesn’t see (photographing it, reading it aloud on a podcast, etc.)
User running pyrxd in a hostile container that can read process memory
Future Radiant consensus bugs that invalidate the protocol pyrxd implements
For auditors and security researchers¶
A consolidated security audit scoping brief pulls the in-scope module map, the load-bearing assumptions, the fail-closed opt-in gates, and the complete stable-ID residual register (this doc’s scenarios plus the design-note and in-code residuals) into one place — start there for a commissioned audit.
If you have time and skill to look at pyrxd, here’s where to start, ranked by expected return on investigation:
Gravity covenant code (
src/pyrxd/gravity/) — highest stakes, most complex protocol code. Review focus: SPV proof construction, covenant param validation, sighash flag handling, edge cases intests/test_gravity_red_team.pythat document known concerns.Wallet file format and load path (
src/pyrxd/hd/wallet.py:save/load) — second-highest stakes (key material). Review focus: AEAD construction, mode-bit checks, malformed-JSON guards, the edge between “file decrypts” and “file is structurally valid wallet.”Glyph script construction (
src/pyrxd/glyph/) — lower direct stakes (most attacks here are footguns, not theft) but the metadata-trust issue (S7) is real. Review focus: howowner_pkhpropagates from CBOR to scriptPubKey to broadcast, and what the user actually sees before signing.CLI mnemonic handling (
src/pyrxd/cli/wallet_cmds.py,src/pyrxd/cli/prompts.py) — boring but easy to mess up. Review focus: every code path that touches the mnemonic string, and confirmation that none of them log, copy to dict-keyed structures, or serialize withoutSecretBytes.Network response parsing (
src/pyrxd/network/electrumx.py,src/pyrxd/network/bitcoin.py) — not where private keys live but where lying-server defenses live. Review focus: hex decoding, length checks, content-type validation, response-correlation race window.
If you find something, please report privately to security@mudwoodlabs.com. We don’t pay bounties yet but credit researchers in SECURITY.md and in the changelog.
Revision history¶
2026-08-10 — added S24 (maker never locks the asset and sweeps the taker’s counter leg — hazard HZ-1, the mirror of S23 and the cheaper attack: loss is the FULL counter leg, not a shortfall). The taker’s asset-funding check previously existed only inside the operator scripts, so any caller driving
SwapCoordinatordirectly locked its counter leg without reading the Radiant chain at all; the library now re-derives the covenant scriptPubKey from the taker’s own terms, binds the exact on-chain value and a policy-pinned depth, and re-runs the check at lock time. Also corrected the S21 claim that the claim executor “does not retry into a fee-pool drain” — it does retry, and that is only safe now that a refused build returns its cap charge (CappedFeeWalletSource.release_unspent); the measured drain is recorded inline. Seven new rows in “Controls in place”.2026-08-10 — added S23 (hostile taker under-funds the counter-leg HTLC). Records the BTC arm of the maker-side counter-funding gate, which previously existed only for ETH and only in an operator script for BTC (hazard HZ-3 in
htlc-handshake-wire-format.md); the library now binds scriptPubKey + exact amount + depth on both chains and re-runs the check at asset-lock time. Two new rows in “Controls in place”.2026-08-09 — added S21 (under-fee’d time-critical spend / the 8-hour irreversibility window). Records the verified fact that Radiant supports neither RBF nor CPFP, so fee pre-sizing is the only control, and documents the pre-sizing controls now enforced in
gravity/fee_policy.py,htlc_spend.pyandradiant_leg.py. Also records that BIP125 mempool pinning does not apply to Radiant. Two new rows in “Controls in place”.2026-06-15 — fixed the duplicate gap-
#8numbering: the “Known gaps” list now runs1–20uniquely (the CLIowner_pkhgap moved8→9and the tail shifted+1). Added the consolidated security audit scoping brief (stable residual IDs across this doc, the design notes, and in-code residuals).2026-05-01 v1.0 — initial threat model. Documents v0.3 surface (library + CLI + glyph commands).
Future revisions should bump the version, add an entry, and call out which sections changed.