Run a two-host ETH↔RXD swap dry-run¶
Every swap run shipped so far has been single-process: one program plays both
the maker and the taker, holding every key and the preimage p in one address space.
That proves the plumbing — the legs broadcast, the FSM advances — but it does not
exercise the one property an atomic swap exists for: safety against a hostile,
untrusted counterparty. As long as one process holds all the keys, the “counterparty
verification” steps are checking the program against itself.
This runbook drives scripts/eth_swap_two_host.py, which splits the existing flow
across two operators on two hosts. Each operator holds only their own keys and sees
only the public negotiation envelope (plus a couple of public locators), copied
between the hosts out-of-band. It is the first real exercise of untrusted-counterparty
verification — and it is the prep for a genuine two-party adversarial run, not the
run itself.
Regtest / testnet exercise — unaudited swap stack. This is the same HTLC swap primitive described in Build a cross-chain atomic swap: open-source, provided as-is, and not externally audited — verify it yourself before moving real value. RXD runs on a Radiant regtest node; ETH runs on a local anvil or Sepolia (free testnet). This harness has no mainnet wiring — you pass
--audit-clearedeven to name Sepolia, an explicit opt-in for the value-bearing testnet. It’s a two-party learning / validation exercise.
For the model behind the steps — the maker/taker roles, the H = SHA256(p) hashlock, and
the wall-clock t_rxd · i_rxd ≥ t_counterchain · i_counterchain + margin · i_counterchain
safety invariant — read
Build a cross-chain atomic swap first. This page is the
operational how; that page is the why.
What stays local vs. what crosses between hosts¶
The whole point of the split is key isolation:
Operator |
Holds locally (NEVER copied to the other host) |
|---|---|
Maker |
the preimage |
Taker |
the taker’s RXD claim key; the taker’s ETH funding key |
Each role persists its private state to a mode-600 local file (--local-out, default
.<role>_local_secret.json inside the io dir) that is not part of the exchange channel.
The entire cross-host surface is four JSON files copied out-of-band (scp, a USB stick,
a paste — anything; the harness never networks them itself). All four are public: the
preimage p is never serialised into any of them. The writer asserts this fail-closed —
it refuses to write a file whose keys look like a secret (preimage, wif, secret, …).
# |
File |
Direction |
Contents (all public) |
|---|---|---|---|
1 |
|
taker → maker |
the taker’s RXD pubkey-hash + ETH addresses |
2 |
|
maker → taker |
the |
3 |
|
taker → maker |
the funded ETH HTLC locator ( |
4 |
|
maker → taker |
the maker’s ETH claim tx hash (the taker scrapes |
The envelope (envelope.json)¶
The envelope is NegotiatedTerms.to_dict() plus the maker’s public payout config. Its
exact fields:
terms.hashlock— H = SHA256(p) (the only secret-derived value;pis absent)terms.btc_sats/terms.radiant_amount— the RXD amount (photons)terms.t_btc/terms.t_rxd— the two refund timelocks (the margin invariant lives here)terms.asset_variant("rxd"),terms.genesis_ref(empty for plain RXD)terms.taker_dest_hash/terms.maker_dest_hash— the covenant holder bindingsterms.counter_chain("eth"),terms.value_amount(wei),terms.eth_timeout_unix_smaker_pkh_hex— the maker’s RXD pubkey-hash (public; needed to re-derive the covenant)eth_maker_claim_addr/eth_taker_refund_addr— the ETH payout addresseseth_chain_id,rxd_network,covenant_spk_hex— the SPK the maker will fund
There is no preimage field anywhere in the schema. The taker independently re-derives
the covenant SPK from the public terms and refuses to proceed if it does not match the
maker’s advertised covenant_spk_hex.
Before you start¶
A Radiant regtest node reachable over ElectrumX/Fulcrum (
--rxd-electrumx-url), with the covenant-funding and fee UTXOs in a regtest wallet. See the quickstart forpyrxd regtest setup/up.An ETH endpoint: a local
anvil(--eth-chain-id 31337) or Sepolia (--eth-rpc-url …, plus--audit-clearedto opt in to the value-bearing testnet run).Each operator funds their own regtest fee UTXO (the covenant output carries the asset and cannot also pay the miner fee). Pass it per role via
--fee-txid/--fee-vout/--fee-value/--fee-spk-hex/--fee-wif. The WIF stays local — it is never written into an exchange file.A shared io directory (
--io). On two real hosts this is a per-host directory; you copy the four files between them in the order below.
Validate the seam first (no chain)¶
Before touching any chain, run the offline self-check. It exercises the security-critical
seam end-to-end: the maker assembles + serialises the envelope, the taker reads it back,
re-derives the covenant, runs the independent margin check, and the harness asserts p
never appears in any serialised artifact — and that the margin check rejects a hostile
too-tight envelope.
$ python scripts/eth_swap_two_host.py --self-check
=== two-host swap PREP self-check (NO chain) ===
[ok] envelope serialises H only — no p, no WIF
[ok] the serialiser guard REJECTS a doc carrying a preimage/secret key
[ok] taker re-derives the SAME covenant SPK from the envelope's public terms
[ok] taker's INDEPENDENT timelock-margin check passes for honest terms
[ok] taker REFUSES a hostile too-tight envelope
SELF-CHECK PASSED …
The two-host run, step by step¶
Each numbered step is one command on one host. Between steps, copy the named file to the other host’s io directory in this order — the next step refuses to run until its input file is present.
The harness confirms before every irreversible broadcast (type broadcast to
proceed; anything else aborts). --yes bypasses confirmation for an unattended run — use
it only when you know exactly what you are signing up for.
Put the signing key in a file, not on the command line¶
Every --eth-key-file below expects a mode-600 file holding the key as hex. A secret passed as
--eth-key-hex is readable by every local user for as long as the process runs (ps shows the
full argument vector) and stays in shell history afterwards; a path on the command line is not a
secret. The runners still accept --eth-key-hex for throwaway keys.
$ (umask 077; printf '%s' "<your-hex-key>" > ~/.swap-taker-eth-key)
$ ls -l ~/.swap-taker-eth-key # must be -rw-------
The runners refuse a key file that is group- or world-readable, owned by another account, a symlink, or not a regular file — checked on the descriptor they actually read, so replacing the path between the check and the read does not work.
1. Taker — publish intro. The taker generates its own RXD + ETH keys, persists them locally (mode 600), and publishes only the public half.
taker$ python scripts/eth_swap_two_host.py --role taker --phase intro \
--io ./swapdir \
--eth-taker-addr 0x<taker-eth-addr> --eth-maker-addr 0x<maker-eth-addr> \
--eth-key-file ~/.swap-taker-eth-key
# → writes taker_intro.json (copy it to the maker's host)
2. Maker — assemble + publish the envelope. The maker generates (p, H), reads
taker_intro.json, builds the covenant + terms, persists p to its local mode-600 file,
and publishes envelope.json. The maker prints the covenant SPK to fund.
maker$ python scripts/eth_swap_two_host.py --role maker --phase envelope \
--io ./swapdir \
--eth-maker-addr 0x<maker-eth-addr> --eth-key-file ~/.swap-maker-eth-key \
--rxd-photons 100000 --t-rxd-blocks 60 --margin-blocks 36
# → writes envelope.json (copy it to the taker's host)
3. Taker — verify the margin, fund the ETH HTLC, publish the locator. The taker reads
the envelope, runs its independent assert_timelock_margin check (refusing if
t_eth − t_rxd < margin), re-derives the covenant SPK and checks it matches, then funds
the ETH HTLC first (claim pays the maker, refund pays the taker) and publishes the
funding locator.
taker$ python scripts/eth_swap_two_host.py --role taker --phase fund \
--io ./swapdir \
--eth-rpc-url <sepolia-or-anvil> --eth-key-file ~/.swap-taker-eth-key --audit-cleared \
--rxd-electrumx-url ws://<regtest-electrumx> \
--fee-txid <…> --fee-vout <…> --fee-value <…> --fee-spk-hex <…> --fee-wif <…>
# → writes taker_funding.json (copy it to the maker's host)
4. Maker — verify the ETH HTLC, lock RXD, claim ETH (reveal p). The maker verifies the
taker’s on-chain ETH HTLC binds to terms (claimant == maker, refundee == taker, H,
timeout, funded) before locking anything. Then the maker funds the RXD covenant SPK on
regtest (the harness pauses for you to do this and confirm ≥ 1 conf), the coordinator
re-validates pinned to finality, and finally the maker claims the ETH — revealing p
on-chain. The claim tx hash is published.
maker$ python scripts/eth_swap_two_host.py --role maker --phase lock-claim \
--io ./swapdir \
--eth-rpc-url <sepolia-or-anvil> --eth-key-file ~/.swap-maker-eth-key --audit-cleared \
--rxd-electrumx-url ws://<regtest-electrumx>
# → writes maker_claim.json (copy it to the taker's host)
5. Taker — scrape p, claim the RXD covenant. The taker reads the maker’s claim tx hash,
scrapes p from that transaction on-chain (never from a file), runs the reorg-finality
gate, and claims the RXD covenant before its CSV refund window opens.
taker$ python scripts/eth_swap_two_host.py --role taker --phase claim \
--io ./swapdir \
--eth-rpc-url <sepolia-or-anvil> --eth-key-file ~/.swap-taker-eth-key --audit-cleared \
--rxd-electrumx-url ws://<regtest-electrumx> --asset-locked-at-height <rxd-height> \
--fee-txid <…> --fee-vout <…> --fee-value <…> --fee-spk-hex <…> --fee-wif <…>
# → on SAFE: claims the covenant → COMPLETED (the swap is done)
The safety checks you are actually exercising¶
The taker independently verifies the margin. Step 3 runs
assert_timelock_marginagainst the envelope alone, with the taker’s own policy — a hostile maker who sets a too-tight RXD refund (or too-loose ETH timeout) is rejected before the taker funds.The taker re-derives the covenant. The taker never trusts the maker’s advertised SPK; it rebuilds it from the public terms and refuses on a mismatch.
The maker verifies the counter-leg before locking. Step 4 runs
maker_verify_counter_funding(and re-runs it pinned to finality at RXD-lock time) — a hostile taker who deploysclaimant = selfor underfunds cannot make the honest maker lock the asset for nothing.ponly ever appears on-chain. It crosses the seam exactly once — when the maker’s ETH claim reveals it on Ethereum — and the taker reads it from there.
Recovery: --phase abort and --phase refund¶
Both roles have both phases, and the rule for choosing between them is one sentence:
Phase |
Use it when |
What it recovers |
|---|---|---|
|
the swap never reached both legs locked — or you simply want your own leg back |
only the leg you locked, unilaterally, as soon as its own timelock matures |
|
both legs are locked and the swap will not complete |
the mutual unwind: every leg goes back to whoever locked it, once both timeouts have elapsed |
abort is available earlier, and it is the one a stalled operator reaches for first. The
ETH HTLC times out at eth_timeout_unix_s; the RXD covenant’s CSV matures at t_rxd, which
is deliberately the later of the two. In the stretch between them the taker can already
recover its own ETH while the mutual unwind is not yet possible — so refund refuses
there, names the shortfall, and points at abort, rather than refunding one leg and failing
on the other. (mutual_refund broadcasts the counter leg first; a half-completed unwind
leaves the record stuck at both_locked with a retry that can never finish.)
scripts/btc_swap_two_host.py has the identical four phases, with the BTC HTLC’s CSV
(t_btc) in place of the ETH timeout.
What each phase actually drives¶
Role + phase |
Coordinator entry point |
FSM |
|---|---|---|
taker |
|
|
taker |
|
|
maker |
|
|
maker |
|
(no advance) |
Three things about that table are worth knowing before you need it:
The taker can push the maker’s covenant refund, and that is not a loss. The covenant’s CSV refund needs no maker key — its scriptSig is
<OP_1>and nothing else — only a fee UTXO. That is why--fee-*is required for takerrefundand not for takerabort: the abort path builds its Radiant leg with a fee source that cannot dispense, so it is structurally incapable of broadcasting a covenant spend at all.The asset-only refund is a taker-destroying trap, and is wired for the maker only.
maybe_refund_asset_on_maker_stallrefunds the covenant, which pays the maker in both directions. A taker that ran it would gift the asset back and destroy its own only recourse while its ETH stayed locked, after which the maker — still holdingp— takes both legs. The coordinator’s role guard refuses it outright for a taker-role coordinator, and no taker phase in either harness can reach it. It appears here as the maker’s half of the mutual unwind (the counter leg is the taker’s to refund), not as a general recovery.Maker
abortis the one case with no coordinator entry point. The maker funds the covenant right after publishing the envelope, so a taker who never funds leaves the maker atnegotiatedwith a locked asset — and both coordinator asset refunds requireboth_locked. The harness therefore calls the same leg primitive both of them call, and does not fabricate aboth_lockedrecord for a counter leg that was never funded.
Running them¶
Both maker phases and taker refund spend the RXD covenant, so they need that operator’s own
regtest fee UTXO (--fee-txid/--fee-vout/--fee-value/--fee-spk-hex/--fee-wif); they refuse up
front, naming the flags, if it is missing. Every broadcast still goes through the same
confirm prompt as the happy path.
# taker — get your own ETH back as soon as the HTLC timeout passes (no Radiant read needed):
taker$ python scripts/eth_swap_two_host.py --role taker --phase abort \
--io ./swapdir --eth-rpc-url <sepolia-or-anvil> \
--eth-key-file ~/.swap-taker-eth-key --audit-cleared \
--rxd-electrumx-url ws://<regtest-electrumx>
# taker — the mutual unwind, once BOTH timeouts have elapsed:
taker$ python scripts/eth_swap_two_host.py --role taker --phase refund \
--io ./swapdir --eth-rpc-url <sepolia-or-anvil> \
--eth-key-file ~/.swap-taker-eth-key --audit-cleared \
--rxd-electrumx-url ws://<regtest-electrumx> \
--fee-txid <…> --fee-vout <…> --fee-value <…> --fee-spk-hex <…> --fee-wif <…>
# maker — the maker's half of the unwind, once t_rxd has matured:
maker$ python scripts/eth_swap_two_host.py --role maker --phase refund \
--io ./swapdir --eth-rpc-url <sepolia-or-anvil> \
--eth-key-file ~/.swap-maker-eth-key --audit-cleared \
--rxd-electrumx-url ws://<regtest-electrumx> \
--fee-txid <…> --fee-vout <…> --fee-value <…> --fee-spk-hex <…> --fee-wif <…>
# maker — the taker never funded at all: unlock the covenant and walk.
# (No ETH key and no ETH RPC: this phase never constructs an ETH leg.)
maker$ python scripts/eth_swap_two_host.py --role maker --phase abort \
--io ./swapdir --rxd-electrumx-url ws://<regtest-electrumx> \
--fee-txid <…> --fee-vout <…> --fee-value <…> --fee-spk-hex <…> --fee-wif <…>
Each phase checks that it is in its own situation and refuses if it is not, naming the phase
you want instead: maker refund requires taker_funding.json to exist, maker abort
requires it not to, and taker refund requires the covenant to be verifiably funded at the
agreed SPK and amount. A Radiant node that cannot answer is never read as “not funded” — the
lookup cannot tell those two apart, so taker refund refuses rather than guessing, while
taker abort (which needs no Radiant read at all) still works.
One thing the harness deliberately will not do: refund the covenant to the maker after
the maker has already claimed the counter leg. That is the FSM’s
asset_vulnerable → one_sided_loss_taker edge — the maker taking both legs — it has no
coordinator driver, and maker refund detects the published claim and declines. The
background model for all of this is in
Build a cross-chain atomic swap.