Build a cross-chain atomic swap (BTC/ETH ↔ RXD)¶
pyrxd ships a trustless cross-chain atomic swap: trade a Radiant asset (RXD, a Glyph FT, or a Glyph NFT) against BTC or ETH with no custodian and no trusted third party. It’s a hash-timelock (HTLC) swap driven by a chain-neutral coordinator, proven end-to-end on regtest and on small real-value mainnet/Sepolia runs.
New to this? Start with the guided tutorial — Trustless cross-chain swap: RXD ↔ ETH — which walks a full swap settling on local chains. This page is the reference for the pieces.
Unaudited — verify it yourself before moving real value. This swap stack is open-source software, provided as-is (see the LICENSE). It’s proven end-to-end on regtest/testnet and on small real-value runs, but has not had an external security audit. An atomic swap’s whole job is to be safe against a hostile counterparty — review the construction against your own use before trusting it with value. See the swap coordinator’s module docstring for the current residual-risk notes.
This is the HTLC swap — not the SPV-oracle one. An earlier SPV-oracle swap covenant is deprecated and superseded by this HTLC construction (it was a non-atomic, weaker swap with known won’t-fix parser findings). Build on the coordinator below; the
examples/gravity_*scripts are SPV-oracle reference-only. See the design decision for why, andexamples/htlc_swap_demo.pyfor a runnable on-ramp to this path.
The mental model¶
Two parties, two chains, one secret:
The maker holds the Radiant asset and wants BTC/ETH. The taker holds BTC/ETH and wants the asset.
The maker generates a 32-byte secret
pand publishesH = SHA256(p). The sameHlocks both legs; revealingpto claim one leg lets the counterparty claim the other.The coordinator drives a chain-neutral state machine over two legs: the Radiant covenant leg (the asset side) and a counter-chain leg (the BTC or ETH value side).
The one safety invariant you must respect¶
from pyrxd import SwapCoordinator
print(SwapCoordinator.__module__) # the role invariant lives in swap_coordinator
MAKER_SECRET_TAKER_LOCKS_BTC_FIRST (a documented constant in
pyrxd.gravity.swap_coordinator) is the safety hinge — read it before you wire anything:
The maker generates
p, publishesH.The taker locks the counter-chain (BTC/ETH HTLC) first.
The maker locks the Radiant covenant second.
The maker claims the counter-chain first, revealing
p.The taker scrapes
pand claims the Radiant asset before its refund opens.
The timelocks must satisfy t_counterchain > t_rxd + margin: the leg claimed
second (Radiant) carries the shorter refund window. The taker’s client MUST verify
t_counterchain − t_rxd ≥ margin before funding, or refuse. The coordinator enforces
this fail-closed (assert_timelock_margin) — don’t route around it.
The pieces (all importable from the top level)¶
from pyrxd import (
SwapCoordinator, # the chain-neutral orchestrator / FSM
CoordinatorConfig, # margins, durability + value-bearing opt-ins
MarginPolicy, # timelock margins; MarginPolicy.measured(...) for real value
NegotiatedTerms, # H, amounts, timeouts, destinations — the public envelope
SwapRecord, SwapState, # the durable swap record + its FSM states
generate_secret, # the maker's (p, H)
RadiantCovenantLeg, # the asset (RXD/FT/NFT) leg
EthLeg, # the ETH counter-chain leg (Solidity HTLC)
CounterChainLeg, # the ABC every counter-chain backend implements
)
The coordinator is constructed with both legs plus its collaborators:
coordinator = SwapCoordinator(
record=SwapRecord(state=SwapState.NEGOTIATED, terms=terms),
counter_leg=eth_leg, # or btc_leg=... for the BTC Taproot-HTLC path
radiant_leg=rxd_leg,
indexer=indexer, # resolves Glyph refs / reads RXD chain state
seen_store=seen_store, # dedup / replay durability across restarts
config=CoordinatorConfig(margin_policy=MarginPolicy.measured(...)),
)
BTC counter-leg. The proven BTC path consumes the Taproot-HTLC functions in
pyrxd.btc_wallet.taprootthrough a duck-typed surface (there is noBitcoinTaprootLegclass yet — seeCounterChainLeg’s scope note). Pass it asbtc_leg=.ETH counter-leg.
EthLegwraps the SolidityEthHtlccontract via web3 (an optional dependency:pip install pyrxd[eth]or addweb3).MarginPolicy.measured(...)vs estimated. A real-value swap MUST use a measured margin policy; the coordinator refuses a value-bearing swap on estimated margins unless you consciously opt in (accept_estimated_eth_margins/ the dust-run hatches).Value-scaled claim burial. Radiant is low-cap PoW, so a flat claim-burial depth bounds reorg probability, not reorg cost vs. value — a swap worth more than the marginal cost to reorg a few Radiant blocks is economically reversible. The coordinator therefore refuses a value-bearing Radiant swap unless you give
MarginPolicythe economic inputs it scales burial from —rxd_reorg_cost_per_block(measured, photons/block) andvalue_at_risk_photons(the assessed economic value; for FT/NFT this is not the on-chain amount) — or setaccept_flat_burial=Truefor a deliberate dust run. The reorg gate then requires the taker’s claim to burymax(rxd_claim_burial, ceil(value × factor / cost))deep before it returns SAFE, so an attacker must out-spend the value to reverse it.
Runnable references (start here — these actually execute)¶
Rather than a toy snippet that wouldn’t run against real chains, embed from the proven, maintained harnesses:
What |
Where |
|---|---|
BTC ↔ RXD full swap on regtest (happy / mutual-refund / maker-stall / reorg-gate) |
|
ETH ↔ RXD full swap on Anvil + regtest |
|
Two-party adversarial scenarios (hostile maker/taker, races) |
|
Operational driver (Sepolia + RXD, at-keyboard, dust) |
|
Run the regtest suites with a local node — see the
quickstart for pyrxd regtest setup / up, plus an Anvil
binary (ETH) or a bitcoin-core regtest image (BTC):
$ RADIANT_REGTEST=1 XCHAIN_REGTEST=1 pytest tests/test_xchain_swap_regtest_e2e.py -m integration
$ XCHAIN_ETH_REGTEST=1 pytest tests/test_xchain_eth_swap_regtest_e2e.py -m integration
Adding another counter-chain¶
Two families are proven; adding a chain within either is a config change, while a new family is a deliberate effort.
EVM family — Base, Optimism, Arbitrum, Linea work today (no new code)¶
The proven EthLeg + EthHtlc.sol machinery is chain-id-agnostic: the same contract
bytecode, the same finalized-checkpoint reads, the same claim/refund/scrape paths run on
any EVM-equivalent chain. Base (an OP-stack L2) is the first packaged example — swap a
Radiant asset against native ETH on Base by changing three knobs, none of which touch
the coordinator:
from pyrxd import KNOWN_EVM_CHAINS, EthLeg, MarginPolicy
from pyrxd.eth_wallet.rpc import EthRpc
from pyrxd.eth_wallet.htlc_leg import EthHtlcContractLeg
base = KNOWN_EVM_CHAINS["base-sepolia"] # or "base" (mainnet; opt-in required)
rpc = EthRpc("https://sepolia.base.org", expected_chain_id=base.chain_id)
contract_leg = EthHtlcContractLeg(rpc=rpc, signing_key=key, chain_id=base.chain_id, artifact=ARTIFACT)
eth_leg = EthLeg(contract_leg=contract_leg, network=base.network, ...) # mainnet needs an opt-in
policy = MarginPolicy(..., eth_finalization_window_s=base.finalization_window_s)
The chain is pinned at every layer: EthRpc refuses a node on the wrong chain id, the leg
signs EIP-155-bound transactions, and the durable locator records chain_id. The
negotiated counter_chain stays "eth" — it names the finalized-checkpoint family, and
the locator’s chain id pins the concrete chain.
The one genuinely chain-specific knob is finality. KNOWN_EVM_CHAINS
(pyrxd.eth_wallet.chains) records a sourced finalization_window_s per chain: on Base, an
L2 block is finalized only once the batch containing it sits in a finalized L1 block —
batch cadence (~1 min) + L1 inclusion + 2 L1 epochs, ≈15 min steady-state. The honest worst
case is the OP-stack 12-hour sequencing window (a batch may legally land that late);
budget stalls in CrossClockMargin.eth_finality_stall_tolerance_s, exactly as for an L1
finality stall — never by inflating the steady-state window. Provenance is cited in the
module docstring; evm_chain_by_id fails closed on a chain with no vetted window.
The registry now ships more EVM chains (mainnet + testnet each; mainnet behind an opt-in): Optimism
(optimism, same OP-stack as Base, 900 s), Arbitrum One (arbitrum-one, Nitro, 1200 s — ~24 h
sequencer force-inclusion worst case), and Linea (linea, a zk/validity rollup, 6000 s ≈ the
median hard finality, with a documented up-to-16 h tail). Each window is sourced per chain because
the finality mechanism differs (OP-stack batch cadence vs Arbitrum’s vs zk proof cadence) even
though the leg code is identical. Polygon PoS is deliberately not in the registry: its
finalized tag is Polygon’s own validator-set “milestone” finality (~5 s), not Ethereum-anchored
— it’s a sidechain, so the Ethereum-finality assumption (and the ≥768 s floor) would misrepresent its
trust model. A Polygon-PoS swap would need a finality model justified in Polygon’s own security
terms; evm_chain_by_id(137) fails closed so a sidechain is never silently treated as a rollup.
Proofs: tests/test_eth_leg_anvil_integration.py::test_full_lifecycle_on_base_chain_id
(full leg lifecycle on Base Sepolia’s chain id) and the entire coordinator e2e re-run as
Base via XCHAIN_ETH_CHAIN_ID=84532 XCHAIN_ETH_REGTEST=1 pytest tests/test_xchain_eth_swap_regtest_e2e.py -m integration.
Bitcoin family — Litecoin works today (no coordinator change)¶
The Taproot-HTLC leg machinery is chain-agnostic across BIP341-activating Bitcoin-family
chains: the identical P2TR HTLC, claim/refund builders, preimage scrape, and BIP68 CSV
semantics were proven byte-for-byte on Litecoin regtest consensus (claim accepted,
wrong-preimage rejected with the same witness-program-mismatch reason, premature refund
non-BIP68-final, matured refund accepted — Litecoin Core 0.21.5.5, taproot active). Swap
a Radiant asset against LTC by changing three knobs, none of which touch the coordinator:
from pyrxd import KNOWN_POW_CHAINS, MarginPolicy
from pyrxd.btc_wallet.keys import generate_keypair
from pyrxd.btc_wallet.taproot import build_htlc
ltc = KNOWN_POW_CHAINS["litecoin"] # network "ltc" / testnet "tltc" / regtest "rltc"
kp = generate_keypair(ltc.regtest_network) # bech32m, rltc1p… addresses
htlc = build_htlc(..., network=ltc.regtest_network) # the SAME taproot builders
policy = MarginPolicy.estimated(block_interval_s=ltc.block_interval_s) # 150 s, not 600
The negotiated counter_chain stays "btc" — it names the PoW-depth family — and the
concrete chain is pinned by the leg/locator network tag (the bech32 HRP), exactly as an
EVM swap pins its chain by chain id. The one genuinely chain-specific safety knob is the
block interval (pyrxd.btc_wallet.chains): Litecoin’s 2.5-minute target means an N-block
margin is 4× less wall-clock than on Bitcoin, and the reorg gate’s reserve math shifts
accordingly — pass the registry interval or every timing margin silently shrinks.
Two more honest caveats: confirmation depth must be value-scaled per chain (reorg
resistance is priced in that chain’s hashrate — the registry deliberately ships no depth
defaults), and the bundled mainnet funding-reader/broadcaster backends are Bitcoin-specific
(a Litecoin deployment supplies its own; the regtest harness drives the node RPC directly).
Proofs: the BTC-leg consensus suite and the entire coordinator e2e suite re-run as
Litecoin via the chain knobs —
BTC_FAMILY_CHAIN=ltc BTC_REGTEST=1 pytest tests/test_btc_htlc_regtest_e2e.py -m integration
and XCHAIN_BTC_FAMILY=ltc XCHAIN_REGTEST=1 pytest tests/test_xchain_swap_regtest_e2e.py -m integration (the node image builds from docker/litecoin-regtest.Dockerfile, wrapping the
official release binary). Mainnet "ltc", like every value-bearing network, requires the
explicit opt-in.
A new chain family — the deliberate path¶
CounterChainLeg (pyrxd.gravity.counter_chain_leg) is the documented contract a new
backend implements: derive_expected_funding / fund / claim / refund /
recover_secret / is_final. The ABC was extracted from two real shapes (BTC Taproot +
ETH Solidity), so it reflects what a third chain actually needs — finality is a per-leg
concern, not a single RPC read. A chain outside both proven families means new consensus
semantics and new finality modelling; adopting the ABC in the coordinator (the BTC path is
still duck-typed) is a deliberate, separately-tested change on mainnet-proven code — read
the ABC’s scope note before starting.