pyrxd.gravity — Cross-chain atomic swaps

pyrxd.gravity — Gravity protocol covenant transaction builders and orchestrator.

Phase 3a implements the Radiant-side covenant transaction builders that correspond to the JS prototype’s claim_tx.js, finalize_tx.js, and forfeit_tx.js.

Phase 3b adds the high-level GravityTrade orchestrator that wraps the full 4-step BTC↔RXD swap into a single async class.

Public surface

  • GravityOffer — all Maker-committed parameters for a covenant

  • ClaimResult — output of build_claim_tx()

  • FinalizeResult — output of build_finalize_tx()

  • ForfeitResult — output of build_forfeit_tx()

  • build_claim_tx — spend MakerOffer → create MakerClaimed UTXO

  • build_finalize_tx — spend MakerClaimed → release photons to Taker

  • build_forfeit_tx — Maker reclaims after claimDeadline

  • compute_p2sh_code_hash — derive the expectedClaimedCodeHash a covenant checks

  • GravityTrade — high-level async swap orchestrator (Phase 3b)

  • TradeConfig — tunable parameters for GravityTrade

  • ConfirmationStatus— BTC confirmation poll result

class pyrxd.gravity.ActiveOffer[source]

Bases: object

State of a live Gravity MakerOffer on Radiant.

Returned by GravityMakerSession.create_offer() and required by all subsequent lifecycle methods.

offer

The original GravityOffer covenant parameters.

Type:

pyrxd.gravity.types.GravityOffer

maker_offer_result

Raw tx details from build_maker_offer_tx.

Type:

pyrxd.gravity.types.MakerOfferResult

offer_txid

Radiant txid of the confirmed MakerOffer funding output.

Type:

str

offer_vout

Output index of the MakerOffer P2SH UTXO (always 0).

Type:

int

offer_photons

Photons locked in the MakerOffer P2SH output.

Type:

int

__init__(offer, maker_offer_result, offer_txid, offer_vout, offer_photons)
Parameters:
Return type:

None

offer: GravityOffer
maker_offer_result: MakerOfferResult
offer_txid: str
offer_vout: int
offer_photons: int
class pyrxd.gravity.CappedFeeWalletSource[source]

Bases: object

A capped FeeUtxoSource over a fixed pre-funded pool.

Parameters:
  • pool – The pre-funded inventory: small plain-RXD FeeInput UTXOs the capped-pool wallet owns. Each must be a bare P2PKH UTXO whose pkh matches its own WIF (validated). Must be non-empty and free of duplicate outpoints (a duplicate would double-spend).

  • total_cap_photons – Hard cumulative ceiling on dispensed value. Dispensing stops once the next input would push the running total over this — before handing it out.

  • max_per_input_photons – Optional per-input ceiling. If given, construction fails when any pool UTXO exceeds it, keeping the “a fee input is small” invariant structural rather than assumed.

__init__(pool, *, total_cap_photons, max_per_input_photons=None)[source]
Parameters:
Return type:

None

property dispensed_photons: int

Cumulative value handed out so far.

property funded_photons: int

Total value of the pre-funded pool. This is the ceiling only if the pool key is isolated from the operator’s main wallet (a deployment property this class cannot verify — see the module docstring and the design note’s residuals).

next_fee_input()[source]

Dispense (commit) the next pool UTXO.

Raises FeePoolExhaustedError — fail-closed — when the pool is empty or the next input would exceed total_cap_photons. Dispense-once: the returned UTXO is never returned again.

Return type:

FeeInput

release_unspent(fee_input)[source]

Credit back the cap charge for a dispensed input that was never broadcast.

The cap authorises spend, but next_fee_input() had to charge it at dispense — and the caller dispenses before it can know whether the spend will build. A build that refuses (the fee is below the node’s deadline-aware relay floor) puts nothing on-chain and pays no fee, yet left the charge standing: repeated refusals ate the budget the covering input needed, and the pool exhausted with a funded, unspendable UTXO still in it while the asset ran out its deadline (audit B3).

Returns True when the charge was credited, False when this input was already credited (idempotent — a second credit would fabricate budget). Raises ValidationError for anything this source has not dispensed.

The cursor is deliberately not rewound. Dispense-once is the property that stops one UTXO ever backing two transactions, and it must survive the un-charge: the released input is retired, and the next dispense moves on to the next one — which is also what stops a small head-of-line input from re-refusing forever while a covering input sits behind it.

Only the caller can know whether it broadcast, so this is a report, not an inference. Call it exactly on the paths where the dispensed input provably never reached a node.

Parameters:

fee_input (FeeInput)

Return type:

bool

property remaining_inputs: int

Count of pool UTXOs not yet dispensed (physical inventory; some may be blocked by the cap — see remaining_photons for the actually-spendable budget).

property remaining_photons: int

Photons that next_fee_input() will actually dispense from here — the in-order prefix of remaining inputs that fits under the cap. Dispensing is in-order and stops at the first input that would exceed the cap (head-of-line), so this is 0 once the next input no longer fits, giving a tower an honest “page now” signal that matches dispense behaviour.

property total_cap_photons: int

The configured cumulative software ceiling.

class pyrxd.gravity.ClaimResult[source]

Bases: object

ClaimResult(tx_hex: ‘str’, txid: ‘str’, tx_size: ‘int’, offer_p2sh: ‘str’, claimed_p2sh: ‘str’, fee_sats: ‘int’, output_photons: ‘int’)

__init__(tx_hex, txid, tx_size, offer_p2sh, claimed_p2sh, fee_sats, output_photons)
Parameters:
  • tx_hex (str)

  • txid (str)

  • tx_size (int)

  • offer_p2sh (str)

  • claimed_p2sh (str)

  • fee_sats (int)

  • output_photons (int)

Return type:

None

tx_hex: str
txid: str
tx_size: int
offer_p2sh: str
claimed_p2sh: str
fee_sats: int
output_photons: int
class pyrxd.gravity.ConfirmationStatus[source]

Bases: object

Status returned by GravityTrade.wait_confirmations().

__init__(txid, confirmations, confirmed, block_height)
Parameters:
  • txid (str)

  • confirmations (int)

  • confirmed (bool)

  • block_height (int | None)

Return type:

None

txid: str
confirmations: int
confirmed: bool
block_height: int | None
class pyrxd.gravity.CovenantArtifact[source]

Bases: object

A loaded, pre-compiled covenant artifact with parameter substitution.

__init__(contract, hex_template, abi)
Parameters:
Return type:

None

constructor_params()[source]

Return the constructor ABI params in declaration order.

Return type:

list[dict]

classmethod from_json(json_text, *, allow_legacy=False)[source]

Load from raw artifact JSON (e.g. from a custom compiled artifact).

Parameters:
  • json_text (str)

  • allow_legacy (bool)

Return type:

CovenantArtifact

classmethod load(name, *, allow_legacy=False)[source]

Load a bundled artifact by stem name (without .artifact.json).

Available artifacts: - maker_offer - maker_covenant_6x12_p2wpkh - maker_covenant_flat_6x12_p2wpkh - maker_covenant_trade

Parameters:
Return type:

CovenantArtifact

substitute(params)[source]

Substitute constructor params into the hex template.

Returns the full redeem script bytes. Raises ValidationError if any required param is missing, any placeholder remains unfilled, or any fixed-width typed param (Ripemd160 / Sha256 / PubKey) has the wrong byte length — these would silently encode as the wrong push and produce an on-chain-rejected covenant.

Values: - int params: pass Python int - bytes/Ripemd160/Sha256/PubKey params: pass hex string

Parameters:

params (dict[str, Any])

Return type:

bytes

contract: str
hex_template: str
abi: list[dict]
class pyrxd.gravity.FeeInput[source]

Bases: object

A plain-RXD P2PKH UTXO that pays the miner fee for an HTLC spend.

The single covenant output carries the asset and cannot also pay the fee, so every HTLC spend joins a fee input the spender owns. The whole surplus (value - out0_value) is consumed as the miner fee — there is no change output (the covenant forbids a second output), so value IS the miner fee. Size it upstream to clear the per-kB min-relay fee without being wastefully large. Both ends are bounded, differently on purpose: the builders REFUSE an under-fee’d transaction (_assert_fee_clears_relay_floor()), because Radiant has neither RBF nor CPFP and an under-fee’d time-critical spend cannot be repaired after broadcast; they only WARN on a wildly over-large one (_warn_if_fee_is_an_overpay()), because refusing a spend the node would have accepted can forfeit the asset to the counterparty’s refund.

__init__(txid, vout, value, scriptpubkey, wif)
Parameters:
Return type:

None

txid: str
vout: int
value: int
scriptpubkey: bytes
wif: str
class pyrxd.gravity.FinalizeResult[source]

Bases: object

FinalizeResult(tx_hex: ‘str’, txid: ‘str’, tx_size: ‘int’, fee_sats: ‘int’, output_photons: ‘int’)

__init__(tx_hex, txid, tx_size, fee_sats, output_photons)
Parameters:
  • tx_hex (str)

  • txid (str)

  • tx_size (int)

  • fee_sats (int)

  • output_photons (int)

Return type:

None

tx_hex: str
txid: str
tx_size: int
fee_sats: int
output_photons: int
class pyrxd.gravity.ForfeitResult[source]

Bases: object

ForfeitResult(tx_hex: ‘str’, txid: ‘str’, tx_size: ‘int’, fee_sats: ‘int’, output_photons: ‘int’)

__init__(tx_hex, txid, tx_size, fee_sats, output_photons)
Parameters:
  • tx_hex (str)

  • txid (str)

  • tx_size (int)

  • fee_sats (int)

  • output_photons (int)

Return type:

None

tx_hex: str
txid: str
tx_size: int
fee_sats: int
output_photons: int
class pyrxd.gravity.GravityMakerSession[source]

Bases: object

Manage the full lifecycle of a Gravity BTC↔RXD atomic swap offer.

This class handles the Maker’s side of the swap:

  1. Build and broadcast the MakerOffer tx (create_offer).

  2. Poll for the Taker’s claim (wait_for_claim).

  3. Broadcast a cancel tx if the Taker never claims (cancel_offer).

  4. Query current state (check_status).

Parameters:
  • rxd_client – Connected ElectrumXClient for Radiant chain operations (broadcast, query UTXOs).

  • btc_source – A BtcDataSource — used only by subclasses / extensions that need BTC confirmation data. May be None for pure Radiant operations.

  • maker_priv – Maker’s secp256k1 private key wrapped in PrivateKeyMaterial.

  • poll_interval_seconds – Seconds between UTXO polls in wait_for_claim. Default 30.

  • fee_policy – Min-relay rate every transaction this session builds is sized and checked against. Defaults to DEFAULT_RADIANT_DEADLINE_FEE_POLICY (mainnet). Set this on regtest, whose node advertises a tenth of the mainnet floor — without it the high-level API has no way to reach the escape hatch the builders already accept.

Examples

Typical Maker flow:

async with ElectrumXClient(["wss://electrumx.example.com"]) as rxd:
    session = GravityMakerSession(rxd_client=rxd, maker_priv=priv)
    params = GravityOfferParams(
        offer=offer,
        funding_txid="...",
        funding_vout=0,
        funding_photons=12_000_000,
        fee_sats=2_500_000,  # ~250-byte funding tx at the 10_000 photons/byte floor
    )
    active = await session.create_offer(params)
    claim_txid = await session.wait_for_claim(active, timeout_seconds=3600)
    if claim_txid is None:
        # fee omitted: sized from the cancel tx's own measured bytes.
        cancel_txid = await session.cancel_offer(active, maker_address=maker_addr)
__init__(rxd_client, maker_priv, btc_source=None, poll_interval_seconds=30, fee_policy=None)[source]
Parameters:
Return type:

None

async cancel_offer(offer, fee_sats=None, maker_address='', fee_policy=None)[source]

Broadcast the cancel (MakerOffer.cancel()) transaction.

Reclaims the MakerOffer UTXO before the claim deadline using build_cancel_tx. This is only valid if the Taker has NOT yet claimed the UTXO.

Parameters:
  • offer (ActiveOffer) – The ActiveOffer to cancel.

  • fee_sats (int | None) – Miner fee in photons for the cancel tx. None (the default) sizes it from the assembled transaction’s own bytes at the relay floor — the only correct default, because the cancel scriptSig carries the whole MakerOffer redeem script and its size therefore varies per offer. This parameter previously defaulted to 1000, ~2,840x under the floor for a 285-byte cancel, which made the documented cancel_offer(active) flow raise on first use and left the Maker with no revocation path.

  • maker_address (str) – Maker’s Radiant P2PKH address to receive the reclaimed photons. Required — must be a valid Radiant address.

  • fee_policy (DeadlineFeePolicy | None) – Per-call override of the session’s policy. Set on regtest, which advertises a tenth of the mainnet relay floor.

Returns:

The cancel tx’s txid.

Return type:

str

Raises:
async check_status(offer)[source]

Return the current status of the offer UTXO.

Queries the Radiant ElectrumX server for the MakerOffer P2SH UTXO.

Returns one of:

  • "open" — UTXO is still unspent (offer not yet claimed).

  • "claimed" — UTXO no longer in unspent set (Taker has claimed).

  • "expired" — claim_deadline has passed and UTXO is unspent

    (Maker can now forfeit).

  • "unknown" — UTXO not found and not yet past deadline

    (may be unconfirmed or already finalized/cancelled).

Parameters:

offer (ActiveOffer) – The ActiveOffer to check.

Returns:

One of "open", "claimed", "expired", "unknown".

Return type:

str

Raises:

NetworkError – On ElectrumX query failure.

async create_offer(offer_params)[source]

Build and broadcast the MakerOffer funding tx.

The offer UTXO is a P2SH output locked to offer_params.offer’s MakerOffer covenant. Once broadcast, the Taker can claim it by spending it with build_claim_tx.

Parameters:

offer_params (GravityOfferParams) – Funding-UTXO details and the GravityOffer covenant.

Returns:

Populated with the resulting txid and UTXO details.

Return type:

ActiveOffer

Raises:
async wait_for_claim(offer, timeout_seconds=3600, *, clock=<built-in function monotonic>)[source]

Poll for the Taker’s claim transaction.

Polls get_utxos() on the MakerOffer P2SH script hash. When the UTXO disappears from the unspent set the Taker has claimed it.

This method cannot directly return the claim txid — ElectrumX’s listunspent API only reports which UTXOs are currently unspent. Once the offer UTXO is spent (claimed), we return the offer’s txid as a sentinel so the caller knows which offer was claimed. Callers that need the actual claim txid should fetch the spending tx separately (e.g. via get_transaction on the address history).

Parameters:
  • offer (ActiveOffer) – The ActiveOffer returned by create_offer.

  • timeout_seconds (int) – Maximum seconds to wait, as WALL-CLOCK seconds. Returns None on timeout.

  • clock (Callable[[], float]) – Monotonic-ish time source. Injectable so the timeout branch is reachable in a test without sleeping — the same shape pyrxd.network.confirm uses, and for the same reason: a fake sleep does not advance a real clock.

Returns:

The offer txid (as a claimed-sentinel) on success, or None on timeout.

Return type:

str or None

class pyrxd.gravity.GravityOffer[source]

Bases: object

All parameters a Maker commits into a MakerOffer covenant.

Mirrors CovenantParams in pyrxd.spv.proof but adds Radiant-side fields and the two precomputed redeem scripts.

__init__(btc_receive_hash, btc_receive_type, btc_satoshis, chain_anchor, anchor_height, merkle_depth, taker_radiant_pkh, claim_deadline, photons_offered, offer_redeem_hex, claimed_redeem_hex, expected_code_hash_hex, expected_nbits=None, expected_nbits_next=None)
Parameters:
  • btc_receive_hash (bytes)

  • btc_receive_type (str)

  • btc_satoshis (int)

  • chain_anchor (bytes)

  • anchor_height (int)

  • merkle_depth (int)

  • taker_radiant_pkh (bytes)

  • claim_deadline (int)

  • photons_offered (int)

  • offer_redeem_hex (str)

  • claimed_redeem_hex (str)

  • expected_code_hash_hex (str)

  • expected_nbits (bytes | None)

  • expected_nbits_next (bytes | None)

Return type:

None

expected_nbits: bytes | None = None
expected_nbits_next: bytes | None = None
validate_deadline_from_now(accept_short_deadline=False)[source]

Check that claim_deadline is at least MIN_DEADLINE_FROM_NOW_HOURS from now.

Raises ValidationError unless accept_short_deadline is True (audit 04-S1 guard: Taker needs time to confirm BTC + build SPV proof + finalize on Radiant).

Parameters:

accept_short_deadline (bool)

Return type:

None

btc_receive_hash: bytes
btc_receive_type: str
btc_satoshis: int
chain_anchor: bytes
anchor_height: int
merkle_depth: int
taker_radiant_pkh: bytes
claim_deadline: int
photons_offered: int
offer_redeem_hex: str
claimed_redeem_hex: str
expected_code_hash_hex: str
class pyrxd.gravity.GravityOfferParams[source]

Bases: object

Parameters required to create a new Gravity MakerOffer.

These are the funding-UTXO details for the Maker’s side. The GravityOffer itself (covenant bytecode, BTC-side params, etc.) is built externally (e.g. via build_gravity_offer) and passed as offer.

offer

Fully populated GravityOffer with offer_redeem_hex set.

Type:

pyrxd.gravity.types.GravityOffer

funding_txid

Hex txid of the Maker’s P2PKH UTXO being spent to fund the offer.

Type:

str

funding_vout

Output index of the Maker’s funding UTXO.

Type:

int

funding_photons

Value of the Maker’s funding UTXO in photons.

Type:

int

fee_sats

Miner fee in photons for the MakerOffer funding tx.

Type:

int

change_address

Optional Radiant P2PKH address for change output. See build_maker_offer_tx for semantics.

Type:

str | None

__init__(offer, funding_txid, funding_vout, funding_photons, fee_sats, change_address=None)
Parameters:
Return type:

None

change_address: str | None = None
offer: GravityOffer
funding_txid: str
funding_vout: int
funding_photons: int
fee_sats: int
class pyrxd.gravity.GravityTrade[source]

Bases: object

Orchestrate a complete Gravity BTC↔RXD atomic swap.

Parameters:
  • radiant_network – Connected ElectrumXClient for Radiant chain operations (broadcast, fetch tx/block).

  • bitcoin_source – A BtcDataSource for Bitcoin chain data (tx fetch, Merkle proof, block headers).

  • config – Optional TradeConfig. Uses defaults if not provided.

Examples

Typical Taker flow:

async with ElectrumXClient(["wss://electrumx.example.com"]) as rxd:
    trade = GravityTrade(radiant_network=rxd, bitcoin_source=btc_src)
    claim = await trade.claim(
        offer=offer,
        offer_txid="...",
        offer_vout=0,
        offer_photons=10_000_000,
        # Photons, at the 10,000/byte mainnet floor: size it from the tx you
        # actually build. These are worked examples, not constants to copy.
        fee_sats=3_000_000,  # ~300-byte claim
        taker_privkey=privkey,
    )
    btc_txid = "..."  # broadcast BTC payment externally
    status = await trade.wait_confirmations(btc_txid)
    result = await trade.finalize(
        btc_txid=btc_txid,
        offer=offer,
        claimed_txid=claim.txid,
        claimed_vout=0,
        claimed_photons=claim.output_photons,
        taker_address="...",
        fee_sats=30_000_000,  # finalize carries the SPV proof: ~10x the claim
    )
__init__(*, radiant_network, bitcoin_source, config=None)[source]
Parameters:
Return type:

None

async claim(offer, offer_txid, offer_vout, offer_photons, fee_sats, taker_privkey, fee_policy=None)[source]

Spend the MakerOffer UTXO, creating a MakerClaimed UTXO.

Broadcasts the claim transaction to the Radiant network and returns a ClaimResult.

The claim transaction requires Taker’s signature (audit 04-S3). build_claim_tx independently verifies the code hash before signing (audit 05-F-13).

Parameters:
  • offer (GravityOffer) – The GravityOffer posted by the Maker.

  • offer_txid (str) – Radiant txid of the MakerOffer funding output.

  • offer_vout (int) – Output index of the MakerOffer UTXO.

  • offer_photons (int) – Value of the MakerOffer UTXO in photons.

  • fee_sats (int) – Radiant miner fee in photons. Must clear the relay floor for the assembled transaction’s real size — at the mainnet floor of 10,000 photons/byte a ~300-byte claim needs ~3,000,000 photons, not the 1000 this docstring used to show.

  • taker_privkey (PrivateKeyMaterial) – Taker’s secp256k1 private key.

  • fee_policy (DeadlineFeePolicy | None) – Per-call override of TradeConfig.fee_policy.

Return type:

ClaimResult

async finalize(btc_txid, offer, claimed_txid, claimed_vout, claimed_photons, taker_address, fee_sats, btc_tx_height=None, fee_policy=None)[source]

Fetch the BTC SPV proof, verify it, and broadcast the finalize tx.

This method always runs the full SpvProofBuilder verifier chain — there is no way to bypass verification at this level.

Parameters:
  • btc_txid (str) – Bitcoin transaction ID of the Taker’s BTC payment.

  • offer (GravityOffer) – The GravityOffer originally posted by the Maker. Used to construct CovenantParams for SPV proof verification.

  • claimed_txid (str) – Radiant txid of the MakerClaimed UTXO (output of claim()).

  • claimed_vout (int) – Output index of the MakerClaimed UTXO.

  • claimed_photons (int) – Value of the MakerClaimed UTXO in photons.

  • taker_address (str) – Taker’s Radiant P2PKH address to receive the photons.

  • fee_sats (int) – Radiant miner fee in photons. The finalize tx is by far the largest in this module — it pushes the whole BTC transaction, N block headers and the Merkle branch into one scriptSig — so its relay floor is an order of magnitude above the claim’s. A fee that was ample for a claim is nowhere near enough here.

  • btc_tx_height (int | None) – Optional: Bitcoin block height where btc_txid was confirmed. If not provided, the orchestrator will determine it automatically.

  • fee_policy (DeadlineFeePolicy | None) – Per-call override of TradeConfig.fee_policy.

Raises:
Return type:

FinalizeResult

async wait_confirmations(btc_txid, min_confirmations=None)[source]

Poll Bitcoin until btc_txid reaches the required confirmations.

Parameters:
  • btc_txid (str) – Bitcoin transaction ID (64 hex chars, big-endian).

  • min_confirmations (int | None) – Override config.min_btc_confirmations for this call. Bound the same way the config field is (>= 1), which it was not: TradeConfig has refused min_btc_confirmations < 1 since it was written, but the per-call override went straight to the source. Measured against a source holding the tx in the mempool only, min_confirmations=0 returned confirmed=True, confirmations=0 on the FIRST poll and -5 returned confirmations=-5 — a “confirmed” verdict for a transaction with no depth at all, handed to the caller who is about to release the other leg. Zero is not a weaker policy here, it is no policy: the depth this waits for MUST equal the covenant’s header-depth N.

Returns:

Always has confirmed=True on return (raises on timeout).

Return type:

ConfirmationStatus

Raises:
  • NetworkError – If polling exceeds config.max_poll_attempts.

  • ValidationError – If btc_txid is not a valid 64-char hex string, or min_confirmations is given and is below 1.

class pyrxd.gravity.HtlcCovenant[source]

Bases: object

A built HTLC covenant: the funded SPK + the bindings a spend must satisfy.

variant

“ft” | “nft” | “rxd”.

Type:

str

funded_spk

The scriptPubKey of the covenant UTXO the maker locks the asset into.

Type:

bytes

prologue_len

Length of the compiled body (== len(funded_spk) for NFT/RXD; the offset of the FT epilogue weld for FT). The bare-0xbd guard pins to this.

Type:

int

taker_holder_script / maker_holder_script

The holder scripts output[0] of a claim (taker) / refund (maker) must equal; the covenant binds hash256 of each.

expected_taker_hash / expected_maker_hash

hash256(taker_holder_script) / hash256(maker_holder_script) — the values baked into the covenant.

genesis_ref

The 36-byte genesis outpoint ref (FT/NFT); b"" for RXD.

Type:

bytes

hashlock

The 32-byte H = SHA256(p).

Type:

bytes

refund_csv

The relative-timelock block count for the refund branch.

Type:

int

__init__(variant, funded_spk, prologue_len, taker_holder_script, maker_holder_script, expected_taker_hash, expected_maker_hash, genesis_ref, hashlock, refund_csv)
Parameters:
Return type:

None

variant: str
funded_spk: bytes
prologue_len: int
taker_holder_script: bytes
maker_holder_script: bytes
expected_taker_hash: bytes
expected_maker_hash: bytes
genesis_ref: bytes
hashlock: bytes
refund_csv: int
class pyrxd.gravity.MakerOfferResult[source]

Bases: object

Output of build_maker_offer_tx() — the MakerOffer funding tx.

__init__(tx_hex, txid, tx_size, offer_p2sh, fee_sats, output_photons)
Parameters:
  • tx_hex (str)

  • txid (str)

  • tx_size (int)

  • offer_p2sh (str)

  • fee_sats (int)

  • output_photons (int)

Return type:

None

tx_hex: str
txid: str
tx_size: int
offer_p2sh: str
fee_sats: int
output_photons: int
class pyrxd.gravity.OfferReceive[source]

Bases: object

A per-offer BTC receive destination derived from a maker account xpub.

Persist offer_index with the offer: the maker needs it to (a) spend the received BTC via the matching child key and (b) never reuse it for another live offer.

__init__(btc_receive_hash, btc_receive_type, offer_index)
Parameters:
  • btc_receive_hash (bytes)

  • btc_receive_type (str)

  • offer_index (int)

Return type:

None

btc_receive_hash: bytes
btc_receive_type: str
offer_index: int
class pyrxd.gravity.RadiantChainIO[source]

Bases: object

Thin chain helper over an ElectrumXClient-like object.

Provides exactly what the leg needs: broadcast, confirmation depth, and the on-chain value of a covenant output. NOT unified with GravityTrade (that drives the SPV-oracle finalize swap, a different protocol).

The injected client must expose broadcast(raw)->txid, get_transaction_verbose(txid)->dict (with confirmations), and get_utxos(script_hash)->list (records with tx_hash/tx_pos/value).

__init__(client)[source]
Parameters:

client (Any)

Return type:

None

async broadcast(raw_tx)[source]
Parameters:

raw_tx (bytes)

Return type:

str

async confirmations(txid)[source]
Parameters:

txid (str)

Return type:

Confirmations

async covenant_unspent_incl_mempool(outpoint)[source]

Mempool-AWARE liveness of a covenant outpoint — the complement to find_covenant_utxo’s mempool-BLIND scantxoutset scan.

True = unspent considering the mempool; False = spent (confirmed OR by a PENDING mempool tx); None = the client cannot answer (the caller keeps its own idempotency guard). Lets the autonomous claim executor treat a covenant already spent IN THE MEMPOOL as claimed — killing the per-tick re-carve drain WITHOUT a durable cross-restart store and WITHOUT the SeenStore’s eviction blind spot (a truly-unspent covenant, e.g. after a claim is evicted by a reorg, correctly re-fires).

Parameters:

outpoint (str)

Return type:

bool | None

async find_covenant_utxo(spk, *, expected_value=None, pin_outpoint=None)[source]

Locate the funded covenant UTXO for spk -> (outpoint, value, height).

Scans the UTXO set of the covenant scriptPubKey (ElectrumX script-hash = sha256(spk) reversed). The HONEST funding is one output, but the SPK is a pure function of PUBLIC negotiated terms, so anyone can pay it and the scan can return several — see the len(utxos) > 1 branch below, which SELECTS the earliest-confirmed match rather than refusing (refusing on ambiguity is a denial anyone can trigger). If expected_value is given, a match must equal it (a wrong value is a mis-funded covenant -> fail-closed); pin_outpoint, once known, selects instead of re-discovering. The returned value is the ON-CHAIN value, never a self-report.

THIS SAID “the covenant funds exactly one output, so there is one matching UTXO”, one screen above the address-poisoning branch that exists because that is not true.

UNITS. expected_value is a PhotonValue because it is matched against the UTXO’s NATIVE carrier value. It is NOT a Glyph FT token count: a token covenant enforces refValueSum(ref) == amount and its carrier can be dust of any size, so an FT amount passed here is a units error (#505). The third element is a ChainHeight — the height the covenant was MINED at, never a confirmation depth; the two sort in opposite directions and the earliest-confirmed rule below inverts under the wrong one.

Parameters:
  • spk (bytes)

  • expected_value (PhotonValue | None)

  • pin_outpoint (str | None)

Return type:

tuple[str, PhotonValue, ChainHeight]

class pyrxd.gravity.RadiantCovenantLeg[source]

Bases: object

The concrete Radiant radiant_leg (HTLC covenant claim/refund).

Parameters:
  • network – Radiant network tag (regtest test chains bypass the audit gate).

  • maker_pkh (taker_pkh /) – The taker (claim) and maker (refund) Radiant holder pubkey-hashes. The covenant binds hash256(holder(pkh)); these must reproduce the terms’ taker_dest_hash/maker_dest_hash (asserted in expected_covenant_scriptpubkey()).

  • chain_io – A RadiantChainIO (broadcast + confirmations + UTXO value).

  • fee_source – A FeeUtxoSource supplying the fee input for each spend.

  • min_confirmations – Confirmations required before the funded covenant value is trusted.

  • audit_cleared – Explicit opt-in for a value-bearing network (see pyrxd.btc_wallet.htlc_leg.require_audit_cleared()).

  • fee_policy – The DeadlineFeePolicy the pre-broadcast affordability gate enforces. Defaults to the reference node’s advertised 0.10 RXD/kB effective relay rate; pass an explicit policy when the node this leg broadcasts to advertises a different effective_minrelaytxfee.

__init__(*, network, taker_pkh, maker_pkh, chain_io, fee_source, min_confirmations=1, audit_cleared=False, fee_policy=None)[source]
Parameters:
  • network (str)

  • taker_pkh (bytes)

  • maker_pkh (bytes)

  • chain_io (RadiantChainIO)

  • fee_source (FeeUtxoSource)

  • min_confirmations (int)

  • audit_cleared (bool)

  • fee_policy (DeadlineFeePolicy | None)

Return type:

None

async claim_asset(record, preimage)[source]

Build + broadcast the TAKER’s claim spend (reveals p). Returns the txid.

Fee-sized against the DEADLINE: the maker’s CSV refund branch opens once the covenant is t_rxd confirmations deep, so t_rxd - confirmations is the number of Radiant blocks in which this claim must be mined, not merely broadcast. The pre-broadcast gate refuses (and pages) if the dispensed fee input cannot meet that requirement — there is no post-broadcast remedy on Radiant.

Parameters:
  • record (SwapRecord)

  • preimage (bytes)

Return type:

str

async covenant_outpoint(terms)[source]

Locate the funded covenant UTXO txid:vout by scanning its SPK’s UTXO set.

The maker locks the asset into the covenant SPK (a pure function of the terms); the leg finds that single funded UTXO on-chain via ElectrumX. The carrier value is bound to terms.radiant_amount so a mis-funded covenant fails closed.

Parameters:

terms (NegotiatedTerms)

Return type:

str

async expected_covenant_scriptpubkey(terms)[source]

The covenant SPK the on-chain lock must equal (built from the terms).

Parameters:

terms (NegotiatedTerms)

Return type:

bytes

async rebroadcast_claim_if_evicted(record, preimage)[source]

Re-broadcast the taker’s claim if it has fallen out of the mempool. Returns the new txid, or None when nothing needed doing.

WHY THIS EXISTS. A non-BIP68-final refund is rejected from the mempool (Radiant Core validation.cpp:724-728), so the maker CANNOT pre-broadcast and a claim already sitting in the mempool at CSV maturity wins the race. The whole safety of the claim window therefore rests on the claim STAYING there — and Radiant has no RBF and no CPFP, so a claim that is evicted cannot be bumped back in. Mempool expiry is about eight hours.

The coordinator broadcast the claim and advanced straight to a completed state, so an eviction was invisible: the maker’s refund became valid at maturity, confirmed, and took both legs while the swap’s own record said it had finished.

Single-shot on purpose — no loop, no clock. The caller drives it on whatever tick it already has, which keeps this testable and keeps clock ownership where the rest of the module puts it.

Returns None when the covenant is already spent (our claim is in the mempool or mined — nothing to do) and when the source ABSTAINS, because an unknown answer must not be treated as “evicted” and turned into a duplicate broadcast.

Parameters:
  • record (SwapRecord)

  • preimage (bytes)

Return type:

str | None

async refund_asset(record)[source]

Build + broadcast the MAKER’s CSV refund spend. Returns the txid.

P3 maturity self-check: the covenant’s CSV refund leaf is only spendable once the covenant UTXO is buried t_rxd deep (the BIP68 relative-block timelock the covenant was built with: refund_csv=t_rxd.value, mature at confirmations >= t_rxd.value). Refuse a non-final refund HERE rather than emit a tx a node rejects — under a deadline-pinning mempool “rely on node rejection” is fragile — with an exact “needs N confirmations, has M” message a block-based poller retries on. This guards EVERY refund_asset caller (mutual_refund, maybe_refund_asset_on_maker_stall) at the leg, complementing the coordinator-side height check in maybe_refund_asset_on_maker_stall. (The CLAIM branch has no CSV, so claim_asset is intentionally NOT gated this way.)

Parameters:

record (SwapRecord)

Return type:

str

async verify_maker_asset_funded(terms, *, min_confirmations=None)[source]

TAKER-side fail-closed gate: is the MAKER’s asset really locked, at the agreed value, buried deep enough, before the taker funds the counter leg? Returns (outpoint, value_photons, confirmations); RAISES on anything else — the taker MUST NOT lock BTC/ETH if this raises. The Radiant twin of pyrxd.btc_wallet.htlc_leg.BitcoinTaprootLeg.verify_counterparty_funded().

WHY: docs/htlc-handshake-wire-format.md HZ-1 states it normatively — “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.” Nothing else in the handshake gives the taker that. The BTC claim leaf is <H> <makerClaimPk> OP_CHECKSIG with no precondition that the asset was ever locked, and the maker holds both p and the claim key from the moment it publishes the envelope. So a maker that locks NOTHING and simply waits can sweep the taker’s HTLC the instant it appears: the taker’s loss is the full btc_sats, and the FSM’s nominal “taker locks first” ordering is bookkeeping, not a safety guarantee.

What is checked, all fail-closed:

  1. the covenant scriptPubKey is re-derived here from the taker’s own ``terms`` (_build_covenant() — amount, H, t_rxd CSV, both dest hashes, the asset REF), never taken from anything the maker advertises;

  2. that exact SPK holds a funded UTXO, and its ON-CHAIN value equals terms.radiant_amount — an unfunded SPK, a mis-valued one, and an ambiguous UTXO set all raise (RadiantChainIO.find_covenant_utxo());

  3. the funding is buried min_confirmations deep. “Funded” alone is NOT enough: ElectrumX listunspent includes MEMPOOL outputs, so a maker can fund with a replaceable transaction, wait for the taker’s lock, then double-spend the funding away — it still claims the counter leg with p while the vanished covenant leaves the taker nothing to claim. None uses this leg’s configured min_confirmations; the coordinator passes the policy’s RXD burial depth for a real-value swap.

Parameters:
  • terms (NegotiatedTerms)

  • min_confirmations (int | None)

Return type:

tuple[str, int, int]

class pyrxd.gravity.RefAuthenticityIndexer[source]

Bases: Protocol

The minimal indexer surface needed to verify a genesis REF is real.

Implementations resolve a genesis-outpoint ref to its on-chain reveal as a ResolvedRef. resolve_ref is async (the underlying glyph.get_token RPC is async) and MUST raise (not return None optimistically) when the indexer cannot reach a definitive answer — the caller treats None, any missing/invalid field, or any exception as fail-closed. Returning None means “no such token” (also fail-closed).

__init__(*args, **kwargs)
async resolve_ref(genesis_ref)[source]

Resolve genesis_ref to its reveal, or None if unknown.

Parameters:

genesis_ref (bytes)

Return type:

ResolvedRef | None

class pyrxd.gravity.ResolvedRef[source]

Bases: object

A trusted indexer’s resolution of a genesis ref to its on-chain reveal.

This is the inspectable surface the gate binds against — the gate does NOT trust a bare boolean from the indexer; it re-checks each field against what the taker agreed to. A real adapter populates this from the indexer’s glyph.get_token response (ref_outpoint/payload_hash/confirmations + a decoded gly marker); a test fake constructs it directly.

genesis_outpoint

The 36-byte genesis outpoint (txid||vout) the indexer says this token was minted at. Binding (a)/(d): MUST equal the advertised genesis_ref.

Type:

bytes

has_gly_marker

True iff the reveal carries a gly envelope (binding (b)). A bare singleton on a plain wallet UTXO has none — the exact R1 forgery.

Type:

bool

payload_hash

The reveal’s payload-commitment hash (binding (c)), or b"" if the indexer did not supply one.

Type:

bytes

confirmations

Confirmations on the genesis tx (binding (e)). A negative/None value is treated as fail-closed by the gate.

Type:

int

__init__(genesis_outpoint, has_gly_marker, payload_hash, confirmations)
Parameters:
  • genesis_outpoint (bytes)

  • has_gly_marker (bool)

  • payload_hash (bytes)

  • confirmations (int)

Return type:

None

genesis_outpoint: bytes
has_gly_marker: bool
payload_hash: bytes
confirmations: int
class pyrxd.gravity.RxinDexerRefAdapter[source]

Bases: object

Resolve a genesis ref to a ResolvedRef via RXinDexer glyph.get_token.

Implements the RefAuthenticityIndexer protocol the pre-lock gate awaits. Maps the indexer’s token dict to the inspectable fields the gate binds:

  • genesis_outpoint — from the token’s ref_outpoint (txid:vout), re-encoded to the 36-byte wire ref so it compares equal to the advertised genesis_ref. (glyph.get_token only returns genuinely-minted Glyph tokens, so a resolvable token IS a gly reveal — see has_gly_marker.)

  • has_gly_markerTrue whenever the indexer returned a token dict for the ref (the indexer only indexes real gly envelopes). A bare wallet-UTXO singleton (the R1 forgery) resolves to None and the gate fails closed.

  • payload_hash — from payload_hash (bytes), or b"" if absent.

  • confirmations — read separately from the genesis tx via chain_io (glyph.get_token does not carry confs).

NOTE (T7 plan D3): a single indexer is a SPOF, and decoding a token dict is NOT SPV authenticity (no Merkle/header binding). For the regtest milestone the local node is ground truth; SPV-bound / multi-source cross-checking is the audit-gated track. This adapter is the single-indexer regtest backend.

__init__(indexer, chain_io)[source]
Parameters:
Return type:

None

async resolve_ref(genesis_ref)[source]
Parameters:

genesis_ref (bytes)

Return type:

ResolvedRef | None

class pyrxd.gravity.SeenStore[source]

Bases: object

In-memory H-freshness store (the coordinator’s reserve/has_seen).

Records every hashlock H the coordinator has committed to funding, so a reused H is rejected for BOTH reasons: economic (free-option replay) and cross-swap preimage replay. reserve(H) is the authoritative atomic test-and-set the coordinator calls PRE-broadcast; has_seen is a read-only advisory probe (the pre-lock gate’s cheap early-reject), never the binding decision.

NON-DURABLE (durable = False): a plain set, so freshness does NOT survive a restart or a second process. That is acceptable only for a single-process, single-shot run that mints a fresh H per swap (the dust runbook); the coordinator’s construct-time guard refuses this store on a value-bearing network unless the operator passes CoordinatorConfig(accept_nondurable_seen=True). A durable replacement (SQLite INSERT OR IGNORE keyed on H, declaring durable = True) is deferred to the external-audit track; it MUST stay non-blocking (asyncio.to_thread behind an async reserve) and fsync the reservation BEFORE the BTC broadcast. The method shape is duck-compatible so that durable store drops in unchanged.

__init__()[source]
Return type:

None

durable = False
has_seen(hashlock)[source]
Parameters:

hashlock (bytes)

Return type:

bool

mark_seen(hashlock)[source]
Parameters:

hashlock (bytes)

Return type:

None

reserve(hashlock)[source]

Atomically record H if unseen; True if freshly reserved, else False.

Atomic on the single-threaded event loop precisely because there is no await between the membership test and the add.

Parameters:

hashlock (bytes)

Return type:

bool

class pyrxd.gravity.TradeConfig[source]

Bases: object

Tunable parameters for GravityTrade.

min_btc_confirmations

Minimum on-chain BTC confirmations before finalizing. MUST equal the covenant’s header-depth N (the finalize path verifies exactly N consecutive headers from the anchor; a proof with fewer is rejected). Default 6 — matches the default N=6 covenant and Bitcoin’s standard finality convention (~1h). N is a per-offer MAKER knob: raise it (e.g. 12) for high-value/irreversible assets to roughly double the reorg cost, at the price of a longer wait. When using a covenant built with a different N, set this to that N (audit 2026-05-24: the two must match).

Type:

int

poll_interval_seconds

Seconds between confirmation polls. Default 60.

Type:

float

max_poll_attempts

Maximum number of polls before wait_confirmations raises NetworkError. Default 120 (= 2 hours at 60s).

Type:

int

accept_short_deadline

If True, suppress the 24h deadline guard (audit 04-S1). Only for testing — do NOT set in production.

Type:

bool

deadline_warning_seconds

Emit a WARNING log in finalize() when the Maker’s claim deadline is less than this many seconds away. Default 7200 (2 hours). Set to 0 to disable. Takers should finalize immediately if this fires (audit 04-S1 forfeit race).

Type:

int

fee_policy

Min-relay rate claim and finalize size their transactions against. None uses DEFAULT_RADIANT_DEADLINE_FEE_POLICY (mainnet). The builders have taken this since #407/#411; without it on the config the high-level orchestrator could not reach the escape hatch at all, so a regtest node — which advertises a tenth of the mainnet floor — had no way to opt out.

Type:

pyrxd.gravity.fee_policy.DeadlineFeePolicy | None

__init__(min_btc_confirmations=6, poll_interval_seconds=60, max_poll_attempts=120, accept_short_deadline=False, deadline_warning_seconds=7200, fee_policy=None)
Parameters:
  • min_btc_confirmations (int)

  • poll_interval_seconds (float)

  • max_poll_attempts (int)

  • accept_short_deadline (bool)

  • deadline_warning_seconds (int)

  • fee_policy (DeadlineFeePolicy | None)

Return type:

None

accept_short_deadline: bool = False
deadline_warning_seconds: int = 7200
fee_policy: DeadlineFeePolicy | None = None
max_poll_attempts: int = 120
min_btc_confirmations: int = 6
poll_interval_seconds: float = 60
pyrxd.gravity.build_claim_tx(offer, funding_txid, funding_vout, funding_photons, fee_sats, taker_privkey, accept_short_deadline=False, fee_policy=None)[source]

Build the Radiant claim() spending tx: MakerOffer → MakerClaimed.

Requires Taker’s private key to produce a Radiant signature satisfying MakerOffer.claim(takerSig) — prevents third-party state-advance grief (audit 04-S3).

Audit 05-F-13: verifies claimedRedeemHex matches expectedClaimedCodeHash before building, so the tx won’t be rejected on-chain.

scriptSig layout:

<takerSig+hashtype> OP_1 <offer redeem script>
Parameters:
  • offer (GravityOffer) – Fully populated GravityOffer (validated in __post_init__).

  • funding_txid (str) – Hex txid of the MakerOffer UTXO being spent.

  • funding_vout (int) – Output index of the MakerOffer UTXO.

  • funding_photons (int) – Value of the MakerOffer UTXO in photons.

  • fee_sats (int) – Miner fee in photons (== satoshis on Radiant).

  • taker_privkey (PrivateKeyMaterial) – Taker’s secp256k1 private key (wrapped in PrivateKeyMaterial).

  • accept_short_deadline (bool) – If True, suppress the 24-hour deadline guard (audit 04-S1).

  • fee_policy (DeadlineFeePolicy | None) – The min-relay rate the assembled transaction is checked against. Defaults to DEFAULT_RADIANT_DEADLINE_FEE_POLICY. Pass a policy built from the target node’s own getmempoolinfo/effective_minrelaytxfee when it advertises something else, which regtest does.

Raises:

InsufficientFundsError – If fee_sats is below the node’s min-relay floor for the transaction’s real, signed size. See _assert_fee_clears_relay_floor().

Return type:

ClaimResult

pyrxd.gravity.build_finalize_tx(spv_proof, claimed_redeem_hex, funding_txid, funding_vout, funding_photons, to_address, fee_sats, minimum_output_photons=0, header_slots=None, branch_slots=None, fee_policy=None)[source]

Build the Radiant finalize() tx: MakerClaimed → Taker’s address.

The spv_proof must be a fully-verified SpvProof produced by SpvProofBuilder.build() — this is the only way to construct one.

No Radiant signature is required — the covenant accepts the scriptSig based on the SPV proof data alone. Output routing is enforced by the covenant’s committed takerRadiantPkh state.

scriptSig layout (pushed bottom-to-top; last push is TOP at exec):

<h1> <h2> ... <hN> <branch> <rawTx> OP_0 <claimed redeem script>

OP_0 (empty push = selector 0) selects the finalize() function.

Parameters:
  • spv_proof (SpvProof) – Fully-verified SPV proof (only obtainable from SpvProofBuilder).

  • claimed_redeem_hex (str) – Hex of MakerClaimed locking bytecode.

  • funding_txid (str) – Txid of the MakerClaimed UTXO being spent.

  • funding_vout (int) – Output index of the MakerClaimed UTXO.

  • funding_photons (int) – Value of the MakerClaimed UTXO in photons.

  • to_address (str) – Taker’s Radiant P2PKH address.

  • fee_sats (int) – Miner fee in photons.

  • minimum_output_photons (int) – The covenant’s totalPhotonsInOutput floor — baked in at offer creation time. The finalize tx is rejected on-chain if output[0].value < totalPhotonsInOutput, so we validate here before burning relay fees. Pass offer.photons_offered when calling from GravityTrade. Defaults to 0 (no floor check) for callers that have already verified externally.

  • fee_policy (DeadlineFeePolicy | None) – The min-relay rate the assembled transaction is checked against. Defaults to DEFAULT_RADIANT_DEADLINE_FEE_POLICY. Pass a policy built from the target node’s own getmempoolinfo/effective_minrelaytxfee when it advertises something else, which regtest does.

  • header_slots (int | None)

  • branch_slots (int | None)

Raises:

InsufficientFundsError – If fee_sats is below the node’s min-relay floor for the transaction’s real size. This is the largest transaction in the module — the whole BTC payment tx, header_slots × 80-byte headers and a branch_slots-level Merkle branch all go into one scriptSig — so its floor is an order of magnitude above a claim’s and a fee that was ample there is not ample here. See _assert_fee_clears_relay_floor().

Return type:

FinalizeResult

pyrxd.gravity.build_forfeit_tx(offer, funding_txid, funding_vout, funding_photons, maker_address, fee_sats, fee_policy=None)[source]

Build the Radiant forfeit() tx: Maker reclaims after claimDeadline.

Can only be built once offer.claim_deadline has passed (i.e. the current wall-clock time is >= claim_deadline).

Sets nLockTime = claim_deadline for OP_CHECKLOCKTIMEVERIFY. Sets input sequence to 0xFFFFFFFE (< 0xFFFFFFFF — required for CLTV to be evaluated).

scriptSig layout:

OP_1 <claimed redeem script>

OP_1 (selector 1) selects the forfeit() function.

Parameters:
  • offer (GravityOffer) – GravityOffer whose claim_deadline has already passed.

  • funding_txid (str) – Txid of the MakerClaimed UTXO being forfeited.

  • funding_vout (int) – Output index of the MakerClaimed UTXO.

  • funding_photons (int) – Value of the MakerClaimed UTXO in photons.

  • maker_address (str) – Maker’s Radiant P2PKH address to receive the reclaimed photons.

  • fee_sats (int) – Miner fee in photons.

  • fee_policy (DeadlineFeePolicy | None) – The min-relay rate the assembled transaction is checked against. Defaults to DEFAULT_RADIANT_DEADLINE_FEE_POLICY. Pass a policy built from the target node’s own getmempoolinfo/effective_minrelaytxfee when it advertises something else, which regtest does.

Raises:

InsufficientFundsError – If fee_sats is below the node’s min-relay floor for the transaction’s real size — forfeit is the last exit from a MakerClaimed UTXO, so one that cannot relay leaves the Maker with nothing else to try. See _assert_fee_clears_relay_floor().

Return type:

ForfeitResult

pyrxd.gravity.build_gravity_offer(maker_pkh, maker_pk, taker_pk, taker_radiant_pkh, btc_receive_hash, btc_receive_type, btc_satoshis, btc_chain_anchor, expected_nbits, anchor_height, merkle_depth, claim_deadline, photons_offered, expected_nbits_next=None, accept_short_deadline=False, covenant_artifact_name='maker_covenant_flat_12x20_sentinel_all', offer_artifact_name='maker_offer', used_btc_receive_hashes=None, reject_low_difficulty=True, min_difficulty_nbits=None)[source]

Build a GravityOffer with real covenant redeem scripts generated from the bundled artifacts.

This is the top-level entry point for Maker-side offer construction. Internally it:

  1. Validates the claim deadline (S1 guard).

  2. Loads the MakerClaimed covenant artifact and substitutes code-section params.

  3. Computes expectedClaimedCodeHash = hash256(P2SH_scriptPubKey) from the substituted redeem script.

  4. Loads the MakerOffer artifact and substitutes its params (including the code hash from step 3).

  5. Returns a GravityOffer with both redeem scripts populated.

Parameters:
  • accept_short_deadline (bool) – Override the 24h deadline guard. Set True only for test harnesses you control — never because a counterparty asks.

  • covenant_artifact_name (str) – Override the MakerClaimed artifact stem.

  • offer_artifact_name (str) – Override the MakerOffer artifact stem.

  • used_btc_receive_hashes (set[bytes] | None) – Optional set of btc_receive_hash values already committed to other LIVE offers by this Maker. If the new btc_receive_hash is in this set, the call is rejected.

  • reject_low_difficulty (bool) – Enforce a difficulty FLOOR on the committed nBits (audit 2026-05-29 F-02). The covenant only pins nBits == committed, so a min-difficulty commit (e.g. the ffff001d footgun) lets an attacker mine a fake SPV header chain off the real anchor for ~$0. With this True the committed target must be strictly harder than the floor. Covenant-less retained uses (bridge-in / oracle / gate) MUST set this True — and SHOULD also pass min_difficulty_nbits, because the default floor (difficulty-1) only blocks the difficulty-1 class, not a target merely easier than mainnet. Defaults True (secure-by-default, audit 2026-05-29 F-02 follow-up): a real mainnet nBits is far harder than difficulty-1 so it passes the default floor; only a difficulty-1-class commit (ffff001d) is rejected. Pass reject_low_difficulty=False for regtest/test offers that use ffff001d.

  • min_difficulty_nbits (bytes | None) – Optional 4-byte wire nBits defining the difficulty floor used when reject_low_difficulty is True. Source this from the live block header at anchor_height for a real network-difficulty floor; if omitted, the floor defaults to difficulty-1 (a coarse footgun guard only).

  • maker_pkh (bytes)

  • maker_pk (bytes)

  • taker_pk (bytes)

  • taker_radiant_pkh (bytes)

  • btc_receive_hash (bytes)

  • btc_receive_type (str)

  • btc_satoshis (int)

  • btc_chain_anchor (bytes)

  • expected_nbits (bytes)

  • anchor_height (int)

  • merkle_depth (int)

  • claim_deadline (int)

  • photons_offered (int)

  • expected_nbits_next (bytes | None)

Return type:

Any

Warning

CROSS-OFFER REPLAY (audit 2026-05-24 C-ECON-1). A Bitcoin payment cannot reference a Radiant offer, so the covenant binds the payment only by btc_receive_hash + btc_satoshis + btc_chain_anchor. If the same btc_receive_hash (BTC receive address) + amount is reused across two offers with overlapping anchor windows, one BTC payment + one SPV proof can finalize BOTH offers — a taker pays once and takes two assets. There is NO on-chain or automatic defense; the earlier “per-offer-derived btcReceiveHash (H1)” control described in the design notes was never implemented. The Maker MUST use a fresh, unique BTC receive address per offer. Pass used_btc_receive_hashes to have this function reject reuse it can see; offers built by separate processes are the caller’s responsibility.

PREFER build_gravity_offer_derived(), which derives a distinct receive address per offer from the maker’s account xpub — that is the structural fix (distinct address ⇒ distinct code hash ⇒ replay impossible) and needs no caller-side live-set tracking. This raw-hash entry point remains for callers that manage receive addresses themselves; the used_btc_receive_hashes guard is only best-effort.

pyrxd.gravity.build_gravity_offer_derived(account_xpub, offer_index, *, maker_pkh, maker_pk, taker_pk, taker_radiant_pkh, btc_satoshis, btc_chain_anchor, expected_nbits, anchor_height, merkle_depth, claim_deadline, photons_offered, expected_nbits_next=None, accept_short_deadline=False, covenant_artifact_name='maker_covenant_flat_12x20_sentinel_all', offer_artifact_name='maker_offer', reject_low_difficulty=True, min_difficulty_nbits=None)[source]

Build an offer whose BTC receive address is DERIVED per-offer (replay-safe).

This is the structural fix for the cross-offer replay (C-ECON-1 / “H1”): the receive hash is derived from account_xpub at offer_index via pyrxd.gravity.receive.derive_offer_btc_receive(), so every offer commits to a DISTINCT BTC address. A payment to one offer’s address cannot satisfy another offer’s covenant (different btcReceiveHash ⇒ different code hash), so one BTC payment can finalize at most one offer — no caller-supplied live-set bookkeeping required.

Prefer this over passing a raw btc_receive_hash to build_gravity_offer(). The caller MUST allocate a fresh, never-reused offer_index per offer (a persistent monotonic counter per account) and hold the matching xprv to spend received BTC.

Returns:

(GravityOffer, OfferReceive) — persist OfferReceive.offer_index with the offer so the maker can later spend the received BTC and never reuse it.

Parameters:
  • account_xpub (Any)

  • offer_index (int)

  • maker_pkh (bytes)

  • maker_pk (bytes)

  • taker_pk (bytes)

  • taker_radiant_pkh (bytes)

  • btc_satoshis (int)

  • btc_chain_anchor (bytes)

  • expected_nbits (bytes)

  • anchor_height (int)

  • merkle_depth (int)

  • claim_deadline (int)

  • photons_offered (int)

  • expected_nbits_next (bytes | None)

  • accept_short_deadline (bool)

  • covenant_artifact_name (str)

  • offer_artifact_name (str)

  • reject_low_difficulty (bool)

  • min_difficulty_nbits (bytes | None)

Return type:

tuple[Any, Any]

pyrxd.gravity.build_htlc_claim_tx(*, covenant, covenant_outpoint, carrier_value, preimage, fee, fee_policy=None)[source]

Build the TAKER’s claim spend: reveal p, pay the single output to the taker.

Covenant scriptSig = <preimage push> <OP_0> (preimage first/under, selector last/on top). The single output pays the covenant’s pinned TAKER holder script at the carrier value; the fee input’s full surplus is the miner fee (no change).

fee_policy (default: the reference node’s 0.10 RXD/kB effective relay rate) supplies the min-relay rate the assembled transaction is checked against. Pass an explicit policy when the target node advertises a different rate — it is node policy, not a protocol constant.

Parameters:
Return type:

Transaction

pyrxd.gravity.build_htlc_covenant_ft(*, genesis_txid, genesis_vout, amount, taker_pkh, maker_pkh, hashlock, refund_csv)[source]

Build the FT-variant HTLC covenant (genesis ref bound via the FT epilogue weld).

Parameters:
Return type:

HtlcCovenant

pyrxd.gravity.build_htlc_covenant_nft(*, genesis_txid, genesis_vout, nft_carrier_value, taker_pkh, maker_pkh, hashlock, refund_csv)[source]

Build the NFT-variant HTLC covenant (singleton d8<ref> inside the body).

Parameters:
Return type:

HtlcCovenant

pyrxd.gravity.build_htlc_covenant_rxd(*, amount, taker_pkh, maker_pkh, hashlock, refund_csv)[source]

Build the RXD-variant HTLC covenant (native RXD: NO genesis ref, NO ref ops).

Parameters:
Return type:

HtlcCovenant

pyrxd.gravity.build_htlc_refund_tx(*, covenant, covenant_outpoint, carrier_value, fee, fee_policy=None)[source]

Build the MAKER’s CSV refund spend (function selector OP_1, after the timelock).

Covenant scriptSig = <OP_1> ONLY (no preimage, no sig — gated by the relative timelock). The covenant input’s nSequence = covenant.refund_csv and tx.version = 2 so BIP68 engages. The single output pays the covenant’s pinned MAKER holder script; the fee input’s full surplus is the miner fee.

Parameters:
Return type:

Transaction

pyrxd.gravity.build_maker_offer_tx(offer, funding_txid, funding_vout, funding_photons, fee_sats, maker_privkey, change_address=None, fee_policy=None)[source]

Build the Radiant funding tx that deploys a MakerOffer P2SH UTXO.

Spends a plain P2PKH UTXO owned by the Maker and creates a P2SH output locked to the MakerOffer redeem script. Once confirmed, the Taker can spend it with build_claim_tx().

The P2SH scriptPubKey is:

OP_HASH160 <hash160(offer_redeem)> OP_EQUAL

Signing uses standard BIP143 P2PKH sighash (the input is a plain P2PKH UTXO, not a covenant) with Radiant’s hashOutputHashes extension. The scriptCode for signing is the P2PKH scriptPubKey of the funding input, derived from the Maker’s compressed public key.

Parameters:
  • offer (GravityOffer) – Fully populated GravityOffer with offer_redeem_hex set.

  • funding_txid (str) – Hex txid of the Maker’s P2PKH UTXO being spent.

  • funding_vout (int) – Output index of the Maker’s P2PKH UTXO.

  • funding_photons (int) – Value of the Maker’s P2PKH UTXO in photons.

  • fee_sats (int) – Miner fee in photons. The offer output receives funding_photons - fee_sats photons.

  • maker_privkey (PrivateKeyMaterial) – Maker’s secp256k1 private key (PrivateKeyMaterial). Used to sign the P2PKH input and derive the P2PKH scriptCode for hashing.

  • change_address (str | None) – Default None (single-output): the full funding_photons - fee_sats is locked in the P2SH, so surplus above offer.photons_offered stays with the covenant to fund the later claim/finalize tx fees. When set (two-output): the P2SH receives exactly offer.photons_offered and the remainder goes to a P2PKH output at change_address. Use the two-output form only when offer.photons_offered already includes a buffer for downstream claim/finalize fees — otherwise the covenant will reject those txs.

  • fee_policy (DeadlineFeePolicy | None) – The min-relay rate the assembled transaction is checked against. Defaults to DEFAULT_RADIANT_DEADLINE_FEE_POLICY (the reference mainnet node’s 0.10 RXD/kB effective rate). The rate is node policy, not a protocol constant — pass a policy built from the target node’s own getmempoolinfo/effective_minrelaytxfee when it advertises something else, which regtest does.

Raises:

InsufficientFundsError – If fee_sats is below the node’s min-relay floor for the transaction’s real, signed size. See _assert_fee_clears_relay_floor().

Return type:

MakerOfferResult

pyrxd.gravity.compute_p2sh_code_hash(redeem_script)[source]

Compute expectedClaimedCodeHash: hash256 of the P2SH scriptPubKey.

This is what MakerOffer checks on-chain:

hash256(tx.outputs[0].codeScript) == expectedClaimedCodeHash

For P2SH outputs the codeScript is the 23-byte OP_HASH160 <hash> OP_EQUAL scriptPubKey.

Audit 05-F-13 fix: caller passes the claimed redeem script; we derive the hash independently rather than trusting a caller-supplied value.

Parameters:

redeem_script (bytes)

Return type:

bytes

pyrxd.gravity.derive_offer_btc_receive(account_xpub, offer_index)[source]

Derive a unique P2WPKH receive hash for one offer.

Parameters:
  • account_xpub (str | bytes | Xpub) – The maker’s BIP32 account-level xpub (e.g. the public form of m/84'/0'/0'). Child derivation is non-hardened so it is reproducible from the xpub; the maker holds the matching xprv to spend received BTC.

  • offer_index (int) – A per-offer, never-reused non-hardened index. The caller owns allocation (a monotonic counter) — this function is pure and does NOT track which indices have been issued.

Returns:

OfferReceive with the derived 20-byte receive hash and the index.

Raises:

ValidationError – on an out-of-range index or an unusable xpub.

Return type:

OfferReceive

pyrxd.gravity.validate_claim_deadline(claim_deadline, *, min_future_seconds=86400, bypass=False)[source]

Raise ValidationError if claim_deadline is not at least min_future_seconds from now (default: 24h).

This is the Python port of the S1 check in extract_p2sh_code_hash.js (audit 04 finding S1: a short deadline lets Maker race-snipe Taker’s claim).

Parameters:
  • bypass (bool) – Only set True for test harnesses you control. Never set because a counterparty asked you to.

  • claim_deadline (int)

  • min_future_seconds (int)

Return type:

None

async pyrxd.gravity.verify_ref_authenticity(indexer, genesis_ref, *, asset_variant, min_confirmations, expected_payload_hash=None)[source]

Hard pre-payment gate: confirm the covenant’s REF is a real minted asset.

await this BEFORE the taker pays any BTC for an FT/NFT swap. Plain-RXD swaps carry no ref and are skipped. Enforces the five bindings (a)-(e) documented at module level and fails closed on EVERY uncertain outcome: indexer unreachable/error, None (unknown token), a missing/invalid field, genesis-outpoint ≠ ref, absent gly marker, payload mismatch, or a genesis shallower than min_confirmations.

Parameters:
  • indexer (RefAuthenticityIndexer) – a trusted RefAuthenticityIndexer. A lying or attacker-controlled indexer defeats this gate — the taker must use an indexer they trust (the audit-gated track adds SPV/multi-source cross-checking; a single indexer is a SPOF, see T7 plan D3).

  • genesis_ref (bytes) – the 36-byte genesis outpoint ref baked into the covenant. This IS the advertised asset’s identity (binding d).

  • asset_variant (str) – “rxd” | “ft” | “nft”. Only ft/nft carry a ref to verify.

  • min_confirmations (int) – required confirmations on the genesis tx (binding e). Must be a non-negative int.

  • expected_payload_hash (bytes | None) – if the taker agreed to a specific payload, the reveal’s payload hash MUST match it (binding c). None skips this single binding (the others still apply).

Raises:

ValidationError – if the ref is not provably the advertised authentic asset. The caller MUST NOT pay the counter-leg (BTC or ETH) when this raises.

Return type:

None