Radiant on-chain swap order (“RSWP”) wire format¶
Date: 2026-05-22. Status: confirmed from canonical source (Radiant-Core node parser + Photonic-Wallet producer + RXinDexer indexer), cross-checked against a Radiant-Core functional-test vector. Informs a future pyrxd swap-offer builder.
Why this exists¶
Third-party RXD wallets that post to the on-chain swap orderbook (e.g. Orbital, Photonic) must sign their offered UTXO with SIGHASH_SINGLE | ANYONECANPAY | FORKID. Because SINGLE binds the signature to the one paired output including its exact value, the offered side must be a single UTXO of an exact amount — which is why those wallets do a “self-send” to mint a clean exact-amount UTXO before posting an offer. pyrxd’s signing stack already supports 0xC3 (SIGHASH.SINGLE_ANYONECANPAY_FORKID, src/pyrxd/constants.py:38), so building/parsing these orders is a serializer job, not a crypto gap. This doc pins the byte format so a builder can be implemented without re-deriving it.
Detection mechanism¶
A swap order is advertised by an OP_RETURN output whose first push is the 4 ASCII bytes RSWP (52 53 57 50). It is not a special script template, and the advertising transaction is otherwise an ordinary tx (it may be funded from any UTXO). “Open vs filled” is derived separately by the node tracking whether the offered UTXO is still unspent.
v2 frame (current — Photonic emits only v2)¶
Pushes after OP_RETURN, in order (Radiant-Core swapindex.cpp:594-659):
# |
Field |
Size / encoding |
Notes |
|---|---|---|---|
1 |
|
4 bytes ASCII |
magic |
2 |
|
1 byte |
|
3 |
|
1 byte |
only |
4 |
|
1 byte |
Photonic |
5 |
|
1 byte |
Photonic always emits |
6 |
|
32 bytes |
see asset encoding below |
7 |
|
32 bytes |
present iff |
8 |
|
32 bytes |
reversed (little-endian internal) txid of offered UTXO |
9 |
|
minimal |
vout |
10..N-1 |
|
one or more pushes, concatenated |
see below |
N |
|
final push |
full scriptSig, see below |
Reassembly rule (swapindex.cpp:642-659): collect all remaining pushes into tail; require len(tail) >= 2; price_terms = concat(tail[:-1]), signature = tail[-1].
(v1/legacy version=0x01 has no flags/offeredType/termsType/wantTokenID and single-push price_terms+signature. Build against v2.)
price_terms byte layout (Photonic MultiTxOutV1)¶
The node treats price_terms as opaque bytes (HexStr(priceTerms)). The real structure is imposed by the producer (Photonic) — a serialized list of the outputs the maker wants to receive:
price_terms := CompactSize(outputCount)
|| [ value(8 bytes LE) || CompactSize(scriptLen) || script ] * outputCount
value: 8-byte little-endian uint64 (satoshis/photons).scriptLen: Bitcoin CompactSize/varint.script: raw scriptPubKey of the desired output (e.g. an FT/NFT/P2PKH script to the maker).Photonic currently always writes a single output.
Reader fallback (
swapBroadcast.ts:359-371): if MultiTxOutV1 parse fails, treat the blob as barevalue(8 LE) || script(rest).
signature format & sighash¶
The signature push is the entire unlocking scriptSig of input[0] of a partially-signed tx (Photonic Swap.tsx:655), not a bare ECDSA sig:
signature := PUSH( DER_ECDSA_sig || sighashByte ) || PUSH( compressed_pubkey )
sighashByte =
0xC3=SIGHASH_SINGLE(0x03) | SIGHASH_FORKID(0x40) | SIGHASH_ANYONECANPAY(0x80).ANYONECANPAY→ signs only the maker’s one offered input (taker adds their inputs).SINGLE→ signs only the output at the same index as that input — the maker’s single demanded output. The demanded output value is bound into the signature, so it is exact by construction (root cause of the self-send requirement).FORKID→ Radiant/BCH-style (BIP143-style preimage with prevout value).With
SINGLE, in the completion tx the maker’s input index must equal the index of the maker’s demanded output. Photonic builds the partial with offered-input and demand-output both at index 0.
Field meanings¶
version:0x01legacy,0x02current.flags: bit 0FLAG_HAS_WANT = 0x01(presence ofwantTokenID). No other bits defined.offeredType: PhotonicContractTypeenum int (verify mapping in Photonicpackages/app/src/types.tsbefore assuming RXD=0/FT=1/NFT=2). Node assigns no meaning.termsType: selectsprice_termsinterpretation; Photonic always0x01+ MultiTxOutV1.
Asset encoding (RXD-native vs Glyph token)¶
Frame is identical for both; asset identity lives in the 32-byte tokenID/wantTokenID and the embedded price_terms script (assetToSwapTokenId, swapBroadcast.ts:405-416):
RXD-native side:
tokenID= 32 zero bytes. When the want side is RXD, omitwantTokenIDand clearflagsbit 0.Glyph-token side:
tokenID=sha256( Outpoint.fromString(glyphRef).ref() ), pushed byte-reversed.RPC
tokenidis big-endian display hex; the on-chain push is little-endian internal bytes.
⚠️ Conflicts / pitfalls (read before implementing)¶
price_termsis PhotonicMultiTxOutV1, not integers. Follow Photonic’s MultiTxOutV1 (below) — it is what is actually on chain and what the node round-trips. History (resolved): as of this doc’s date (2026-05-22), RXinDexer decodedprice_termsas small price/amount integers keyed byterms_type, which produced garbage against real Photonic-produced orders. That was fixed upstream 2026-06-01 —Radiant-Core/RXinDexercommit24572c7c(“fix(swap): decode RSWP priceTerms (MultiTxOutV1)…”) replaced it with the canonicalMultiTxOutV1decode (parse_multi_txoutinelectrumx/server/swap_index.py), so current RXinDexer agrees with Photonic. Still re-check the specific indexer build you rely on. (Note: the localMudwoodLabs/RXinDexerfork may lag the upstream fix.)signatureis the full scriptSig, not a bare signature, despite RPC help labeling it “Partial signature.”tokenIDderivation differs between the node’s functional test (bare reversed txid) and Photonic (sha256(ref)reversed). Node accepts either (opaque 32 bytes); use Photonic’ssha256(ref)for orderbook interop.The real RXinDexer RPC is
swap.get_orders, notswap.get_open_orders/swap.get_orderbook. Correction (2026-07-05): this pitfall previously claimed RXinDexer’s ElectrumX methods were namedswap.get_open_orders/swap.get_orderbook. Source-verified against a freshRadiant-Core/RXinDexermaincheckout (electrumx/server/glyph_api.py+electrumx/server/swap_index.py, upstream state as of 2026-06-30 — seesrc/pyrxd/swap/rswp/rxindexer_source.pyon branchfeat/rswp-rxindexer-sourcefor the full citation trail): the actually-wired method isswap.get_orders(base_ref, quote_ref, limit, offset), registered inglyph_api.py’sGLYPH_METHODStable. With onlybase_ref: open orders offering that token. With both refs: the{bids, asks}orderbook for that pair. There is no want-token-only filter. TheSWAP_METHODStable inswap_index.pythat once advertisedswap.get_open_orders/swap.get_orderbookwas dead code — never wired to any ElectrumX session — and has since been removed upstream; those names were never actually queryable, so this doc’s claim they existed (under different names) was itself wrong, not just imprecise. Separately, RXinDexer’s indexed row (SwapOrderInfo) does not carry the raw frame fields a fullRswpOrderneeds — nosignature,price_termsblob,version,flags,offered_type, orterms_type(confirmed via the dataclass’s__slots__in two checkouts); only derived fields (price,amount,maker_address,status, …) persist. Photonic itself queries the node directly (getopenorders, …), not RXinDexer, so this gap only matters for an RXinDexer-only client — which must useswap.get_ordersfor discovery only (whichtx_hash/voutadvertised the order) and independently re-fetch +decode_rswp_orderthe advertising transaction to recover the signable fields.offeredUTXOIndexuses minimalCScriptNumencoding.
Verified test vector (Radiant-Core feature_swap.py:80-178)¶
An order with two price-term pushes 0102030405 + 060708090a and signature 04050607 round-trips through getopenorders as price_terms="0102030405060708090a", signature="04050607" — proving (a) price_terms = concatenation of middle pushes, (b) signature = final push, (c) on-chain pushes are little-endian/reversed while RPC reports big-endian display hex.
Minimal build recipe for pyrxd (v2)¶
tokenID(offered):sha256(genesis_ref_bytes)for a Glyph token else 32×0x00; push reversed.wantTokenID: same rule; omit + clearflagsbit 0 if want side is RXD.flags = 0x01iffwantTokenIDpresent else0x00.offeredType= ContractType int;termsType = 0x01.offeredUTXOHash= reversed offered txid;offeredUTXOIndex= minimal CScriptNum.price_terms=CompactSize(1) || value(8 LE) || CompactSize(len(script)) || scriptfor the single demanded output.Sign the offered UTXO with sighash
0xC3(FORKID/BIP143 preimage);signaturepush = full P2PKH scriptSigPUSH(der||0xC3) PUSH(pubkey).Emit
OP_RETURN "RSWP" 0x02 flags offeredType termsType tokenID [wantTokenID] offeredUTXOHash offeredUTXOIndex priceTerms signatureas a value-0 output in any funded tx.
pyrxd already has the preimage + signing for 0xC3 (src/pyrxd/transaction/transaction_preimage.py:190-235, src/pyrxd/script/type.py:78-85, src/pyrxd/constants.py:38). Remaining work is the OP_RETURN/MultiTxOutV1 serializer + a per-input sighash-aware offer builder.