Counter-chain and asset support¶
This page states exactly which counter chains and which assets the pyrxd cross-chain swap stack can trade a Radiant asset against, and — separately — how much proof stands behind each combination. It is a lookup table, not a recommendation: “supported” here means the code will build and drive that swap, never this has been shown safe.
Security posture. The cross-chain swap stack is UNAUDITED. No entry in any
table below changes that, including the mainnet-proven ones. The in-code audit gate
(require_audit_cleared in src/pyrxd/btc_wallet/htlc_leg.py) has been non-blocking
since 0.9.0 and is advisory: it will not stop you moving real value, and it is not a
substitute for an audit you commission yourself.
Source of truth. Every row is derived from the source and cites the file and symbol that makes it true. Where a row claims an on-chain run, the artifact is named so you can open it in an explorer rather than take this page’s word for it. Combinations with no run say not run — there are no inferred maturity levels here.
Narrative introduction: Gravity. Task instructions: How-to: build a cross-chain atomic swap. Proof artifacts with explorer links: showcase.
1. What the two sides are¶
The Radiant side is one of three asset variants — ASSET_VARIANTS in
src/pyrxd/gravity/swap_state.py:
Variant |
Asset |
Covenant builder |
|---|---|---|
|
plain RXD |
|
|
a Glyph fungible token |
|
|
a Glyph NFT (singleton) |
|
The counter side is one of two families — COUNTER_CHAINS in the same module:
Family tag |
Meaning |
Concrete chain pinned by |
|---|---|---|
|
PoW-depth chain (Bitcoin-family, BIP341 Taproot) |
the bech32 HRP |
|
finalized-checkpoint chain (EVM) |
the EIP-155 chain id |
counter_chain names the family, not the chain: a Litecoin swap is still "btc",
and a Base swap is still "eth". The concrete chain — and with it the safety
parameters — comes from the registries in §3 and §4. An ERC-20 swap is also "eth":
the token changes the unit, not the chain (NegotiatedTerms.token_address refuses any
other combination).
2. How to read the “highest proof reached” column¶
Level |
What was on the line |
What it does not mean |
|---|---|---|
mainnet ↔ mainnet |
Real value on both chains, both legs settled on a public chain |
Not an audit, and not an adversarial two-party proof — these were single-operator dust runs |
mainnet ↔ testnet |
Real value on the Radiant leg only; the counter leg was faucet value on a testnet |
Nothing at all about that counter chain’s mainnet |
fork + regtest |
The real counter-chain contract state, forked locally (anvil), against a Radiant regtest node |
No transaction ever reached a public chain |
regtest |
Both sides on isolated local nodes, real node consensus on both |
No real value, no public chain |
not run |
The chain/asset is wired, and fails closed if you get it wrong, but no run of any kind has exercised it |
Not “broken” — unproven |
The regtest lanes marked below run in the nightly nightly-cross-chain and
nightly-btc-family jobs (.github/workflows/integration.yml), not on every push. The
mainnet-fork lanes are not in CI at all — they need an external archive RPC, so they
are a local gate someone has to choose to run.
The four fork + regtest rows were run green on 2026-08-25:
tests/test_xchain_erc20_usdc_lifecycle_e2e.py, 8 passed against an Ethereum L1 fork
(USDC and USDT on chain 1) and 8 passed against a Base fork (USDC and USDT on chain
8453). Every other row records the proof that exists in the repository, not a run.
Reproducing the Base lane needs an archive endpoint: anvil fetches state behind the tip, and an endpoint that serves ordinary calls but refuses archive ones fails deep inside the swap looking like a code bug rather than at startup. The suite header names endpoints measured working.
3. Bitcoin-family counter chains (counter_chain = "btc")¶
Registry: KNOWN_POW_CHAINS in src/pyrxd/btc_wallet/chains.py. The Taproot-HTLC leg
is chain-agnostic across the family, so adding a chain does not touch the coordinator;
what is chain-specific is the block interval, which sizes every blocks↔seconds safety
margin.
Chain |
Network tags (mainnet / testnet / regtest) |
Block interval |
Counter asset |
Radiant asset |
Highest proof reached |
|---|---|---|---|---|---|
Bitcoin |
|
600 s |
BTC (native) |
|
mainnet ↔ mainnet |
Bitcoin |
“ |
“ |
BTC (native) |
|
not run |
Litecoin |
|
150 s |
LTC (native) |
|
regtest (leg level; see below) |
Litecoin |
“ |
“ |
LTC (native) |
|
not run |
Evidence — Bitcoin mainnet ↔ Radiant mainnet. One dust swap, both legs real value. Re-read from the two chains on 2026-08-25 rather than taken from the run’s own report:
BTC claim
0e2ba620…3e8ef6e3spends the P2TR HTLCbc1p78e72e5gazcklekwtyy9j50cqv36mp43phcq9s9nu2xlsqxl84fq9vlh8d; its witness carries the preimagef683f50e…e3d854c2.RXD claim
d9f8dee9…9a8f67dbspends the Radiant covenant outputc4882a6e…b248451b:0; itsscriptSigcarries the same preimage, and it pays 0.055 RXD to a plain P2PKH — therxdvariant, not a Glyph token.
Both transactions, with explorer links, are in the showcase. The same
corridor also runs as a whole-coordinator regtest e2e — Bitcoin Core and a Radiant node,
one coordinator — in the nightly nightly-cross-chain job
(tests/test_xchain_swap_regtest_e2e.py).
Evidence — Litecoin. Consensus level only, on two suites with different reach. The
leg-level HTLC suite runs against Litecoin Core v0.21.5.5 regtest in the nightly
nightly-btc-family job (tests/test_btc_htlc_regtest_e2e.py with
BTC_FAMILY_CHAIN=ltc, image built from docker/litecoin-regtest.Dockerfile): claim
accepted, wrong preimage rejected, premature refund non-BIP68-final, matured refund
accepted. The whole-coordinator e2e can also be pointed at Litecoin
(tests/test_xchain_swap_regtest_e2e.py with XCHAIN_BTC_FAMILY=ltc), but that variant
is local-only — it is not one of the CI lanes. Nothing has run on Litecoin mainnet or
testnet, and the bundled funding-reader/broadcaster backends
(src/pyrxd/network/bitcoin.py) are Bitcoin-mainnet-specific: a Litecoin deployment
supplies its own, as the regtest harness does.
Confirmation depths are deliberately not shipped. KNOWN_POW_CHAINS carries no
default depth. Depth buys reorg-resistance priced in that chain’s hashrate, and “6
confirmations” folklore transfers across chains even less well than it transfers across
values — a real-value run needs a measured MarginPolicy sized to the target chain’s
cost-to-reorg.
4. EVM counter chains, native ETH (counter_chain = "eth", no token)¶
Registry: KNOWN_EVM_CHAINS in src/pyrxd/eth_wallet/chains.py. The window below is
the steady-state lag of the finalized tag, which seeds the reorg gate’s
finalization reserve. Stalls are budgeted separately, in
CrossClockMargin.eth_finality_stall_tolerance_s — do not read the window as a worst
case. OP-stack chains permit a batch up to 12 h late, Arbitrum’s sequencer
force-inclusion delay is ~24 h, and Linea’s documented finality tail reaches 16 h.
Chain |
Chain id |
|
Finalized-tag window |
Highest proof reached |
|---|---|---|---|---|
Ethereum |
1 |
|
768 s |
not run |
Ethereum Sepolia |
11155111 |
|
768 s |
mainnet ↔ testnet ( |
Base |
8453 |
|
900 s |
not run |
Base Sepolia |
84532 |
|
900 s |
not run for native ETH (the Base Sepolia run in §5 was USDC) |
Optimism |
10 |
|
900 s |
not run |
Optimism Sepolia |
11155420 |
|
900 s |
not run |
Arbitrum One |
42161 |
|
1200 s |
not run |
Arbitrum Sepolia |
421614 |
|
1200 s |
not run |
Linea |
59144 |
|
6000 s |
not run |
Linea Sepolia |
59141 |
|
6000 s |
not run |
Evidence — Ethereum Sepolia ↔ Radiant mainnet. Three dust runs, each with the Radiant leg on mainnet and the ETH leg on the Sepolia testnet: plain RXD, a Glyph NFT, and a Glyph FT. Claim transactions for both sides of each are listed in the showcase. No swap has ever run on Ethereum mainnet.
What the “not run” rows do have. The coordinator e2e re-runs unchanged under another
chain id on a local anvil (XCHAIN_ETH_CHAIN_ID=84532,
tests/test_xchain_eth_swap_regtest_e2e.py) and the leg suite drives a full lifecycle
against a node presenting Base Sepolia’s chain id
(tests/test_eth_leg_anvil_integration.py). That proves the leg machinery is
chain-id-agnostic. It does not touch the named chain, and says nothing about that
chain’s finality behaviour, RPC providers, or fee market.
Refused by design. Two chains are excluded on the record rather than by omission,
and evm_chain_by_id fails closed for both:
Polygon PoS (137) — its
finalizedtag is Polygon’s own validator-set milestone finality, not Ethereum-anchored, so the Ethereum-derived window misrepresents it in both directions. A Polygon swap needs a finality model argued in Polygon’s own security terms.BNB Smart Chain (56) — same reason: BSC’s
finalizedtag is its own validator set’s (Parlia + BEP-126), measured 0–2 s behind the tip against three independent endpoints on 2026-08-25. An Ethereum-anchored chain cannot finalize inside the 768 s L1 checkpoint, and inflating the window does not recover the difference — a longer wait buys more of BSC’s security, never Ethereum’s. The blocker is the chain, not the token: BSC USDT is unpinned as a consequence, and stays unpinned until that chain question is answered (see the BSC notes inchains.pyandtokens.py).
Any chain id not in the registry is refused the same way: no vetted window, so no swap.
5. ERC-20 assets (counter_chain = "eth" with token_address)¶
Registry: KNOWN_TOKENS in src/pyrxd/eth_wallet/tokens.py, keyed by
(symbol, chain_id). Addresses are pinned, never resolved by symbol — bridged
look-alikes report an identical symbol and decimals (measured, not assumed), so the
address is the only discriminator, and it is the thing that is pinned. A
(symbol, chain id) pair that is not in the table below is refused, not guessed
(token_for).
“Issuer can freeze” is the pinned has_blacklist capability, not a runtime probe: a
failed probe is indistinguishable from “not frozen”, and that fail-open would sit on the
one gate guarding an unrecoverable loss.
Token |
Chain (id) |
Pinned address |
Decimals |
Issuer can freeze? |
Freeze predicate |
Asset pin read off the live chain |
Highest proof reached |
|---|---|---|---|---|---|---|---|
USDC |
Ethereum (1) |
|
6 |
yes |
|
2026-08-23 |
fork + regtest |
USDC |
Sepolia (11155111) |
|
6 |
yes |
|
2026-08-23 |
not run |
USDC |
Base (8453) |
|
6 |
yes |
|
2026-08-23 |
fork + regtest |
USDC |
Base Sepolia (84532) |
|
6 |
yes |
|
2026-08-23 |
mainnet ↔ testnet |
USDC |
Optimism (10) |
|
6 |
yes |
|
2026-08-23 |
not run |
USDC |
Optimism Sepolia (11155420) |
|
6 |
yes |
|
2026-08-23 |
not run |
USDC |
Arbitrum One (42161) |
|
6 |
yes |
|
2026-08-23 |
not run |
USDC |
Arbitrum Sepolia (421614) |
|
6 |
yes |
|
2026-08-23 |
not run |
USDC |
Linea (59144) |
|
6 |
yes |
|
2026-08-23 |
not run |
USDT |
Ethereum (1) |
|
6 |
yes |
|
2026-08-25 |
fork + regtest |
USDT |
Optimism (10) |
|
6 |
no (established positively) |
n/a |
2026-08-25 |
not run |
USDT |
Base (8453) |
|
6 |
no (established positively) |
n/a |
2026-08-25 |
fork + regtest |
All nine USDC entries are Circle’s published native addresses, each confirmed by reading
symbol() and decimals() off the chain itself rather than trusting the published
list. Decimals are pinned and re-asserted against the chain at swap start
(assert_token_matches_chain): a pinned constant cannot notice a proxy upgrade, and a
runtime-only read has nothing to disagree with.
There is no Linea Sepolia USDC row: that chain is in KNOWN_EVM_CHAINS, no token is
pinned for it, so a token swap there is refused.
The two freeze spellings are not interchangeable. Measured against the live
contracts on 2026-08-25: USDC answers isBlacklisted and reverts on isBlackListed;
Tether’s USDT is the exact reverse. A single hard-coded name made the gate revert on the
other token — which is_blacklisted correctly turns into a refusal, so the swap was not
unsafe, it was impossible. Carrying the predicate name per token is what makes a second
issuer representable at all.
“No” is a positive finding, not a silent probe failure. Optimism and Base USDT are
pinned has_blacklist=False on evidence: l1Token() returns Tether’s real L1 USDT, so
these are the canonical bridge representations; neither is an EIP-1967 proxy
(implementation and admin slots empty); and an opcode-aware disassembly finds no
DELEGATECALL, CALLCODE, SELFDESTRUCT or CREATE*, so the contract cannot be
upgraded or replaced. (A naive byte scan reports CALLCODE — that byte is PUSH
immediate data. Absence of a byte is conclusive; presence is not.)
Residual on those two, and it is not “no counterparty risk”. Burn is bridge-callable against an arbitrary holder, and the L2 standard bridge is itself an upgradeable system contract. That is bridge-governance risk rather than issuer discretion, it is a different hazard from a freeze, and the pre-reveal gate does not cover it — the gate only knows about freezes.
Evidence — RXD mainnet ↔ USDC on Base Sepolia. One dust run, 2026-08-25. The Radiant leg carried real mainnet value; the USDC leg was Base Sepolia testnet faucet USDC. This is not a both-sides-mainnet proof and must not be described as one. Artifacts, re-read from both chains on 2026-08-25:
Radiant mainnet: covenant funded at
421cfaeb…70577ef2:0(1000 photons); claim0bcedf65…00c6e542spends it, with the preimaged4260e5b…c4b0dd22in itsscriptSig.Base Sepolia: HTLC contract
0xEe54F9c4255d97A208bee0AB95369D04E4C318AE; claim0xe43c8414…2f16b3f6(status success) emitted the same preimage in its event data and moved 1 000 000 base units — 1.000000 USDC — out of the HTLC. The token in that transfer log is0x036cbd53…8f3dcf7e, exactly the address this registry pins for USDC on chain 84532.
The same preimage on both chains is what makes it one atomic swap rather than two payments. No ERC-20 swap has run on any EVM mainnet.
Evidence — fork + regtest. tests/test_xchain_erc20_usdc_lifecycle_e2e.py drives
the production SwapCoordinator from NEGOTIATED to COMPLETED, plus the refund and
crash-resume paths, against the real USDC and USDT contracts on an anvil fork of
Ethereum mainnet and a real Radiant covenant on a regtest node.
tests/test_erc20_leg_fork_integration.py covers the freeze gate, funding bounds, gas
and preimage recovery against the same real contracts. Both are opt-in
(XCHAIN_ERC20_E2E=1 plus an archive RPC) and neither is re-run by CI — read this
level as the proof exists and is reproducible on demand, and reproduce it before you
rely on it. Selecting a Base fork
(PYRXD_ETH_FORK_CHAIN_ID=8453) is supported by that suite and is the only way to
exercise the has_blacklist=False branch of the pre-reveal gate; this page does not
record it as run.
6. Refused and deliberately unpinned assets¶
A missing token is usually a decision, not an omission. These refusals are the feature: they turn a wrong-asset lock into a named error.
Asset |
Address |
Why it is not usable |
|---|---|---|
Arbitrum One USDC.e |
|
Bridged, not Circle-issued — a distinct contract with its own liquidity. Refused by address, with a message naming the native alternative ( |
Optimism USDC.e |
|
Same: a bridged look-alike, refused by address |
Arbitrum One “USDT” |
|
Reports symbol |
Linea USDT |
|
Deliberately unpinned: it is an EIP-1967 proxy with a populated admin slot, so the admin can swap in an implementation that freezes. “Cannot freeze today” is not the property worth pinning; “cannot be made to freeze” is |
BNB Smart Chain USDT |
|
Unpinned because of the chain, not the token (§4). Note for whoever pins it: it is 18 decimals, where every token above is 6 — the first entry where the symbol alone is ambiguous by a factor of 10¹², which is what the |
Polygon PoS, any asset |
— |
Chain refused; see §4 |
Anything else |
— |
|
Linea USDC is not on this list, on purpose. Linea’s bridged USDC.e was upgraded
in place to native USDC at the same address, so 0x17621186…821EE1ff is legitimate
and is pinned in §5.
7. Trust model: token corridors are weaker than native ETH¶
This is the most important section of the page, and it is not a maturity question — it holds even where a corridor is fully proven.
Native ETH and BTC are held by the chain: once locked, nobody can stop a claim or a refund, so the HTLC’s promise — if either party walks away, both can refund after their timelock — is unconditional. USDC, and any freezable token, is held by a contract whose issuer can freeze addresses. The same sentence then ends with “…unless the issuer intervenes”, which converts an unconditional refund right into a conditional one and puts a third party inside a two-party protocol.
Measured against the real USDC contract on a mainnet fork: freezing the claimant or
the refundee still leaves a recovery path, but freezing the HTLC contract itself
makes claim and refund revert — the funds are stranded permanently, with no
timeout to rescue them.
What the stack does about it, none of which is a fix:
One fresh contract per swap, so a contract freeze loses exactly one swap rather than every swap at once.
A pre-reveal freeze gate inside
claim— not beside it — checking the HTLC contract and both parties at the chain tip immediately before the preimage is published (assert_not_frozen_before_revealinsrc/pyrxd/eth_wallet/erc20.py, called fromErc20HtlcLeg.claim). It raises rather than guessing when the status cannot be read.A short funded window, because exposure is the time the tokens sit in the contract — which cuts against the usual instinct to be generous with timelocks.
Accepted and unmitigated: check-then-reveal is a race the gate narrows but cannot close;
the freeze read is single-source, so it defends a failing RPC provider, not a lying
one; and USDC’s proxy admin can change the token logic under you, where only a
decimals change would be caught.
Full analysis: threat model and the USDC corridor is issuer-trusted, not trustless. Do not describe a token corridor as trustless. Any user-facing description of it must carry the issuer-trust caveat alongside the “swaps are unaudited” line.
8. Adding a counter chain or a token¶
Both registries fail closed, so the work is stating the safety knob, not writing a leg:
A Bitcoin-family chain — add a
PowChaintoKNOWN_POW_CHAINSwith a sourcedblock_interval_s, and supply a funding reader/broadcaster for it. The Taproot-HTLC leg and the coordinator are unchanged.An EVM chain — add an
EvmChaintoKNOWN_EVM_CHAINSwith a sourcedfinalization_window_s(floor:ETH_FINALIZATION_WINDOW_FLOOR_S, 768 s — an L2 cannot finalize faster than the L1 checkpoint it settles to), and say where the worst-case stall is budgeted. If the chain’sfinalizedtag is not Ethereum-anchored, it does not belong in this registry at all.A token — add an
Erc20TokentoKNOWN_TOKENSwith the issuer’s published address for that chain id,decimalscross-checked by reading the live contract, andhas_blacklist/blacklist_fnestablished positively. A probe that answers neither freeze spelling is not evidence a token cannot freeze; leave it unpinned until someone reads its admin surface.
Adding a row to a registry does not raise a maturity level. Update §3–§5 only when a run actually happened, and name the artifact.