Same-chain partial-transaction swaps¶
Audience: developers building same-chain RXD ↔ token (or token ↔
token) trades with pyrxd.swap — e.g. a marketplace or an on-chain
order flow.
Status: the API is implemented and unit-tested (including adversarial cases). Like every value-moving primitive in pyrxd, treat an external audit as the gate before real-value, untrusted-counterparty use.
What this is¶
A partial-transaction swap trades two assets atomically inside a
single transaction using signature-level atomicity. The maker signs
one input (the asset they give) committing to one output (the asset they
want back) with SIGHASH_SINGLE | ANYONECANPAY. The taker then adds
their own inputs and outputs to complete the trade and broadcasts.
Because both legs settle in one transaction, the swap is atomic: it either confirms wholly or not at all. There is no escrow, no covenant, and no second transaction.
maker input[0] ── gives asset X ──┐ ┌── output[0] maker receives asset Y (SINGLE-committed)
│ one │
taker input[1+] ── funds Y + fee ──┤ tx ├── output[1] taker receives asset X
│ ├── output[..] FT/RXD change to taker
└──────────┘
When to use it (vs Gravity)¶
|
||
|---|---|---|
Chains |
Same chain (RXD ↔ RXD/token) |
Cross-chain (RXD ↔ BTC/…) |
Atomicity |
One transaction, signature-level |
HTLC (hashlock + timelock) or SPV-oracle |
Counterparty |
Maker + taker complete one tx |
Two chains, two legs |
Use pyrxd.swap for trading assets that live on Radiant. Use Gravity
when the two assets live on different chains.
Why it is safe¶
The maker’s SINGLE|ANYONECANPAY signature is the enforcement — not the
declared terms. That signature commits to:
the maker’s given input: its outpoint, value, and locking script (so the given amount/asset can’t be misrepresented), and
output[0] only: the maker’s receive asset and amount.
ANYONECANPAY lets the taker add inputs; SINGLE lets the taker add
outputs after index 0. Neither lets the taker alter what the maker gives
or receives without invalidating the signature.
accept_offer therefore, by construction:
reads the maker’s real given asset from the source transaction (verified to hash to the input’s outpoint) — never from the declared terms;
reconciles the real given/received assets against the stated
SwapTermsand rejects on any mismatch;re-verifies the maker’s signature before and after completing the transaction;
derives the taker’s received amount from the real given asset (there is no caller knob to get it wrong);
enforces token conservation per FT ref and returns RXD change to the taker.
This closes the classic footgun where a hand-rolled taker builds its received-amount output from caller-supplied parameters and never checks the maker’s real prevout.
Glyph FT specifics¶
Radiant FTs carry their amount as the output’s photon value (1 photon = 1 FT unit) and their identity as a genesis ref embedded in the 75-byte FT script. The swap API:
treats an FT output’s photons as its token amount;
requires ref continuity — every FT ref in the outputs must be funded by an input of the same ref (the Radiant consensus rule), with surplus returned as FT change;
enforces amount conservation per ref in the SDK (there is no consensus opcode that does this for plain transfers).
Limitations (v1)¶
Whole-UTXO give. The maker spends their entire given UTXO;
SINGLEprotects only output[0], so a maker-side change output would be unprotected. Pre-split the UTXO to sell a partial amount.RXD and FT only. NFTs (singletons) are out of scope.
Explicit fee.
accept_offer(fee=…)takes an absolute photon fee; the taker funds it.
Minimal example¶
See examples/partial_swap_demo.py
for a runnable end-to-end demo (offer → transport → accept → verify),
covering an FT-for-RXD trade with conservation and change.
from pyrxd.swap import Asset, FundingInput, SwapOffer, accept_offer, create_offer
# Maker: give an FT UTXO, want 800 RXD photons.
offer = create_offer(
give_source_tx=maker_ft_source_tx,
give_vout=0,
maker_key=maker_key,
receive=Asset("rxd", 800),
maker_receive_pkh=maker_pkh,
)
payload = offer.to_dict() # JSON-able; send over any transport
# Taker: verify + complete + sign.
tx = accept_offer(
SwapOffer.from_dict(payload),
funding=[FundingInput(taker_rxd_source_tx, 0, taker_key)],
taker_receive_pkh=taker_pkh,
taker_change_pkh=taker_pkh,
fee=300,
)
raw = tx.serialize().hex() # broadcast