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:98-109 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:529), 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, 516-517). 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:1352, 1456, 1573, 1636, 1705).
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 (“Refusing to guess at
the meaning of the fields”). 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:435-439, scripts/eth_swap_two_host.py:637.
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:676-688, scripts/eth_swap_two_host.py:632-641.
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:118-160); 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:527-532), so it is durable state, not a transient message.
Key |
Type |
Counter leg |
|---|---|---|
|
object |
BTC — a serialised |
|
object |
ETH — a serialised |
BtcHtlcLocator (taproot.py:572-584): 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:93-104): 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:77-78).
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:1437), which recomputes the
expected covenant scriptPubKey from terms and compares it byte-for-byte against the observed one
(:1462-1475). Match ⇒ BOTH_LOCKED. Mismatch, or an inability to recompute it, ⇒
PARAMS_MISMATCH and the taker refunds the counter leg (:1464-1469 — 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:391-419). 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 before funding, by the party that
chooses to run it — pre_btc_lock_gate (swap_coordinator.py:1241-1266) resolves the credential,
checks it is genuinely consensus-soulbound, and requires its owner to be the swap’s pinned payout
(taker_dest_hash). 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 a pre-fund 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:762, scripts/eth_swap_two_host.py:711.
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:1636-1656):
SHA256(scraped p) == terms.hashlock— scraping is by hash over all candidate pushes, never by byte offset (counter_chain_leg.py:93-95);provenance — BTC: the transaction spends this swap’s funding outpoint (
swap_coordinator.py:1590-1612); ETH: the transaction targets this swap’s contract instance and emitsClaimed(p)from it (eth_leg.py:214-220). 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 to_dict (swap_state.py:370-395) and
consumed by from_dict (:397-415). 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 when it equals |
|
|
int |
conditional |
Required on an ETH swap, forbidden on a BTC swap. Absolute unix deadline; the contract immutable |
|
|
hex |
optional |
Empty or exactly 36 bytes. Off-chain soulbound-credential gate, checked before funding 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 four keys only when they differ from the BTC defaults
(swap_state.py:385-394), so an all-BTC document is byte-identical to the pre-ETH schema. A
conforming reader MUST apply these defaults for absent keys:
Absent key |
Default |
|---|---|
|
|
|
|
|
|
|
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": 60, "unit": "blocks" },
"t_rxd": { "value": 20, "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:530). 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:281-303). 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:272-273(len(preimage) != 32⇒ raise) andtaproot.py:871.
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:287-290): 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:268-333, guard 3; the same reasoning on the BTC leaf at taproot.py:287-290).
The preimage MUST be fresh per swap and MUST come from a CSPRNG (swap_coordinator.py:529). 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:1363-1375).
Timelock deltas — the direction is counterintuitive and inverting it loses funds¶
t_btc − t_rxd ≥ margin (both normalised to blocks)
Enforced by assert_timelock_margin (swap_coordinator.py:475), in two steps:
swap_coordinator.py:506—if btc_blocks <= rxd_blocks: raise(strict ordering)swap_coordinator.py:510—if (btc_blocks - rxd_blocks) < margin_blocks: raise(the margin)
The invariant in words (swap_coordinator.py:106-108): the leg claimed second (Radiant) has
the SHORTER refund window; the leg claimed first (the counter leg) holds the LONGER one.
The party who commits capital first therefore also waits longest to get it back — that asymmetry is
deliberate, and it is the direct cause of the accepted griefing residual (see
Known residuals).
What breaks if it is inverted. If t_rxd ≥ t_btc, the Radiant refund does not mature before the
counter-leg refund. The taker, who must claim the asset after the maker reveals p, can be left
with no window in which both “the maker has revealed” and “the maker’s CSV refund has not yet
opened” hold — the maker refunds the asset out from under the pending claim while still holding a
claimable counter leg. The margin is the buffer that keeps that window open; it must cover the
counter-chain confirmation tail, the Radiant reorg depth, and the seconds↔blocks conversion slack
(swap_coordinator.py:116-123).
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:451-457). The shipped default is
ESTIMATED_DEFAULT_MARGIN_BLOCKS = 36 (swap_coordinator.py:132), which is labelled ESTIMATED
and is test-only: a policy constructed with require_measured=True refuses to use it
(:271-275). A real-value swap MUST supply a margin measured from real block data
(measure_margin_from_btc_block_times, :378-472).
Two traps.
NegotiatedTerms.__post_init__comparest_btcandt_rxdonly when they share a unit (swap_state.py:364-368). 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:332-336).
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 locks ( |
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 (htlc_covenant.py:180; the same mechanism proves a credential’s owner is the
payout recipient at swap_coordinator.py:1257-1262). A pkh that does not reproduce the dest hash
is rejected fail-closed when the leg builds the covenant (radiant_leg.py:468-471).
Holder-script layouts, from htlc_covenant.py:168-177:
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:1389-1394). 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 on an ambiguous set (radiant_leg.py:199-206).
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:707), 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 (:1062-1077). 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:122-155;
radiant_leg.py:402; htlc_spend.py:255, 313). 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 shipped runbook inverts the documented lock order¶
swap_state.py:151-152 and the MAKER_SECRET_TAKER_LOCKS_BTC_FIRST invariant both say the taker
funds the counter leg first. The shipped BTC runbook does the opposite: the maker funds the
Radiant covenant right after publishing the envelope (scripts/btc_swap_two_host.py:690), and the
taker verifies that funding is on chain and buried before locking any BTC
(:503-533). The FSM still records the taker’s lock as the first transition.
The harness states its own reason (:503-506): “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:156-160), and
maker_claims_btc refuses it only for an honest maker driving its own coordinator
(swap_coordinator.py:1573-1574). 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.verify_maker_asset_funded(terms, *, min_confirmations=None)re-derives the covenant scriptPubKey from the taker’s ownterms(never anything the maker advertises), locates its funded UTXO, binds the on-chain value toterms.radiant_amountexactly, and requires a confirmation depth. “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.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 or ambiguous covenant UTXO, a shallow funding, an unreachable node, and a
radiant_legthat does not implement the read all refuse.
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:680, 826; scripts/eth_swap_two_host.py:633, 838 — 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:39) versions the durable record, and is emitted only
when the counter leg is ETH (:530-535).
Compounding it: NegotiatedTerms.from_dict reads named keys and silently ignores anything else
(swap_state.py:397-415; 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 must still exceed
t_rxd (swap_state.py:323-325, 364-368) — 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:196-199). The pre-fund ordering gate correctly routes ETH swaps to
a different, cross-clock check (swap_coordinator.py:1279-1282, 1297-1328) 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:1286-1293), and the docstring calls
this “maker-promised params match the locally re-derived BTC funding SPK” (:1210). On both
shipped legs the two methods call the same function on the same input:
BTC —
htlc_leg.py:350-362, both returnself._htlc(terms).scriptpubkey; the docstring at:356-360says so plainly (“there is no separate maker-side derivation”).ETH —
eth_leg.py:90-94, 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:684-685, scripts/eth_swap_two_host.py:638-639), 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:540).
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:294-303) — 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 locking (eth_leg.py:150-191).
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:400). 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:939-944). 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:61-184.
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 (:208-223).
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:102-110).
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:1733-1739).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:1910-1915). 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:1179-1192, 1358-1400).
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.