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:

  1. Make explicit what pyrxd protects, and from whom.

  2. Map every claimed protection to a concrete control in the codebase.

  3. Surface gaps honestly so users, contributors, and (eventually) auditors can see what is and isn’t covered.

  4. 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 wallet.dat

A2

BIP39 seed (PBKDF2 of A1)

64 bytes

In-memory SecretBytes only; never persisted directly

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 (wallet.dat)

AES-GCM-encrypted JSON

Disk at ~/.pyrxd/wallet.dat, mode 0600

A6

Account-level xpub

base58check string

Watch-only-safe; can be exported via wallet export-xpub

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 collect_spendable(); on disk in addresses dict of wallet.dat

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 HdWallet (seed in SecretBytes)

The pyrxd agent daemon process, for the unlock window only; reachable via a 0600 Unix socket

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.

TA7: Hostile metadata file author

Capabilities: Crafts a metadata.json and convinces the user to mint/deploy from it.

Goals: Make the user broadcast a transaction that locks funds or tokens to an attacker key without the user noticing. Crash the CLI with malformed CBOR. Embed a malicious URL or script reference.

Reach: Real and underappreciated. The user types pyrxd glyph mint-nft alice-token.json and trusts that the resulting token belongs to them. If the file’s owner_pkh is the attacker’s, the NFT is the attacker’s.

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”:

  1. 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.

  2. Stdin → mnemonic and passphrase input via click.prompt(hide_input=True). Normalized via _normalize_mnemonic (whitespace collapse). Validated by bip39.validate_mnemonic. Never logged.

  3. Configuration files~/.pyrxd/config.toml parsed by stdlib tomllib. Schema-checked by Config dataclass. Mode permissions on parent dir checked.

  4. Wallet file (wallet.dat) → AES-GCM authenticated decryption; tag mismatch raises before any post-decrypt code runs. File mode checked (0o600 required) before read.

  5. Metadata files (metadata.json) → JSON parsed with stdlib. Protocol names mapped to GlyphProtocol ints. Validated by GlyphMetadata.__post_init__. Cap on payload size enforced by decode_payload.

  6. 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).

  7. Network: HTTP responses from Bitcoin data sources → Content-type checked, size-capped, hex-decoded with explicit length. URL construction uses urllib.parse.quote.

  8. Library API surface (caller → pyrxd) → typed validation at constructors: Hex32, Hex20, Txid, Satoshis, Photons, BlockHeight, Nbits, SighashFlag all reject malformed inputs at construction. PrivateKey, PublicKey validate input bytes/strings.

  9. 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.txt to “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 new in 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 cp or rsync; 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_script between trial and final, then re-signs. Documented in tests/test_preimage.py. Tested in RxdWallet and HdWallet send/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.json from chat. The file contains the attacker’s owner_pkh (or the metadata triggers a tx whose change goes to the attacker). User runs pyrxd glyph mint-nft nft-metadata.json and 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_pkh from the metadata.

    • init-metadata scaffolds 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 MultiSourceBtcDataSource quorum.

  • 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_history for 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_chain re-raises NetworkError on 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.py

    • README 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 coincurve release ships with backdoored signing.

  • Asset: A4, every signature pyrxd produces.

  • Controls: pip-audit in dev deps; coincurve is 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-rxd or pyrxd-tools with 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] in pyproject.toml points 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() uses traceback.format_exception(...) only — never capture_locals=True. Source-line context contains variable names but never values. Tested in test_debug_emits_traceback_on_decrypt_failure: the user’s exact input never appears in result.output.

  • Residual risk: Source line text mentions mnemonic and passphrase as 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_tx and build_send_max_tx validate fee_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 new is interrupted (Ctrl-C, power loss) mid-write.

  • Asset: A5.

  • Control: Atomic write pattern: mkstempfchmod 0o600 → write → fsyncos.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.replace atomicity.

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 SigningRequest to drain the wallet. It passes SO_PEERCRED (same uid) and the 0600/0700 filesystem 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-under threshold 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 and agent lock bound 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|FORKID sighash (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), revealing p (SECRET_REVEALED). The honest taker is then offline, mempool-pinned, or censored across the window from that reveal to t_rxd. At t_rxd the maker CSV-refunds the Radiant covenant → ASSET_VULNERABLEONE_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_rxd to 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 window N is 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 ClaimExecutor now 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 whose output[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 unless enable_autonomous_mainnet_custody=True. Autonomous claim size is bounded by claim_dust_ceiling (a default the operator raises with explicit per-value consent; the blunt accept_unbounded_reorg_risk flag cannot cross it). So R1’s closure for an un-armed or un-wired tower still rests on operator/taker liveness within t_rxd; an armed tower closes it autonomously within the consented value bound. The earlier “no ClaimExecutor in 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) size t_rxd for 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 @ afdf57b1 and the live mainnet node, Radiant Core 3.1.2, 2026-08-09):

    • No RBF. src/validation.cpp:667 and :866 reject any mempool conflict outright (txn-mempool-conflict, REJECT_DUPLICATE). The only bip125 string in the tree is a deprecated help label; there is no bumpfee RPC. Radiant ships DSProof — it treats a conflict as fraud to broadcast, the exact opposite of replacement.

    • No CPFP. getmempoolancestors / getmempooldescendants exist and make it look supported, but src/miner.cpp:404 selects on GetModifiedFeeRate() = (nFee + feeDelta) / vsize — the transaction’s own fee over its own size — and breaks at blockMinFeeRate. The backlog queue is topological ordering only. A high-fee child cannot lift a low-fee parent into a block.

    • The window. DEFAULT_MEMPOOL_EXPIRY = 8 hours (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_rxd elapses, 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.DeadlineFeePolicy derives the requirement as ceil(size × rate / 1000) from the node’s advertised effective_minrelaytxfee (0.10 RXD/kB on the reference node; minrelaytxfee is 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_tx refuse to return an under-fee’d transaction, sized against len(tx.serialize()) — the exact wire bytes, after signing, never an estimate.

    • radiant_leg.RadiantCovenantLeg applies the deadline-aware requirement immediately before broadcast: for a claim, blocks-to-deadline is t_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.py maps it to DECLINED (which pages) and deliberately retries on the next tick, because CappedFeeWalletSource is 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_failure now reports the input back via CappedFeeWalletSource.release_unspent on 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 firstswap_state.py: NEGOTIATED --> BTC_LOCKED : taker funds BTC P2TR HTLC (locks FIRST).

    • The taker’s own refund is the longer timelock — assert_timelock_margin enforces t_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_btc is valid from BTC_LOCKED once t_btc elapses.

    • 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 ABORTED cleanly. 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_btc deliberately, 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 than terms.value_amount. The honest maker locks its Radiant asset, claims the under-funded counter leg (revealing p on chain), and the taker then claims the full asset with that p. 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 with claimant = attacker or 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 agreed amount_sats, and point funding_outpoint at 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 own terms), its value against terms.value_amount exactly (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_funded is the ETH twin.

    • SwapCoordinator.post_asset_lock_revalidate makes the gate non-skippable on both chains: no verified locator on the record ⇒ refuse BOTH_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 requires btc_claim_reorg_depth confirmations on the BTC funding (PoW finality is a depth); the ETH twin pins to the finalized checkpoint.

    • 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) and tests/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 the p it has held since it generated the secret. There is no asset to claim back, and the taker’s own refund does not open until t_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’s NEGOTIATED --> BTC_LOCKED : taker funds FIRST ordering is bookkeeping, not a safety guarantee, and maker_claims_btc refuses 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 in docs/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 driving SwapCoordinator directly got pre_btc_lock_check(...) -> ok=True and taker_funds_btc(...) -> BTC_LOCKED having 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 of pre_btc_lock_check and therefore on every taker_funds_btc.

    • RadiantCovenantLeg.verify_maker_asset_funded — re-derives the covenant scriptPubKey from the taker’s own terms (never one the maker advertises), locates its funded UTXO, binds the on-chain value to terms.radiant_amount exactly, and requires a confirmation depth. “Funded” alone is not enough: ElectrumX listunspent includes 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 the SeenStore reserve so the reserve keeps its “last step before the only broadcast” property and a refusal does not burn H.

    • Depth pin from existing policy: a real-value (MarginPolicy.is_measured) swap requires rxd_claim_burial confirmations 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’s min_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_leg that 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

src/pyrxd/hd/wallet.py:save/load

scrypt KDF (n=2^14)

S4

src/pyrxd/hd/wallet.py:_derive_enc_key

Mode 0o600 enforcement (save)

S1, S5

src/pyrxd/hd/wallet.py:save (mkstemp + fchmod)

Mode 0o600 verification (load)

S5

src/pyrxd/hd/wallet.py:_load_existing

Atomic write (mkstemp + replace)

S16

src/pyrxd/hd/wallet.py:save

SecretBytes repr/copy/pickle disable

TA1 (post-compromise mitigation)

src/pyrxd/security/secrets.py

SecretBytes.zeroize()

TA1 (best-effort)

src/pyrxd/security/secrets.py:zeroize

__hash__ = None on key types

TA1 (no dict/set leakage)

src/pyrxd/keys.py, src/pyrxd/security/secrets.py, src/pyrxd/hd/bip32.py

hmac.compare_digest for key equality

side-channel timing

src/pyrxd/keys.py:PrivateKey.__eq__, src/pyrxd/security/secrets.py:SecretBytes.__eq__

RFC 6979 deterministic signatures

TA1 (no nonce reuse)

via coincurve

Low-s normalization

tx malleability

via coincurve

wss:// enforced; ws:// rejected

TA3, TA4

src/pyrxd/network/electrumx.py URL validation

Response size cap (10 MB)

TA5 (memory DoS)

src/pyrxd/network/electrumx.py, src/pyrxd/network/bitcoin.py

Per-id JSON-RPC correlation

TA5 (response-swap race)

src/pyrxd/network/electrumx.py:_pending

Typed boundary validation (Hex32, Txid, etc.)

input validation everywhere

src/pyrxd/security/types.py

Mnemonic input via click.prompt(hide_input=True)

TA2 (echo prevention)

src/pyrxd/cli/prompts.py

Mnemonic display Enter gate

TA2

src/pyrxd/cli/prompts.py:show_mnemonic

Mnemonic normalization before BIP39 validation

TA2

src/pyrxd/cli/prompts.py:_normalize_mnemonic

--json --yes required for destructive ops

TA2 (footgun in scripts)

src/pyrxd/cli/context.py:is_destructive_mode_safe

Confirmation summary before broadcast

TA2, TA7

src/pyrxd/cli/glyph_cmds.py:_confirm_or_abort

--debug traceback without capture_locals

S13

src/pyrxd/cli/errors.py:CliError.show

Static “decrypt failed” message

TA1 (no input echo)

src/pyrxd/hd/wallet.py:_load_existing, CLI surface

Wallet save refuses overwrite

TA2

src/pyrxd/cli/wallet_cmds.py:wallet_new

Library N5 fix: re-raise NetworkError on scan

S9

src/pyrxd/hd/wallet.py:_scan_chain

Library N6 fix: load() raises FileNotFoundError

TA2 (no silent overwrite)

src/pyrxd/hd/wallet.py:load

Two-pass fee with unlock-script reset

S6

src/pyrxd/wallet.py, src/pyrxd/hd/wallet.py:build_send_tx

Multi-source data quorum (detects source disagreement, NOT a self-consistent forgery)

TA6

src/pyrxd/network/bitcoin.py:MultiSourceBtcDataSource

Committed nBits pin enforced in the SPV verifier (the actual forgery defense)

TA6

src/pyrxd/spv/chain.py:verify_chain, proof.py:CovenantParams.expected_nbits

Merkle proof bound to the height-identified header

TA6

src/pyrxd/spv/proof.py:build(tx_block_height=…)

Confirmation-depth [1,tip] floor + above-dust funding quorum

TA6

src/pyrxd/network/bitcoin.py:get_raw_tx, MultiSourceBtcFundingReader

Sole-authority audit gate — advisory since 0.9.0; does NOT block

TA6

src/pyrxd/spv/proof.py:require_spv_sole_authority_cleared

SPV verification (Gravity)

TA6, TA8

src/pyrxd/spv/, src/pyrxd/gravity/

Maker-side counter-funding gate: on-chain scriptPubKey + exact amount + depth, re-derived from the maker’s own terms (both chains)

S23

src/pyrxd/gravity/swap_coordinator.py:maker_verify_counter_funding, btc_wallet/htlc_leg.py:verify_counterparty_funded, gravity/eth_leg.py:verify_counterparty_funded

Counter-funding verification re-run at asset-lock time (verify→lock TOCTOU) + non-skippable before BOTH_LOCKED

S23

src/pyrxd/gravity/swap_coordinator.py:_assert_btc_counter_funding_verified, _assert_eth_counter_funding_verified

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

src/pyrxd/gravity/swap_coordinator.py:taker_verify_asset_funding, gravity/radiant_leg.py:verify_maker_asset_funded

Asset-funding verification re-run at lock time inside taker_funds_btc (verify→lock TOCTOU) + non-skippable step of pre_btc_lock_check

S24

src/pyrxd/gravity/swap_coordinator.py:pre_btc_lock_check, taker_funds_btc

Fee-pool cap charges SPEND, not DISPENSE: a refused (never-broadcast) build returns its cap charge

S21

src/pyrxd/gravity/capped_fee_source.py:release_unspent, gravity/radiant_leg.py:_unspent_on_failure

Cold recovery refuses an unbounded fee overpay and a 0-conf covenant parent

S21

src/pyrxd/cli/swap_recovery.py:select_fee_utxo, _assert_covenant_confirmed

outspend/tx reads bind the echoed txid, reject non-finite numbers, and treat only spent is False as unspent

TA6, S23, S24

src/pyrxd/network/bitcoin.py:_assert_tx_identity, _finite_int, read_confirmed_unspent_output

An “already known” broadcast rejection is honored only when a read-back produces the same bytes

TA6

src/pyrxd/network/failover.py:_holds_tx

Gravity red-team test suite

TA8

tests/test_gravity_red_team.py (1500+ lines)

Agent per-spend confirmation on /dev/tty, threshold 0 = always confirm incl. self-spends (fails closed w/o tty; utf-8)

S18

src/pyrxd/agent/confirm.py, signer.py

Agent refuses unattributable outputs (non-P2PKH/non-OP_RETURN) so the user always sees a verifiable destination

S18

src/pyrxd/agent/signer.py:_summarize

Agent bounds attacker-supplied derivation coords (change∈{0,1}, index≤cap) before any key derivation

S18 (pre-confirm DoS)

src/pyrxd/agent/signer.py:_check_coords

Agent prevout authenticity (source-tx verified, value/script from real prevout)

S19

src/pyrxd/agent/signer.py:_verify_and_prepare_input

Agent ALL|FORKID-only sighash + fully-owned-only

S19

src/pyrxd/agent/signer.py

Agent never returns key material (conformance-tested)

S18

src/pyrxd/agent/signer.py, tests/test_agent_signer.py

Agent socket: 0700 dir + 0600 socket + SO_PEERCRED uid==owner; per-conn recv timeout (anti-slow-loris)

TA1 (other-uid)

src/pyrxd/agent/daemon.py:_bind/_read_peer_uid/_serve_conn

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 lock + SIGTERM/SIGHUP/atexit

A11 window

src/pyrxd/agent/daemon.py:lock, hd/wallet.py:zeroize/_xprv, cli/agent_cmds.py

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

src/pyrxd/agent/hygiene.py, cli/agent_cmds.py:agent_unlock

Min-relay fee floor derived from the REAL serialized size (not a flat dust constant)

S21

src/pyrxd/gravity/fee_policy.py, htlc_spend.py:_assert_fee_clears_relay_floor

Deadline-aware pre-broadcast affordability gate (refuse + page, never an unfixable broadcast)

S21

src/pyrxd/gravity/radiant_leg.py:_assert_affordable, watch/claim_executor.py

CodeQL on every push

static analysis

.github/workflows/codeql.yml

Bandit on every push

security smells

.github/workflows/ci.yml

ruff lint + format

code hygiene

.github/workflows/lint.yml

pip-audit (dev dep)

known-CVE supply-chain

pyproject.toml

detect-secrets pre-commit

committed-secret prevention

.pre-commit-config.yaml

100% coverage on pyrxd.security

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

  1. No third-party crypto audit of pyrxd’s integration of underlying primitives.

  2. No formal verification of BIP32/39/44 vectors beyond unit tests. Test vectors come from the BIP specs themselves.

  3. No fuzz testing of the CLI surface. Issue #10.

  4. No timing-attack analysis of pyrxd-internal comparisons beyond known-good hmac.compare_digest use.

  5. 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 IS memset on lock. The account xprv is no longer stored long-lived (hardening #8/H1): HdWallet._xprv is 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

  1. 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.

  2. 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.

  3. 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_cleared is 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. See docs/how-to/spv-verification-pitfalls.md.

CLI

  1. Broadcast summary doesn’t show resolved owner_pkh from metadata files. S7 residual risk. Should be addressed before v0.3.0 release.

  2. No clipboard hygiene warning. S3 residual risk. Issue #11.

  3. 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

  1. Gravity covenant variants flagged “still being hardened” in README. TA8 is the highest-stakes attacker; this is the highest-priority audit target.

  2. 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.py proves on a real radiant-core node 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 enforces OP_OUTPUTVALUE==1); build_dmint_mint_tx now rejects non-1 carriers early. dMint V2 is now consensus-validated too (#219): the canonical-Photonic redesign is byte-matched to upstream and accepted on radiant-core v3.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 (deploy 95335028…bb16fb09, mint 1239f64a…e0cd6c67), plus an LWMA mint that lowered the recreated target on-chain (MAX → ~MAX/8) exactly matching the off-chain DAA (deploy dea3beb9…, mint e7b52f16…) — so adaptive difficulty is proven on mainnet, not just regtest. The per-call V2UnvalidatedWarning is no longer emitted, and as of 0.9.0 V2 is the default deploy format: allow_v2_deploy defaults to True and the historical allow_v2_deploy=False opt-out is itself deprecated (soft-warns). All five DAA modes are now ported and byte-matched to canonical Photonic, and the glyph deploy-dmint --v2 / claim-dmint CLI 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 computed target × clampedDelta (multiply-first, output capped at MAX_TARGET not 2^48), which exceeds int64 (CScriptNum) for ordinary parameters and aborts the mint with INVALID_NUMBER_RANGE_64_BIT (OP_MUL safeMul), permanently bricking the contract (a liveness bug, not a theft vector — confirmed against radiant-core interpreter.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 to EPOCH_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 floors timeDelta at 0 via OP_0 OP_MAX). pyrxd byte-matches the merged canonical (EPOCH+LWMA golden vectors regenerated; the off-chain miner replicas compute_next_target_epoch/_linear updated to match), and DmintV2DeployParams / the CLI now accept DaaMode.EPOCH again (difficulty ≥ 32768 for the 2^48 cap). The off-chain current_time validation (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.

  3. No multi-signature support. Single-sig only; users wanting m-of-n must build it themselves.

Supply chain

  1. No pinned transitive dependency hashes. A compromised release of coincurve, Cryptodome, etc. would propagate. pip-audit catches known CVEs but not zero-days. (Deliberate for a library — pinning transitive hashes over-constrains downstreams.)

  2. 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).

  3. 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

  1. Incident-response runbook now exists. docs/runbooks/incident-response.md documents the triage → fix-branch → GitHub Security Advisory / CVE → release → notify flow for a report to security@mudwoodlabs.com.

  2. ~~No coordinated-disclosure SLA.~~ Resolved: SECURITY.md states the SLA — acknowledge within 2 business days, initial assessment within 7, coordinated disclosure typically within 90 (Project Zero norms).

  3. 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:

  1. 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 in tests/test_gravity_red_team.py that document known concerns.

  2. 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.”

  3. 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: how owner_pkh propagates from CBOR to scriptPubKey to broadcast, and what the user actually sees before signing.

  4. 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 without SecretBytes.

  5. 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 SwapCoordinator directly 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.py and radiant_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-#8 numbering: the “Known gaps” list now runs 1–20 uniquely (the CLI owner_pkh gap moved 8→9 and 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.