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.One pyrxd version per swap record. From 0.27 the record names the role that wrote it and is versioned (schema 3): a coordinator run with the other
--rolerefuses it, and an older pyrxd reading it would drop the fields it does not know when it re-persists. Do not drive one record from two versions, and upgrade the watchtower with the runners.
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 300 --margin-blocks 36
# → writes envelope.json (copy it to the taker's host)
--t-rxd-blocks is the maker’s own leg, so it must be the LONGER one (#482): the covenant’s
refund may open no earlier than the ETH deadline (--eth-timeout-s, 24 h by default) plus the
cross-clock margin, and the taker’s step 3 refuses anything shorter. 300 is the smallest window
eth_absolute_to_rxd_relative_blocks accepts at the script’s default margins, the nominal
300 s Radiant interval and a 768 s finalization window (Ethereum, Sepolia, anvil); chains with a
longer window need more. The script’s own default of 120 is shorter than that, and the 60 this
page used to show exits at startup. Size it for your own deadline, chain and interval with that
function.
3. Taker — verify the margin, fund the ETH HTLC, publish the locator. The taker reads
the envelope, runs its independent assert_t_rxd_fits_the_eth_deadline check (refusing
unless the covenant’s refund opens no earlier than the ETH deadline plus the cross-clock
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 |
your half of the mutual unwind: on ETH each operator refunds the leg it locked (#850); on BTC the taker’s run still refunds both legs, 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, so the taker’s own leg is recoverable first.
On ETH, taker refund refunds the ETH HTLC only (#850). The covenant’s refund pays the
maker and is the maker’s to send: if the maker claimed the ETH before its timeout, the taker can
still claim the covenant with the revealed p (--phase claim), and a covenant refund sent from
the taker’s side would take that away. Since #850 PR 6 the record goes to mutual_refund when the
chain shows the HTLC refunded at the finalized block, which for a taker means “my leg is back”;
it does not mean the covenant was refunded.
ETH taker abort and taker refund decide from the chain, not from one log read (#850 PRs 5b
and 6). Before sending anything, the coordinator reads the HTLC’s disposition at the finalized
block through the shared reader (pyrxd.eth_wallet.disposition), from every endpoint the leg is
given; this runner gives it the one --eth-rpc-url. A terminal answer (claimed or refunded) from one
endpoint moves the record only on a chain id pyrxd knows as a testnet; on any other chain id it
changes nothing. Taker
refund also reads it first, before the covenant and timeout checks:
Claimed, verified at the finalized block: the phase refuses, sends nothing, names the claim tx and tells you to run
--phase claimbeforet_rxd.A
Claimedlog that is not yet verified: the phase prints a warning and goes on. An expired HTLC can no longer be claimed, so the refund still goes, but the record stays open untilt_rxdmatures, becausepmay be public. Claim the covenant first if that claim is on chain.Unknown (the reads disagree, or a settled contract shows neither a verified claim nor a verified refund): the refund is still attempted when the contract allows it. If it fails, the phase exits non-zero and does not conclude “refunded”. Check the contract’s events on another RPC or an explorer, and if there is a claim, put its tx hash in
maker_claim.jsonand run--phase claimbeforet_rxd.Refunded: nothing is sent; the record goes terminal.
Both phases follow their refund to the end. The record goes to aborted (abort) or
mutual_refund (refund) when the chain shows the HTLC refunded at the finalized block, not when
the refund is broadcast. The phase keeps polling until then, for up to --abort-poll-timeout-s
(7200 s by default; the flag applies to both phases). The refund’s hash is saved in the record
before it is sent, and polling reads that refund before it sends anything. It re-sends only when
the first was dropped or its nonce was used by another transaction. It replaces a refund still
pending after 600 s at the same nonce, and re-sends once after an out-of-gas refund. If the run
times out, run the same phase again; it follows the saved refund. A refund that abort sent is
followed by refund too, once the record is both_locked. A mined refund that reverted is not
re-sent: the phase stops, names the revert, and --resend-reverted-refund sends one more.
The swap record carries across phases. Each phase rebuilds its record from the exchange
files, and then merges it with the record this host saved (<--local-out>.swaprec.json). Fields
the record already holds are kept, such as the covenant outpoint pinned by an earlier phase and
the pending deploy or push handles. The exchange files only fill fields the record lacks. If
the record and the exchange files disagree on the terms, the hashlock, the counter-leg contract
or the covenant outpoint, the phase refuses and sends nothing. (An ETH locator’s deploy
transaction hash is not compared: it binds nothing, and the maker’s own verification cannot know
it.) Find out which one describes your swap before running it again. Each phase still drives the
FSM state it always did, so a retry can rewind in memory (a claim retry after the reveal was
recorded, a lock-claim retry), but the saved record never moves back from secret_revealed or a
later state to an earlier one: a retry that stops part-way leaves the record of the reveal in
place.
The saved record’s state is checked first, on both runners. Taker abort and taker refund
refuse when it says secret_revealed, asset_vulnerable or completed: p is public and the
covenant is yours to claim, so run --phase claim before t_rxd. Maker refund refuses when it
says secret_revealed, and maker abort when it says secret_revealed or completed, because
you claimed the counter leg and refunding the asset too would take both legs; re-run --phase lock-claim if that claim did not confirm. If your claim was dropped and the taker has since
refunded the counter leg, lock-claim fails its verification and these runners have no phase that
recovers the covenant from that record; pyrxd swap build-refund reads only a recovery file with
hashlock_H, rxd_covenant_spk and t_rxd_blocks, which they do not write. Maker abort also refuses whenever the record holds a
counter-leg locator (the taker did fund): use --phase refund. Terminal states are not refused
for being terminal, because today they are written when a transaction is broadcast, not when it
confirms, so re-sending a dropped refund must stay possible.
Taker --phase fund reads the record first as well: a record past negotiated (already funded)
refuses, and on ETH so does a record holding an interrupted deploy, which this runner does not
resume (it holds no fund lock); that contract refunds to you by its address after the ETH
deadline. The BTC runner does resume one: it records its funding transaction before
broadcasting it, and a re-run of --phase fund records that output if it has confirmed, or
else re-sends the same bytes.
scripts/btc_swap_two_host.py has the same four phases, with the BTC HTLC’s CSV (t_btc) in
place of the ETH timeout — but its taker refund is unchanged for now: it still refunds
both legs, so it still refuses until the covenant’s CSV has matured. On BTC the same race is
open (the claim leaf has no timelock, so the maker can claim while the taker’s refund is
unconfirmed); closing it needs the watchtower to tell the taker’s refund from a maker claim
first, which is tracked in #850.
What each phase actually drives¶
Role + phase |
Coordinator entry point |
FSM |
|---|---|---|
taker |
|
|
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 on ETH it no longer does. The covenant’s CSV refund needs no maker key — its scriptSig is
<OP_1>and nothing else — only a fee UTXO. On ETH, takerabortand takerrefundboth build their Radiant leg with a fee source that cannot dispense, so they are structurally incapable of broadcasting a covenant spend, and neither needs--fee-*. On BTC, takerrefundstill sends the covenant refund and still requires--fee-*(#850).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, on BTC, 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 — your half of the unwind once both legs are locked: the ETH HTLC only, after its timeout:
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>
# 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.