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-cleared even 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 p; the maker’s RXD refund key; the maker’s ETH claim key

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_intro.json

taker → maker

the taker’s RXD pubkey-hash + ETH addresses

2

envelope.json

maker → taker

the NegotiatedTerms (hashlock H only), the maker’s public ETH/RXD payout config, and the funded covenant SPK

3

taker_funding.json

taker → maker

the funded ETH HTLC locator (EthHtlcLocator — carries H, never p)

4

maker_claim.json

maker → taker

the maker’s ETH claim tx hash (the taker scrapes p from that tx on-chain, never from this file). Optional: without it, --phase claim finds the claim in the HTLC contract’s logs

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; p is 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 bindings

  • terms.counter_chain ("eth"), terms.value_amount (wei), terms.eth_timeout_unix_s

  • maker_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 addresses

  • eth_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 for pyrxd regtest setup / up.

  • An ETH endpoint: a local anvil (--eth-chain-id 31337) or Sepolia (--eth-rpc-url …, plus --audit-cleared to 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 --role refuses 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_margin against 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 deploys claimant = self or underfunds cannot make the honest maker lock the asset for nothing.

  • p only 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

abort

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

refund

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 claim before t_rxd.

  • A Claimed log 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 until t_rxd matures, because p may 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.json and run --phase claim before t_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 abort

SwapCoordinator.taker_refund_btc(), polled (ETH)

btc_locked → aborted (ETH: once the refund is final at the finalized block)

taker refund (ETH)

SwapCoordinator.mutual_refund() on a taker-role coordinator, polled

both_locked → mutual_refund once the ETH refund is final at the finalized block (the covenant is the maker’s)

taker refund (BTC)

SwapCoordinator.mutual_refund()

both_locked → mutual_refund

maker refund

SwapCoordinator.maybe_refund_asset_on_maker_stall()

both_locked → asset_refunded_taker_acts

maker abort

RadiantCovenantLeg.refund_asset() — no coordinator entry point exists

(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, taker abort and taker refund both 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, taker refund still 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_stall refunds 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 holding p — 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 abort is 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 at negotiated with a locked asset — and both coordinator asset refunds require both_locked. The harness therefore calls the same leg primitive both of them call, and does not fabricate a both_locked record 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.