Cross-chain HTLC swap-handshake wire format¶
Date: 2026-08-10. Status: derived from the pyrxd implementation (not from a plan or a
prior doc); every normative statement below is pinned to a file:line or a test. Describes the
BTC↔RXD and ETH↔RXD atomic-swap negotiation as it is actually implemented.
⚠️ The stack this specifies is UNAUDITED
pyrxd’s cross-chain swap stack has not had an external security audit. It has been proven end-to-end on regtest and exercised with real value on mainnet, and it has survived several internal red-team passes — none of which is an audit. Writing this format down makes a second implementation possible; it does not make either implementation safe.
This document is normative about what the bytes are and what a receiver must check. It is not an assurance argument. Where the code is weaker than a reader would assume, that is recorded here plainly rather than smoothed over — see the Interop hazards section, which is the most important section in this file.
The stack defends safety, not liveness. See Known residuals.
Why this exists¶
The swap legs are specified by their chains: a BIP341 Taproot HTLC on Bitcoin, a Solidity HTLC
on Ethereum, a Radiant covenant on RXD. The negotiation that precedes them was not specified
anywhere — it lived implicitly in SwapCoordinator plus a pair of operator harnesses. A second
implementation could reproduce the on-chain artifacts and still be unable to complete a swap with
pyrxd, because it could not tell which fields cross the wire, which are local policy, which are
binding, and which are decorative.
This document is the missing half. Its sibling
swap-order-wire-format.md covers the on-chain RSWP orderbook
advertisement — a different, unrelated format for a different, unrelated protocol (same-chain
SIGHASH_SINGLE|ANYONECANPAY offers). If you are looking for OP_RETURN "RSWP", you want that
file. Nothing here appears on chain in an OP_RETURN; the handshake is an off-chain exchange of
JSON documents whose only on-chain shadow is the two scriptPubKeys both parties independently
re-derive from it.
Roles, and the one invariant everything rests on¶
swap_coordinator.py:134-154 names the invariant MAKER_SECRET_TAKER_LOCKS_BTC_FIRST:
The maker holds the Radiant asset and wants the counter-chain value (BTC/ETH). The maker generates the secret
p, publishesH = SHA256(p), locks the asset, and claims the counter leg first — which is what revealsp.The taker holds the counter-chain value and wants the asset. The taker funds the counter-leg HTLC, then claims the asset second by scraping
poff the maker’s counter-chain claim.
Only the maker ever holds p. It is generated as 32 CSPRNG bytes
(swap_coordinator.py:1001, in generate_secret), wrapped in SecretBytes, and is absent by construction from every
serialisable type — NegotiatedTerms, SwapRecord, and both locators have no field for it
(swap_state.py:16-19). The taker recovers p from the chain, never from a message.
The handshake¶
Five artifacts cross between the two parties. Four are JSON documents; the fifth is an on-chain
transaction both sides observe. This is the entire cross-party surface — the harness docstring
states it as such (scripts/btc_swap_two_host.py:42-50).
# |
Message |
Direction |
Drives |
|---|---|---|---|
1 |
|
taker → maker |
(pre-FSM) |
2 |
|
maker → taker |
establishes |
— |
maker funds the Radiant covenant on chain |
maker → chain |
see HZ-1 |
3 |
|
taker → maker |
|
4 |
maker locks/confirms the asset; taker revalidates |
— |
|
5 |
|
maker → taker |
|
Message ordering is strict: a receiver MUST NOT accept message n+1 before message n, and the
coordinator enforces this by refusing every step that is not valid from the current state
(swap_coordinator.py:2829, 3289, 3604, 3910, 3979 — the state checks opening taker_funds_btc,
post_asset_lock_revalidate, maker_claims_btc, taker_observed_reveal and
taker_scrape_and_claim_asset).
Envelope framing¶
Every message is a JSON object carrying a schema string:
{ "schema": "btc_rxd_two_host_envelope_v1", "…": "…" }
Value |
Meaning |
|---|---|
|
BTC counter leg ( |
|
ETH counter leg ( |
A conforming implementation MUST read schema and MUST refuse an unrecognised value, exactly
as gravity/watch/escalation.py:210-214 does for the watchtower heartbeat’s schema_version
(“Refusing to guess at the meaning of the escalation count”). Be aware that the shipped harnesses do not do this — see
HZ-2. The requirement is stated normatively here
because the alternative is a silent misparse of a future format.
schema is currently present only on envelope. Messages 1, 3 and 5 are bare objects. A
conforming implementation SHOULD tag all four with the same value and MUST tolerate its absence on
messages 1, 3 and 5 for compatibility with the shipped harnesses.
1. taker_intro (taker → maker)¶
The taker’s public key material, so the maker can build the covenant and the taptree.
Key |
Type |
Req. |
Meaning |
|---|---|---|---|
|
40-hex |
yes |
Taker’s Radiant pubkey-hash (20 bytes). Becomes |
|
64-hex |
BTC only |
Taker’s x-only key for the BTC refund leaf. |
|
|
ETH only |
Taker’s address; becomes the contract’s immutable |
Source: scripts/btc_swap_two_host.py:591-595, scripts/eth_swap_two_host.py:520-528. The ETH harness
also writes eth_maker_claim_addr (scripts/eth_swap_two_host.py:526) into taker_intro; the maker
ignores it and puts its own --eth-maker-addr in the envelope.
2. envelope (maker → taker)¶
The core message. Carries H — never p.
Key |
Type |
Req. |
Binding? |
Meaning |
|---|---|---|---|---|
|
string |
yes |
— |
See framing above. |
|
object |
yes |
yes |
The |
|
40-hex |
yes |
yes |
Maker’s Radiant pubkey-hash. Bound transitively: |
|
hex |
yes |
yes |
The Radiant covenant scriptPubKey the maker will fund. The taker MUST re-derive it and compare. |
|
hex |
BTC only |
no |
Where the maker sends its claimed BTC. Advisory — see HZ-6. |
|
|
ETH only |
yes |
Becomes the contract’s immutable |
|
|
ETH only |
yes |
Echoed from |
|
int |
ETH only |
not validated |
EIP-155 chain id. See HZ-5. |
|
string |
yes |
not validated |
Network tags. See HZ-5. |
Source: scripts/btc_swap_two_host.py:851-863, scripts/eth_swap_two_host.py:824-833. The ETH envelope
carries rxd_network and eth_chain_id but no btc_network.
The maker MUST NOT place a private key, WIF, seed, or the preimage in this document. The harnesses
enforce this with a recursive key-name and WIF-shape scan before every write
(scripts/btc_swap_two_host.py:141-183: _SECRET_FORBIDDEN_KEYS, _looks_like_wif,
_assert_public_only); a conforming implementation SHOULD do the same, because
the failure mode is unrecoverable and silent.
3. taker_funding (taker → maker)¶
The funded counter-leg locator — everything needed to later claim or refund the output. Losing it
strands the funds (taproot.py:617-621, the BtcHtlcLocator docstring), so it is durable state, not a transient message.
Key |
Type |
Counter leg |
|---|---|---|
|
object |
BTC — a serialised |
|
object |
ETH — a serialised |
BtcHtlcLocator (taproot.py:624-630): funding_outpoint{txid,vout}, claim_script,
refund_script, leaf_version, control_block_claim, control_block_refund, internal_key,
amount_sats, network.
EthHtlcLocator (eth_wallet/locator.py:168-175): chain_id, contract_address,
deploy_tx_hash, hashlock, claimant, refundee, timeout, amount_wei. Note the ETH
locator’s hashlock is 0x-prefixed (66 chars) while every hex field in terms is bare — a
serialiser that normalises one to the other will produce a rejected document
(eth_wallet/locator.py:185-186).
A receiving maker MUST NOT trust any field of this document. The taker controls every byte of
it and can, for example, describe a correct HTLC tree while pointing funding_outpoint at an
unrelated output it owns, or self-report an amount_sats the chain does not carry. The only safe
use is as a pointer: read the real output at that outpoint from a node and compare it against a
locally re-derived expectation. In this library that is
SwapCoordinator.maker_verify_counter_funding, which reads only the outpoint out of this document;
see HZ-3 for what it binds and why the derivable funding address does not bind the value.
4. Asset lock and revalidation¶
Not a message: the maker funds the covenant on chain and the taker observes it. The taker MUST call
the equivalent of post_asset_lock_revalidate (swap_coordinator.py:3264-3331), which recomputes the
expected covenant scriptPubKey from terms and compares it byte-for-byte against the observed one
(:3293-3309). Match ⇒ BOTH_LOCKED. Mismatch, or an inability to recompute it, ⇒
PARAMS_MISMATCH and the taker refunds the counter leg (:3294-3302 — the unrecomputable case is
treated as a mismatch, which is the fail-closed direction).
Every negotiated Radiant parameter that is substituted into the covenant bytecode — hashlock,
t_rxd, asset_variant, genesis_ref, radiant_amount, taker_dest_hash, maker_dest_hash — is
revalidated by this single byte-comparison, because changing any of them changes the SPK.
credential_ref is the exception, and it is a load-bearing one. It is not substituted into
the covenant; no covenant builder takes a credential parameter
(src/pyrxd/gravity/htlc_covenant.py:429-566, the three builders). A credential-gated swap and an ungated one with
otherwise identical terms produce a byte-identical covenant scriptPubKey — the published
btc-rxd-credential-gated and btc-rxd vectors in conformance/htlc-handshake-vectors.json
demonstrate exactly that, and test_credential_gating_does_not_change_the_covenant_spk asserts it.
The credential gate is therefore off-chain policy, enforced by the party that chooses to run
it — the credential rule, _credential_binding_failure (swap_coordinator.py:3333-3373),
resolves the credential, checks it is genuinely consensus-soulbound, and requires its owner to be
the swap’s pinned payout (taker_dest_hash). The taker’s pre-fund gate, pre_btc_lock_check
(swap_coordinator.py:1914-2096), runs it before funding, and the maker’s path runs it again
before BOTH_LOCKED, the precondition for revealing p. (Earlier revisions of this document
called the gate pre_btc_lock_gate; no function has that name.) It is fail-closed when a
credential_ref is present (an unwired resolver, an unresolvable ref, or an owner mismatch all
abort), but it is an off-chain check, not a spending condition: nothing on chain stops an
uncredentialed party who learns p and can produce the pinned holder script from claiming. An
implementer who treats the SPK byte-compare as binding the gate has no gate at all.
5. maker_claim (maker → taker)¶
The maker’s counter-chain claim, which reveals p on chain.
Key |
Type |
Counter leg |
|---|---|---|
|
hex |
BTC — the raw claim transaction |
|
|
ETH — the claim transaction hash |
Source: scripts/btc_swap_two_host.py:944, scripts/eth_swap_two_host.py:912.
This message is a convenience pointer, not a channel. p is public on chain the moment the
claim confirms; the taker can and should find it by watching the counter chain. A conforming taker
MUST verify, before acting on it (swap_coordinator.py:3985-3990):
SHA256(scraped p) == terms.hashlock— scraping is by hash over all candidate pushes, never by byte offset (counter_chain_leg.py:45-48);provenance — BTC: the transaction spends this swap’s funding outpoint (
swap_coordinator.py:3864-3886); ETH: the transaction targets this swap’s contract instance and emitsClaimed(p)from it (eth_leg.py:360-366). Without this, a claim transaction from a different swap that happens to shareHwould be accepted.
The terms object¶
The canonical wire form of NegotiatedTerms, produced by NegotiatedTerms.to_dict
(swap_state.py:484-515) and consumed by NegotiatedTerms.from_dict (:517-536). All hex is bare lowercase with no 0x prefix.
Key |
Type |
Req. |
Constraint |
Source |
|---|---|---|---|---|
|
64-hex |
yes |
Exactly 32 bytes. |
|
|
int |
yes |
|
|
|
int |
yes |
|
|
|
|
yes |
Counter-leg relative refund timelock. Advisory on an ETH swap — see HZ-4. |
|
|
|
yes |
Radiant relative refund timelock. MUST be |
|
|
|
yes |
Selects the covenant template. |
|
|
hex |
yes |
Reversed txid ‖ vout LE32. Produce 36 bytes for |
|
|
64-hex |
yes |
32 bytes = |
|
|
64-hex |
yes |
32 bytes = |
|
|
64-hex |
yes |
32 bytes. Maker’s key on the claim leaf. MUST be 32 zero bytes on an ETH swap. |
|
|
64-hex |
yes |
32 bytes. Taker’s key on the refund leaf. Same zero rule for ETH. |
|
|
|
optional |
Default |
|
|
int |
conditional |
Counter-leg amount in the counter chain’s own unit. Omitted on a BTC swap, where it must equal |
|
|
int |
conditional |
Required on an ETH swap, forbidden on a BTC swap. Absolute unix deadline; the contract immutable |
|
|
|
conditional |
The ERC-20 contract the counter leg is denominated in, lowercased; absent for a native-currency leg. ETH counter chain only. |
|
|
hex |
optional |
Empty or exactly 36 bytes. Off-chain soulbound-credential gate, checked by |
|
genesis_ref — what is actually enforced, and where¶
The row above states the producer rule. The checks a consumer can rely on are narrower, and the difference matters to anyone porting the validation:
Case |
Checked by |
Behaviour |
|---|---|---|
|
|
|
|
|
Constructs fine; raises |
|
nothing |
Accepted and silently ignored. The |
An implementation that wants the strict rule stated in the row must check it itself; pyrxd does not
reject an rxd swap that carries one.
Omission and default rules — normative¶
to_dict emits the last five keys only when they differ from the BTC defaults
(swap_state.py:499-514), so an all-BTC document is byte-identical to the pre-ETH schema. The one
refinement: value_amount is emitted on every non-BTC swap, even when it happens to equal
btc_sats. A
conforming reader MUST apply these defaults for absent keys:
Absent key |
Default |
|---|---|
|
|
|
|
|
|
|
empty (a native-currency leg) |
|
empty |
An implementation that requires all keys to be present will reject every honest BTC handshake.
Locked by test_optional_keys_are_omitted_exactly_when_default.
Worked example¶
A minimal BTC↔RXD terms object, from conformance/htlc-handshake-vectors.json (btc-rxd):
{
"hashlock": "c8cfe67f2d85148469f2998b7259d7cf356463c18a649c693e75f775a5259ddb",
"btc_sats": 100000,
"radiant_amount": 100000,
"t_btc": { "value": 20, "unit": "blocks" },
"t_rxd": { "value": 120, "unit": "blocks" },
"asset_variant": "rxd",
"genesis_ref": "",
"taker_dest_hash": "…",
"maker_dest_hash": "…",
"btc_claim_pubkey_xonly": "3333…33",
"btc_refund_pubkey_xonly": "4444…44"
}
Safety-critical parameters¶
Hashlock — SHA256, and the preimage is exactly 32 bytes¶
H = SHA256(p), single SHA256, 32 bytes (swap_coordinator.py:1002). Note that this is a
different hash from the dest hashes in the same object, which are hash256 (double SHA256) —
mixing them produces an unspendable covenant.
p MUST be exactly 32 bytes. This is not a convention; it is consensus-pinned on both legs.
BTC claim leaf:
OP_SIZE <0x20> OP_EQUALVERIFY OP_SHA256 <H> OP_EQUALVERIFY <claimPk> OP_CHECKSIG(taproot.py:334-356). The leading three opcodes are the pin; the leaf hex begins82 0120 88.Radiant covenant claim branch:
OP_SWAP OP_SIZE 20 OP_EQUALVERIFY OP_SHA256 <hashlock> OP_EQUALVERIFY, compiled into all three covenant artifacts.Builder-side, fail-closed before broadcast:
htlc_spend.py:330-331(len(preimage) != 32⇒ raise) andtaproot.py:1054.
Why the length rule is load-bearing. A red-team pass found a preimage-length asset-theft vector
that this pin closes, and the fix comment states it directly (taproot.py:338-343): without the
OP_SIZE prefix, a malicious maker could reveal a non-32-byte p' with SHA256(p') = H. That p'
satisfies the hashlock, so the maker’s claim succeeds — but the counterparty’s witness scrape only
considers 32-byte candidates, so it silently finds nothing, never learns p', and its covenant
strands until the maker’s CSV refund takes it. The counterparty loses the asset having paid the
counter leg.
A conforming implementation MUST: pin the length in the script it builds (not only in the
builder), reject a non-32-byte p before broadcast, and — on the scraping side — reject rather
than accept a candidate of any other length. Silently skipping a wrong-length candidate is what
turns this into a loss. Push 0x20 minimally (0120): MINIMALDATA is a consensus rule on both
chains, and on the Radiant side a non-minimal push permanently bricks the covenant on both
branches — finding F-001, now guarded fail-closed at build time for values in 1..16
(htlc_covenant.py:290-371, guard 3; the same reasoning on the BTC leaf at taproot.py:338-343).
The preimage MUST be fresh per swap and MUST come from a CSPRNG (swap_coordinator.py:1001). H
reuse is separately rejected: the coordinator atomically reserves H in a seen-store immediately
before the funding broadcast, and a second funder of the same H is refused with nothing
broadcast (swap_coordinator.py:2968-2972).
Timelock deltas — the maker’s own leg must outlast the one it claims¶
t_rxd · i_rxd ≥ t_btc · i_btc + margin · i_btc
where i_rxd and i_btc are the two chains’ block intervals in seconds. The relation is in WALL
CLOCK, not in raw block counts — t_btc counts Bitcoin blocks (~600 s) and t_rxd counts Radiant
blocks (~300 s), so a Radiant block buys roughly half of what a Bitcoin block does. At the nominal
600/300 the relation reduces to t_rxd ≥ 2·t_btc + 2·margin.
Enforced by assert_timelock_margin (pyrxd/gravity/swap_coordinator.py), in two steps: a strict
ordering check (t_rxd must exceed t_btc) and then the margin, both after converting to seconds.
The gate also subtracts the Radiant blocks already elapsed when it is called
(elapsed_blocks=cov_confs from pre_btc_lock_check), because the maker’s leg starts ticking at
its funding confirmation. A conforming implementation deriving t_btc from t_rxd MUST reserve
headroom for that elapsed depth, or the terms it derives will be refused by this same check once the
covenant confirms.
The invariant in words. The maker holds the preimage p. It LOCKS the Radiant leg and
CLAIMS the counter leg, so the leg it locks carries the LONGER refund window and the leg it
claims the SHORTER one — Herlihy, Atomic Cross-Chain Swaps
(arXiv:1801.09515 §1), where timeouts decrease along the
propagation path and consecutive ones differ by at least ∆.
What breaks if it is inverted. If t_rxd ≤ t_btc, the maker’s own leg refunds while the counter
leg is still claimable. The maker waits out its own CSV, refunds the Radiant asset while p is
still secret, and then claims the counter leg with p — taking both legs, deterministically, with
no recourse for the taker. The margin is not a safety buffer in that arrangement; it is the width of
the attack window.
⚠ THIS SECTION PUBLISHED THE OPPOSITE RULE UNTIL #482, and said so with confidence: it gave
t_btc − t_rxd ≥ margin, called the direction “counterintuitive”, and titled the paragraph above “What breaks if it is inverted” while describing the correct layout as the breakage. The conformance vectors matched it — they acceptedt_btc=60/t_rxd=20and rejectedt_btc=20/t_rxd=60. An implementation built to this document and checked against those vectors was assembled into the layout in which the party holdingptakes both legs.If you implemented against any published pyrxd handshake material before 2026-09-01, re-derive your timelock ordering. The vectors are corrected and their schema is bumped to
radiant-htlc-handshake/3so the files can be told apart by version.
⚠ THIS RELATION WAS PUBLISHED IN RAW BLOCKS UNTIL #567, comparing t_btc and t_rxd
one-for-one although they count different chains’ blocks. t_rxd=180 “exceeds” t_btc=144 by 36
raw blocks while being 15 h against 24 h in wall clock — the Radiant refund opening nine hours
before the counter-leg deadline. Under the pre-#482 direction that conflation was fail-safe; after
the inversion it was fail-open. Fixed in #567 and restated above in seconds. If you implemented
the block-denominated form, re-derive against the wall-clock relation.
Margin floor. There is no protocol-mandated minimum. margin is each party’s own policy,
not a negotiated field — the taker checks the maker’s terms against the taker’s margin and
refuses on failure (scripts/btc_swap_two_host.py:614-617). The shipped default is
ESTIMATED_DEFAULT_MARGIN_BLOCKS = 36 (swap_coordinator.py:177), which is labelled ESTIMATED
and is test-only: a policy constructed with require_measured=True refuses to use it
(:484-488). A real-value swap MUST supply a margin measured from real block data
(measure_margin_from_btc_block_times, :667-772).
Two traps.
NegotiatedTerms.__post_init__comparest_btcandt_rxdonly when they share a unit (swap_state.py:478-482). Aseconds-taggedt_btcshorter than ablockst_rxdconstructs without error. Only the normalisingassert_timelock_margincatches it. A conforming implementation MUST run the normalising check; the construction guard is defence in depth, not the safety check. Verified bytest_cross_unit_inversion_passes_construction_but_fails_the_margin_check.t_rxdMUST beblocks. Asecondsvalue would be used raw as the covenant CSV operand and desynchronise the on-chain refund window from every off-chain gate; it is rejected at construction (swap_state.py:420-424).
Leg addresses¶
There is no “address” field in terms. Destinations are bound in three different ways, and the
differences matter:
Destination |
How it is carried |
Binding? |
|---|---|---|
Radiant claim (pays the taker) |
|
Consensus-bound in the covenant; the claim’s |
Radiant refund (pays the maker) |
|
Consensus-bound, same mechanism. |
BTC claim (pays the maker) |
|
Not bound — see HZ-6. |
BTC refund (pays the taker) |
not on the wire at all |
Taker-local. Safe: it is the taker’s own money returning. |
ETH claim / refund |
|
Contract immutables; verified on chain by the maker before it reveals |
The pkhs themselves are not in terms — only hash256(holder(pkh)) is. The raw pkhs travel
separately, in taker_intro.taker_pkh_hex and envelope.maker_pkh_hex, so each side can rebuild
the covenant; they are bound only transitively, by recomputing
holder_hash(pkh, variant=…, genesis_ref=…) and comparing it to the dest hash baked into the
covenant bytecode (holder_hash, htlc_covenant.py:202-222; the same mechanism proves a credential’s owner is the
payout recipient at swap_coordinator.py:3363-3367). A pkh that does not reproduce the dest hash
is rejected fail-closed when the leg builds the covenant (radiant_leg.py:931-934).
Holder-script layouts, from htlc_covenant.py:190-199:
variant |
holder script |
bytes |
|---|---|---|
|
|
25 |
|
|
75 |
|
|
63 |
Funding parameters¶
The counter-leg amount is bound to the negotiated price after the lock, not before. A P2TR
scriptPubKey commits to the taptree, not to the output value; an ETH contract address commits to
its immutables, not to its balance. So the funding-target check cannot catch a wrong amount, and a
separate check does: funded != terms.value_amount ⇒ raise
(swap_coordinator.py:3122-3127). Both under- and over-funding are rejected; over-funding is a
one-sided taker loss because the claim leaf does not cap value.
The asset-leg amount is bound differently per variant — and the three are not equivalent:
variant |
covenant pin on |
Semantics |
|---|---|---|
|
|
≥, not equality |
|
|
exact carrier value |
|
|
exact token sum; no photon value is pinned at all |
All three additionally pin OP_TXOUTPUTCOUNT OP_1 (exactly one output) and the destination hash.
The leg re-reads the on-chain value rather than trusting a self-report, filtering the covenant UTXO
set on the expected value and failing closed when nothing matches (radiant_leg.py:360-363). When
several outputs match it does not refuse: it selects the earliest-confirmed one (unconfirmed
sorts last; ties break on txid, then index), and once the funded outpoint is recorded, the claim
pins to it instead of scanning again (radiant_leg.py:364-409). Refusing was the weakness this
replaced. The covenant SPK is a pure function of public terms, so a second payment to it can come
from anyone who can build one, and a refusal would let that payment block the taker’s claim until
the maker’s CSV refund opens. That is a one-sided taker loss. Selecting is safe because every
candidate is the identical covenant script at the exact agreed value. For rxd a second match is
real RXD that the same claim and refund rules govern. For ft and nft the script’s ref push has
to trace to an input that carries the ref, so only a holder of the asset can create one, and an
nft singleton cannot sit in two outputs at once.
Finality and confirmation-depth parameters¶
None of these cross the wire. Every depth, reorg, and confirmation parameter is per-party local
policy on MarginPolicy / CoordinatorConfig. Two conforming implementations can hold completely
different finality policies and still interoperate — each simply refuses at different moments. See
HZ-7.
The values a second implementation should know about:
Parameter |
Default |
Nature |
Source |
|---|---|---|---|
|
6 blocks |
ESTIMATED, test-only |
|
|
6 blocks |
ESTIMATED, test-only |
|
reorg-depth hard floor |
2 blocks |
not a knob — rejected below this |
|
ETH finalization window floor |
768 s (2 post-Merge epochs) |
not a knob |
|
|
6 |
policy |
|
|
6 |
policy |
|
|
1 |
policy — see HZ-8 |
|
|
6 |
policy |
|
The gate that consumes them, assess_claim_finality (swap_coordinator.py:1309-1464), returns
SAFE / WAIT / SQUEEZED and never claims silently off a shallow reveal. On a value-bearing
Radiant swap the coordinator additionally refuses to construct unless the burial is value-scaled
— ceil(value × factor / reorg_cost_per_block) — or the operator explicitly opts into a flat
burial for a dust run (:1745-1760). Both inputs are operator-supplied; there is no price or
hashrate feed in the stack.
Fee policy¶
No fee parameter crosses the wire. NegotiatedTerms has no fee field, and DeadlineFeePolicy
has no to_dict/from_dict — it is only ever constructor-injected (fee_policy.py:157-311;
radiant_leg.py:865; htlc_spend.py:313, 371). Fees are node policy, not protocol.
This is worth stating explicitly because on Radiant it is unusually consequential: the chain supports neither RBF nor CPFP, so an under-fee’d time-critical spend is not slow, it is unrepairable for up to the 8-hour mempool expiry. Pre-sizing is the only control. See threat-model S21.
Interop hazards (read this before implementing)¶
These are the places where the protocol is underspecified, or where the code is weaker than its own docstrings suggest. Each was found by reading the implementation against the spec being written, not from a prior document. They are ordered by consequence.
HZ-1: The FSM’s first edge is not the lock order¶
The FSM’s first transition is the taker’s — NEGOTIATED → BTC_LOCKED on the TAKER_FUNDS_BTC event,
at swap_state.py:167 — and the invariant’s name, MAKER_SECRET_TAKER_LOCKS_BTC_FIRST, predates
HZ-1 and reads the same way. Neither is the order value reaches the chains. The protocol is
maker-locks-first, and the code now says so where those names live: the comment on that edge
(swap_state.py:157-166) and the invariant’s own text (swap_coordinator.py:138-141, with the note
on its name at :151-153). The shipped BTC runbook follows it: the maker funds the Radiant covenant
right after publishing the envelope (scripts/btc_swap_two_host.py:865), and the taker verifies
that funding is on chain and buried before locking any BTC (:654-666). An earlier revision of
both the comment and the invariant described the opposite order; that disagreement is what this
hazard was first written about.
The harness states its own reason (scripts/btc_swap_two_host.py:654-658): “a hostile maker who never locks RXD can wait for
our BTC HTLC and claim it with p → one-sided taker loss.” That is a correct reading of the scripts
— the BTC claim leaf is <H> … <makerClaimPk> OP_CHECKSIG with no precondition that the asset was
ever locked, and the maker holds both p and the claim key from the moment the envelope is
published. Consensus permits the claim; the FSM simply has no edge for it (from BTC_LOCKED the
only exits are BOTH_LOCKED, ABORTED, PARAMS_MISMATCH — swap_state.py:175-179), and
maker_claims_btc refuses it only for an honest maker driving its own coordinator
(swap_coordinator.py:3604-3605). A hostile maker does not use a coordinator.
Normative: a taker MUST NOT fund the counter leg until it has confirmed the maker’s asset lock on chain, at the agreed scriptPubKey, for the agreed value, at a depth the taker chose. The FSM’s nominal ordering is a bookkeeping order, not a safety guarantee. Do not read “taker locks first” as permission to lock first.
What the library enforces (added after this hazard was written). The rule above was normative
and unimplemented: the check lived only in the two operator scripts, so a caller driving
SwapCoordinator directly got pre_btc_lock_check(...) -> ok=True and taker_funds_btc(...) -> BTC_LOCKED having invoked zero methods on the Radiant leg. It is now step 5 of the gate:
RadiantCovenantLeg.maker_funding_evidence(...)re-derives the covenant scriptPubKey from the taker’s ownterms(never anything the maker advertises), locates its funded UTXO withlistunspent, and fetches the funding transaction’s raw bytes, its merkle and coinbase branches and block headers. The coordinator then proves it withpyrxd.gravity.funding_spv.verify_maker_funding: the script and the value — bound toterms.radiant_amountexactly — are read from the transaction’s own bytes, its inclusion is checked against a header linked to a checkpoint pyrxd ships (through proof-of-work-checked headers above the newest one), and the depth must reachk = max(6, burial, ceil(2 × value ÷ C))on mainnet,Cbeing the photon cost of one forged confirmation. “Funded” alone is not enough — ElectrumXlistunspentincludes mempool outputs, so a maker can fund with a replaceable transaction, wait for the lock, then double-spend the funding away. That the output is still unspent remains the server’s word: SPV cannot show a non-spend.SwapCoordinator.taker_verify_asset_fundingis the entry point, run insidepre_btc_lock_checkand re-run insidetaker_funds_btcimmediately before the counter-leg broadcast — that re-run is what closes the verify→lock TOCTOU. It sits before theSeenStorereserve, so the reserve keeps its “last step before the only broadcast” property (TOCTOU-1) and a refusal does not burnH.Depth pin from existing policy: a real-value (
MarginPolicy.is_measured) swap requiresrxd_claim_burialconfirmations — the same depth the claim-finality gate requires of the taker’s own Radiant claim. An estimated/test policy defers to the leg’smin_confirmations.Fail-closed everywhere: an unfunded SPK, a mis-valued covenant UTXO, a shallow funding, an unreachable node, and a
radiant_legthat does not implement the read all refuse. Several matching UTXOs are not a refusal: the earliest-confirmed is selected (see the value-pin section above for why refusing there is the attack).
Both operator scripts now call the library rather than their own copy, so there is one
implementation. Threat model: S24. Tests:
tests/test_taker_asset_funding_gate_adversarial.py.
The FSM ordering is left as-is deliberately: it records transitions, and the safety requirement is
a precondition on the taker’s transition, not a different edge. Note for anyone driving the
coordinator: the maker’s covenant must be funded and buried before taker_funds_btc is called.
The six -m integration end-to-end suites have been reordered to that rule and now run green
against live regtest nodes (tests/test_xchain_swap_regtest_e2e.py,
tests/test_xchain_eth_swap_regtest_e2e.py, tests/test_xchain_eth_adversarial_e2e.py,
tests/test_xchain_eth_active_adversary_e2e.py, tests/test_xchain_eth_tierb_isolated_e2e.py,
tests/test_xchain_eth_glyph_real_rxindexer_e2e.py). Two adversarial scenarios moved to a stronger
assertion in the process — a maker that funds only a decoy covenant, and one that merely claims to
have locked, are both now refused before the taker locks anything, so neither costs the taker a
timeout wait — and the later post_asset_lock_revalidate layer is still exercised where the maker
funds the agreed covenant but misreports its scriptPubKey. See the CHANGELOG entry.
HZ-2: The version tag is written but never read¶
The two envelope schema strings appear at exactly four sites, all of them writes
(scripts/btc_swap_two_host.py:855, 1392; scripts/eth_swap_two_host.py:825, 1579 — the second of
each pair is the offline self-check fixture). No code path reads or validates the field: the taker
phases go straight to env["terms"]. Worse, NegotiatedTerms itself has no version field at
all — the only
SWAP_RECORD_SCHEMA_VERSION (swap_state.py:43) versions the durable record, and is emitted only
when the counter leg is ETH (:797-798).
Compounding it: NegotiatedTerms.from_dict reads named keys and silently ignores anything else
(swap_state.py:517-536; verified by test_from_dict_silently_ignores_unknown_keys). Combined with
the optional-key omission rules, a receiver cannot distinguish “the sender omitted this because it
holds the default” from “the sender is a newer version and meant something I cannot see.”
Consequence: there is no mechanism by which a receiver can detect that a sender meant something it does not understand. Every safety property therefore rests on the fields that are independently re-derived and byte-compared — the BTC funding scriptPubKey and the Radiant covenant scriptPubKey — and not on the envelope. Anything not covered by one of those two comparisons is, in effect, unauthenticated.
Normative: validate schema and fail closed on an unknown value. If you extend terms, put
the new field inside one of the two re-derived commitments, or it is not binding.
HZ-3: The maker-side BTC funding check — CLOSED in the library¶
Status: fixed. This section previously documented an open hole. It now documents what the library enforces; the historical framing is kept because the reasoning that produced the hole is the part worth not repeating.
What the hole was. SwapCoordinator.maker_verify_counter_funding used to refuse a BTC counter
leg outright, on the stated grounds that “a BTC counter leg’s funding target is a pure function of
terms, so the coordinator’s derive==promised pre-fund gate + the funding reader already bind it.”
That reasoning does not hold, for two reasons:
the
derive==promisedgate runs on the taker’s side, inside the taker’s own funding step, so a hostile taker simply does not run it; andit is a tautology anyway — see HZ-4b.
The only BTC maker-side binding was _maker_verify_btc_funding, in a script
(scripts/btc_swap_two_host.py). So an implementation built against pyrxd.gravity.SwapCoordinator
alone had no maker-side check that the taker funded the right HTLC with the right amount on a
BTC swap. Two outcomes, both real:
Wrong/absent HTLC → the maker locks the asset, cannot claim the BTC (its signature does not satisfy a taptree it did not expect), never reveals
p; both sides refund at their timelocks. Capital lockup, no theft — the griefing residual, from the maker’s side.Correct HTLC, under-funded → the maker locks the asset, claims the under-funded BTC (revealing
p), and the taker claims the asset. The maker is paid less than the agreed price. A real, bounded, one-sided maker loss — and the coordinator’s own amount bind does not catch it, because that check runs insidetaker_funds_btc, i.e. on the honest taker’s own leg. A P2TR scriptPubKey commits to the taptree, not to the output value, so every SPK-derivation check in the handshake passes on an HTLC funded short.
What the library now enforces. The check moved out of the script and into the library, with one implementation:
BitcoinTaprootLeg.verify_counterparty_funded(funding_ref, terms, *, min_confirmations=None)reads the output at the counterparty’s advertised outpoint authoritatively from the chain (a confirmed, unspent output — a spent/unconfirmed/unknown one raises) and asserts, all fail-closed: its scriptPubKey equals the HTLC re-derived from the maker’s ownterms; its value equalsterms.value_amountexactly (over-funding is rejected as well as under-funding, matching the taker-side bind — the claim leaf does not cap value, so an over-funded HTLC is a one-sided taker loss); and it is buriedmin_confirmationsdeep. The returned locator is rebuilt from the leg’s own derivation, so nothing counterparty-supplied survives into the maker’s claim.SwapCoordinator.maker_verify_counter_fundingnow accepts a BTC counter leg and dispatches to it. The one untrusted input the maker passes is the funding outpoint (aBtcOutpoint, aBtcHtlcLocatorwhose outpoint alone is read, or"<txid>:<vout>").SwapCoordinator.post_asset_lock_revalidatemakes it non-skippable on both chains: it requires a verified locator on the record and re-runs the verification at asset-lock time before it will advance toBOTH_LOCKED(the state that enables thepreveal). Re-running is what closes the verify→lock TOCTOU — a reorg, or a taker who funds only after the maker looked, is caught there.The depth pin reuses the policy’s existing reorg knob rather than inventing a second notion of BTC finality: a real-value (
MarginPolicy.is_measured) swap requiresbtc_claim_reorg_depthconfirmations; an estimated/test policy defers to the leg’smin_confirmations. This is the BTC analogue of the ETH gate’sblock_identifier='finalized'pin, and the sameis_measureddiscipline the N-floor and the cross-clock margin already use. PoW finality is a depth (seegravity/finality.py).A funding reader that cannot report a confirmed output’s scriptPubKey + value, a counter leg with no
verify_counterparty_funded, a missing locator, and an unreachable node all refuse the lock.
Adversarial coverage: tests/test_btc_maker_counter_funding_adversarial.py (under-funded,
over-funded, decoy scriptPubKey, shallow, spent, verified-then-reorged, and the fail-closed plumbing).
Normative (unchanged, and now what the library does): before locking the asset on a BTC swap, a
maker MUST read the output at the taker’s advertised outpoint from a node it trusts, and MUST assert
its scriptPubKey equals the locally re-derived HTLC scriptPubKey, its value equals
terms.value_amount, and it is unspent and buried to the maker’s chosen depth. On any failure: do
not lock. A second implementer MUST NOT infer from “the BTC funding address is derivable from terms”
that the funding is therefore bound — the address is bound; the value is not.
HZ-4: t_btc is required on an ETH swap and means nothing¶
On an ETH swap t_btc must still be supplied, must still be a Timelock, and t_rxd must
still exceed it (swap_state.py:411-412, 478-482) — but the real deadline is the absolute
eth_timeout_unix_s, and the ETH leg explicitly ignores the relative timelock the coordinator
passes to refund (eth_leg.py:318-321). The pre-fund ordering gate correctly routes ETH swaps to
a different, cross-clock check (swap_coordinator.py:2029-2033; _assert_eth_timelock_ordering,
swap_coordinator.py:2720-2788) rather than
assert_timelock_margin.
Normative: on an ETH swap, treat t_btc as a required placeholder with no on-chain meaning. Do
not derive a refund from it, and do not validate the ETH leg against it. Validate
eth_timeout_unix_s.
HZ-4b: The derive==promised gate is a tautology¶
Step 4 of the pre-fund gate compares derive_funding_scriptpubkey(terms) with
promised_funding_scriptpubkey(terms) (swap_coordinator.py:2052-2053), and the docstring calls
this “maker-promised params match the locally re-derived BTC funding SPK” (:1933). On both
shipped legs the two methods call the same function on the same input:
BTC —
src/pyrxd/btc_wallet/htlc_leg.py:412and:422, both returnself._htlc(terms).scriptpubkey; the docstring at:417-420says so plainly (“there is no separate maker-side derivation”).ETH —
eth_leg.py:93-107, both returnself._commitment(terms), computed from the leg’s own localclaim_to/refund_to/timeout.
There is no independently-transmitted “promised scriptPubKey” anywhere in the handshake. The check is a self-consistency assertion that would catch an internal derivation bug; it is not a counterparty check. A second implementer who reads it as “the maker’s claim is verified here” will omit the checks that actually bind.
HZ-5: Network identity is carried but never checked¶
envelope carries btc_network, rxd_network and (ETH) eth_chain_id
(scripts/btc_swap_two_host.py:859-860, scripts/eth_swap_two_host.py:830-831), but no reader
compares them to its own configuration — the taker phases read maker_pkh_hex,
covenant_spk_hex and the payout fields and nothing else. terms carries no network at all, and
BtcHtlcLocator.network defaults to "bc" (taproot.py:630).
This matters more than it looks, because a P2TR scriptPubKey is network-independent: the same
terms derive byte-identical funding output on mainnet, testnet and regtest — only the bech32 HRP
in the address differs. Two parties can therefore agree a complete, internally-consistent terms
object while operating on different networks. The failure is discovered only when one side cannot
find the other’s funding, i.e. after at least one lock.
Normative: validate btc_network / rxd_network / eth_chain_id against local configuration
before acting on terms, and refuse on mismatch. Do not rely on the derived scriptPubKey to
distinguish networks; it cannot.
HZ-6: The BTC payout SPK is advisory; the ETH one is binding¶
envelope.btc_maker_payout_spk_hex is not committed to anywhere. The BTC claim leaf is
… <claimPk> OP_CHECKSIG (taproot.py:354-355) — a bare signature check with no output
restriction, so the maker may send its claimed BTC anywhere. The field exists so each side can
construct its leg object; it is not a promise.
The ETH equivalents are the opposite: claimant and refundee are contract immutables, and the
maker verifies them on chain against its own expectation before it reveals p
(eth_leg.py:252-313 (verify_counterparty_funded)).
Normative: do not treat btc_maker_payout_spk_hex as binding, and do not build a safety
argument on it.
HZ-7: Finality policy is unnegotiated and unnegotiable¶
No confirmation depth, reorg depth, burial, or margin appears in any message. Each party applies its own. This is a deliberate design choice — a counterparty-supplied safety parameter would be worthless — but it has an interop consequence the handshake gives no way to express: two conforming implementations can hold irreconcilable policies and discover it only as an unexplained stall (one side waiting for depth the other considers unnecessary). There is no field in which to say “I will require 6 confirmations”, and no message with which to renegotiate.
Normative: publish your depth requirements out of band, and size t_rxd/t_btc for the
counterparty’s worst plausible policy, not your own.
HZ-8: The library default accepts a 1-confirmation covenant¶
RadiantCovenantLeg(min_confirmations=1) is the constructor default (radiant_leg.py:863). The
harness threads an operator flag into it and defaults that to 1 as well, with the flag’s own help
text warning that real value must set it deep (scripts/btc_swap_two_host.py:1539-1546). A
shallow or mempool-only covenant funding is replaceable/reorgable: a maker who double-spends it
after the taker has locked strands the taker’s counter leg.
Normative: set a depth appropriate to the value at risk. Do not ship the default.
The state machine¶
The FSM is the pure, exhaustively-tested core: 13 states, 14 edges, in swap_state.py:67-199.
advance(state, event) raises on any undefined (state, event) pair and on any transition out of
a terminal state — an undefined edge is a bug, never a no-op (:246-261).
From |
Event |
To |
Driven by |
|---|---|---|---|
|
|
|
message 3 |
|
|
|
timeout |
|
|
|
step 4, SPK match |
|
|
|
step 4, SPK mismatch |
|
|
|
|
|
|
|
taker refund |
|
|
|
message 5 |
|
|
|
stall trigger |
|
|
|
the safe failure |
|
|
|
maker-only primitive |
|
|
|
reorg gate |
|
|
|
reorg gate |
|
|
|
deliberate winner-take-all |
|
|
|
the R1 residual |
Terminal states: COMPLETED, MUTUAL_REFUND, ABORTED, ASSET_REFUNDED_TAKER_ACTS,
ONE_SIDED_LOSS_TAKER (swap_state.py:108-116).
Notes a second implementation needs:
The reorg gate’s
WAITverdict is not a transition. The record staysSECRET_REVEALEDand the caller retries; no state is stranded because the gate runs before any advance (swap_coordinator.py:4011-4017).ASSET_REFUNDED_TAKER_ACTSis reached by a maker-only primitive. The covenant’s CSV refund pays the maker in both directions, so a taker that runs it gifts the asset back and destroys its only recourse; the coordinator forbids it for aTAKER-role instance (swap_coordinator.py:4189-4194). The taker’s stall recovery ismutual_refund.Durable state MUST be persisted before an awaited broadcast and the post-broadcast write shielded from cancellation, or a retry double-funds (
swap_coordinator.py:2860-2863, 3131-3133).
Known residuals¶
This format does not close, and cannot close, the following. All are documented in
threat-model.md.
S22 — capital-lockup griefing. REAL and ACCEPTED. A counterparty can repeatedly open swaps, let you lock capital, and simply never lock their own leg. You are made whole at your timelock; they spend nothing and never transact, so there is nothing to observe or slash. pyrxd’s swap stack defends SAFETY, not LIVENESS. It will not let a counterparty take your funds; it will not stop one immobilising your capital for a timelock at near-zero cost. Do not read this document as promising griefing resistance. A bond was considered and deliberately not built — full reasoning and the revisit trigger in
solutions/design-decisions/griefing-is-a-liveness-residual-not-a-bond.md.S20 — the HTLC free option. REAL and ACCEPTED. If the taker is offline, pinned, or censored across the window from the maker’s reveal to
t_rxd, the maker CSV-refunds the asset and holds both legs. Bounded by the margin, the reorg gate, and the value-scaled burial; not eliminated. It is inherent to the reveal-on-the-long-leg shape, not a pyrxd defect.S21 — under-fee’d time-critical spends are unrepairable. Radiant has neither RBF nor CPFP, so a claim or refund below the effective relay floor cannot be bumped and squats on its own inputs for up to 8 hours. Fee pre-sizing is the only control, and it is local, not negotiated.
S10 — Gravity covenant bugs. The threat model records this as the most concentrated risk in the codebase and an audit-recommended target.
No external audit. The gate before relying on this for non-dust value is an external audit plus a genuine two-party adversarial run, not a passing conformance suite.
Conformance vectors¶
conformance/htlc-handshake-vectors.json (schema: "radiant-htlc-handshake/1"), guarded by
tests/test_htlc_handshake_conformance_vectors.py — 51 checks over five terms vectors (rxd / ft
/ nft on BTC, rxd on ETH, and a credential-gated variant), three preimage-length vectors, and four
margin verdicts.
Each terms vector publishes the exact wire object plus the two artifacts a counterparty
independently re-derives from it: the BTC P2TR funding scriptPubKey (with its taptree leaf scripts
and address) and the Radiant covenant scriptPubKey. Build both from terms and byte-compare; that
is the whole interop test.
pyrxd is the reference producer here, so a green run is a regression lock, not independent
validation — and there is no mainnet-anchored vector yet (tracked follow-up). See
conformance/README.md for the suite conventions.
One published fact is worth reading before you build anything: the btc-rxd, btc-ft and
btc-nft vectors differ in asset_variant, genesis_ref, radiant_amount and both dest hashes,
and derive the identical BTC funding address. The BTC taptree commits to H, the two x-only
keys and t_btc — and to nothing at all on the Radiant side. Verifying the counter leg therefore
tells you nothing about which asset you are buying. Only the covenant scriptPubKey comparison does.
Locked by test_btc_funding_spk_does_not_bind_the_asset_side.
The preimage in the vectors is a fixed 32-byte ASCII string, deliberately not CSPRNG output, so that no conformance file ever carries a value capable of opening a funded HTLC.