Trustless cross-chain swap: RXD ↔ ETH on local chains

Trade a Radiant asset for ETH with no bridge, no custodian, and no trusted third party — and watch it settle end to end on chains running entirely on your own machine. This is the flagship of what pyrxd’s gravity module does: a hash-timelock (HTLC) atomic swap driven by a chain-neutral coordinator.

Pre-audit — local/testnet only. This primitive has not had an external security audit. It’s the right tool to build and demo cross-chain swaps on regtest/testnet, but do not move real value with it until the audit gate clears. An atomic swap’s whole job is to be safe against a hostile counterparty; that property is what an audit certifies.

The idea in one breath

Two parties, two chains, one secret:

  • The maker holds the Radiant asset and wants ETH. The taker holds ETH and wants the asset.

  • The maker picks a random 32-byte secret p and publishes H = SHA256(p). The same H locks both legs.

  • Revealing p to claim one leg hands the counterparty the secret to claim the other. So either both legs settle, or neither does (after a timeout both refund). That’s the atomicity — enforced by each chain’s own consensus, not by anyone’s good behaviour.

  one secret p,  H = SHA256(p),  locks BOTH legs:

      Radiant covenant  ◀──── same H ────▶  ETH HTLC
      (holds the asset)                     (holds the ETH)

  reveal p to claim one leg  →  the counterparty scrapes p and claims the other.

You’ll do this twice: first see the on-chain pieces with no setup at all, then run a full swap on local Anvil + a Radiant regtest node.

Part 1 — see the building blocks (30 seconds, no setup)

The Radiant side of the swap is an on-chain HTLC covenant: it holds the asset and can be spent two ways — the taker’s hashlock claim (reveal p) or the maker’s CSV refund (after a timelock). Build a real one, with the real production builders, with no network:

$ python examples/htlc_swap_demo.py

It generates (p, H), builds an NFT HTLC covenant, and builds both spend paths — the claim (which embeds p) and the timelocked refund — printing the maker/taker roles and the one safety invariant you’ll meet again below. Nothing connects out; it’s pure construction. Read it alongside build a cross-chain swap, which catalogues the pieces.

Part 2 — run a full swap on local chains

Now the whole thing: a real EthLeg (deploying the real EthHtlc contract on a local Anvil) and a real RadiantCovenantLeg (a radiant-core regtest node), driven through the production SwapCoordinator from negotiated to completed. No real value moves — Anvil is a local devnet with public deterministic keys, and the Radiant node is a throwaway regtest container.

Prerequisites (one-time)

$ pip install "pyrxd[eth]"          # the ETH leg needs web3
$ pyrxd regtest setup               # builds the radiant-core regtest image (downloads the
                                    # official release + verifies its checksum); same Step 0 as
                                    # the quickstart
$ curl -L https://foundry.paradigm.xyz | bash && foundryup   # installs `anvil`

(Need Docker on your PATH too — the regtest node runs in a container.)

The flow, step by step

The coordinator is chain-neutral: it drives a state machine over two legs and never trusts a counterparty’s claims — every step re-verifies against on-chain truth. Here’s the happy path (the method names say btc for historical reasons; they drive whichever counter-leg you wired — here, ETH):

# build the covenant + the two real legs + the coordinator (see the e2e test for the full setup)
coord = SwapCoordinator(record=SwapRecord(state=SwapState.NEGOTIATED, terms=terms),
                        counter_leg=eth_leg, radiant_leg=rxd_leg, indexer=indexer,
                        seen_store=seen, config=config)

# 0. MAKER funds the Radiant HTLC covenant on chain, and lets it bury.
#    This is a PRECONDITION, not an ordering preference: the BTC/ETH claim leaf has no
#    precondition that the asset was ever locked, and the maker holds p from the start — so a
#    maker that locks nothing can sweep a counter leg locked before it. See hazard HZ-1 in
#    docs/htlc-handshake-wire-format.md and threat-model S24.

# 1. TAKER locks the ETH — deploys + funds the EthHtlc on Anvil, bound to H. The pre-lock gate
#    reads the Radiant chain itself and REFUSES unless the maker's covenant is funded at the
#    agreed value and buried; the check is re-run immediately before the broadcast.
rec = await coord.taker_funds_btc(terms)                 # NEGOTIATED → BTC_LOCKED

# 2. The taker re-validates the on-chain covenant SPK, the Glyph ref, and the cross-clock
#    timelock margin, and records the asset lock in the FSM.
rec = await coord.post_asset_lock_revalidate(cov.funded_spk)   # → BOTH_LOCKED

# 3. MAKER claims the ETH — revealing p on-chain (the EthHtlc emits Claimed(p)).
rec = await coord.maker_claims_btc(p)                    # → SECRET_REVEALED

# 4. TAKER scrapes p off the maker's ETH claim and claims the Radiant asset — but only once the
#    reorg-finality gate says the ETH claim is reorg-SAFE (see "why it's trustless" below).
rec = await coord.taker_scrape_and_claim_asset(claim_tx, ...)  # → COMPLETED

That’s it — the secret revealed on one chain settles the other, and the asset covenant is spent.

Watch it actually settle

The end-to-end proof is wired up and maintained as an integration test — it runs this exact flow through the real coordinator, for three asset kinds (native RXD, a Glyph NFT, and a Glyph FT), against a live local Anvil + regtest node:

$ XCHAIN_ETH_REGTEST=1 pytest tests/test_xchain_eth_swap_regtest_e2e.py::TestEthRxdSwap \
      -m integration -s
...
tests/test_xchain_eth_swap_regtest_e2e.py ....
======================= 4 passed in ~7s =======================

Three trustless cross-chain swaps — RXD, an NFT, and an FT — plus the mutual-refund failure path, settling on real local ETH + Radiant consensus in under ten seconds, no real value at risk. The same file also runs the alert-only watchtower against the swap, proving it pages the right action at the right moment (and never pages a claim against a not-yet-final ETH reveal).

If a prerequisite is missing (Docker, the regtest image, anvil, or web3), the test skips rather than fails — so a green/ skipped result tells you exactly what’s set up.

Why it’s trustless (the safety, briefly)

The swap never asks you to trust the counterparty — it leans on each chain’s consensus plus three rules the coordinator enforces fail-closed:

  • Ordering + timelock margin. The maker locks the asset first, the taker locks ETH second, and the maker reveals p first. The leg the maker locks (Radiant) carries the longer refund window and the leg it claims the shorter, so the taker always has time to scrape p and claim before its own refund opens. The coordinator refuses to proceed unless t_rxd · i_rxd t_counter · i_counter + margin · i_counter — a wall-clock comparison, because a Radiant block is ~300 s against Ethereum’s and Bitcoin’s much longer targets. (This bullet gave the lock order and the inequality backwards until 2026-09-02.)

  • The reorg-finality gate. The taker doesn’t claim the asset the instant p appears — it waits until the ETH claim is reorg-safe (post-Merge finalized-checkpoint final, and the Radiant claim buried deep enough that reversing it would cost more than the value at stake). A premature reveal returns WAIT/SQUEEZED, never a silent claim.

  • No counterparty inputs trusted. Every step re-derives the covenant, re-checks the Glyph ref, and reads values from on-chain truth — never from what the other party asserts.

The one exception — a stablecoin counter-leg. The same machinery can settle in USDC or USDT instead of native ETH, which is often what you want: a stablecoin quote does not drift against either party while the timelocks run. It costs you the guarantee above. Native ETH is held by the EVM, while a stablecoin is held by a contract whose issuer can freeze an address — and a freeze landing in the window between revealing p and claiming lets the counterparty take their leg while you can neither claim nor refund. Freezing the HTLC contract itself is worse, and is measured: claim and refund both revert, permanently, with no timeout to rescue the funds. pyrxd re-reads freeze status at the chain tip immediately before it will build a claim, so abandoning the swap there costs only fees — but check-then-reveal is itself a race, and a freeze after the reveal is unmitigated. On that corridor, atomicity is conditional on the issuer not intervening. The trade-off in full is in build a cross-chain swap; the analysis is in the threat model.

If the maker stalls (locks the asset but never reveals p), nobody is stuck: after the timelocks mature, mutual_refund returns both legs to their owners.

Next steps

  • The pieces, as reference: build a cross-chain swap — the coordinator, the legs, MarginPolicy, and how to add another counter-chain.

  • Other chains, no new code: the same machinery runs against BTC (Taproot-HTLC) and the EVM family — Base, Optimism, Arbitrum, Linea (pyrxd.eth_wallet.chains). Only the per-chain finality window changes.

  • Stablecoin instead of ETH: the EVM counter-leg can hold USDC or USDT — same HTLC, weaker trust model, since the issuer can freeze the asset. See settling the counter-leg in a stablecoin.

  • Two operators, for real adversarial testing: run a two-host swap dry-run splits the swap across two separate operators exchanging only the public envelope — the genuine adversarial exercise.

  • Before any real value: the external security audit is the hard gate. See SECURITY.md.