THORChain — execution dossier
Status (v6, 2026-11-11): executed by the wallet for the pairs of §11 — BTC and LTC ↔ ETH / USDC / USDT / DAI / WBTC on Ethereum, and BTC ↔ LTC, → DOGE. Every other pair stays price discovery only. Read §10 first: it states the vault-address problem, the defence, and exactly what remains trusted. Sections 1–8 are the dossier of the quote-only phase.
Status in the quote-only phase: price discovery only (executability: quote_only, atomic: false). Adapter:
packages/swap/src/providers/thorchain.ts. Everything below was read from the live network and from the official sources on
2026-11-10 (America/Toronto; 2026-11-11 UTC), THORNode 3.21.3. Where something could not be verified it says so.
1. Sources
| What | URL |
|---|---|
| Endpoints (official list) | https://dev.thorchain.org/concepts/connecting-to-thorchain.html |
| Swap quote guide | https://dev.thorchain.org/swap-guide/quickstart-guide.html |
| Memo grammar | https://dev.thorchain.org/concepts/memos.html |
| How to send the inbound transaction, per chain | https://dev.thorchain.org/concepts/sending-transactions.html |
| Asset notation | https://dev.thorchain.org/concepts/asset-notation.html |
| Quote implementation (limit, expiry, minimum, memo) | https://gitlab.com/thorchain/thornode/-/blob/develop/x/thorchain/querier_quotes.go |
| Limit enforcement at execution | https://gitlab.com/thorchain/thornode/-/blob/develop/x/thorchain/swap_current.go |
| Memo serialisation / parsing | https://gitlab.com/thorchain/thornode/-/blob/develop/x/thorchain/memo/memo_swap.go |
| EVM router source | https://gitlab.com/thorchain/thornode/-/blob/develop/chain/ethereum/contracts/THORChain_Router.sol |
Endpoint actually usable without a key: one. The docs list gateway.liquify.com/chain/thorchain_api (Liquify) and
thornode.thorchain.network. On the day of the check thornode.thorchain.network, midgard.thorchain.network and the former
Nine Realms hosts (thornode.ninerealms.com, midgard.ninerealms.com) did not resolve (no A record, checked against
1.1.1.1 and 8.8.8.8); api.thorchain.org answers 401 API key required; thornode.thorswap.net sits behind a browser
challenge. The adapter therefore pins https://gateway.liquify.com/chain/thorchain_api/thorchain. Consequence for the next
phase: a "several independent THORNodes must agree" check cannot be built from keyless public endpoints today (§6).
The Liquify gateway load-balances over nodes that are not always in the same state. Observed during this work: latencies from
0.15 s to 20 s, timeouts, and one node answering refusing quote on node with stale state for minutes while the others
advanced; block heights seen in the grpc-metadata-x-cosmos-block-height header differed by up to 15 blocks between two
consecutive calls. Rate limit: 50 000 requests / day / IP (docs, and x-ratelimit-limit-day: 50000 in the answers); the
docs ask for an x-client-id header (the adapter sends sp-wallet) and for exponential backoff on 429/503. No key, no sign-up.
No terms-of-use document for the gateway is referenced by the THORChain docs; none was reviewed.
2. What is served
Chain → wallet network: BTC bitcoin, ETH ethereum, LTC litecoin, DOGE dogecoin, BCH bitcoincash, AVAX avalanche,
BSC bsc, BASE base, GAIA cosmos, XRP xrpl, TRON tron, SOL solana. (ZEC and the native THOR.* assets have no
wallet network.)
GET /thorchain/pools returned 44 pools, 36 Available. A pool is served only if it is Available and its asset is in
the wallet registry, matched by chain + contract address (never by ticker). That leaves 21 assets:
- gas assets: BTC, ETH, LTC, DOGE, BCH, AVAX, BNB (BSC), ETH (Base), ATOM, XRP, TRX, SOL;
- Ethereum: USDC
0xA0b8…eB48, USDT0xdAC1…1ec7, DAI0x6B17…1d0F, WBTC0x2260…C599, AAVE0x7Fc6…DaE9; - Avalanche: USDC
0xB97E…8a6E, USDT0x9702…A8c7; Base: USDC0x8335…2913; TRON: USDTTR7N…Lj6t.
Not served although the wallet has the token: ETH.LINK (pool is Staged, the node answers pool(ETH.LINK-…) is not available), every Solana SPL token (only SOL.SOL has a pool), BSC stablecoins (the Available BSC pools are Binance-peg
USDC/USDT, which the wallet registry does not list), Arbitrum/Optimism/Polygon (no THORChain chain client).
The list is pinned in the adapter and re-checked by scripts/smoke-swap-thorchain.mts, which fails when a pinned pool is no
longer Available and warns when a new registry asset becomes available.
Halts. GET /thorchain/inbound_addresses gives, per chain, halted (chain halt: HALT<CHAIN>CHAIN, solvency halts),
chain_trading_paused (HALT<CHAIN>TRADING) and global_trading_paused (HALTTRADING); THORNode derives them from mimir.
The adapter reads that endpoint together with every quote (flags cached 15 s) and returns no route when the source or the
destination chain is halted or paused; if the flags cannot be read it fails closed. This is not theoretical: during the smoke
runs HALTBTCTRADING, HALTSIGNINGBTC and HALTXRPTRADING were set and cleared within minutes, and XRP stayed halted.
GET /thorchain/mimir is printed by the smoke script for the record.
3. The quote, and how each SwapQuote field is filled
GET /thorchain/quote/swap?from_asset=BTC.BTC&to_asset=ETH.ETH&amount=55000&destination=<recipient>&liquidity_tolerance_bps=<slippageBps>
No affiliate, no refund-address override, no streaming parameter (see below).
- Units. Every amount is an integer in 1e-8 units, whatever the asset. In:
floor(base / 10^(d−8))for d ≥ 8,base × 10^(8−d)for d < 8. Out: the reverse, floored. An 18-decimal input loses what is below 1e-8 of a coin (at most 9 999 999 999 wei): the protocol does not credit it. PureBigInt; property-tested. amountOut=expected_amount_out: already net of the liquidity fee and of the outbound fee.minAmountOut= the memo limit. Withliquidity_tolerance_bpsTHORNode writesLIM = floor(expected_amount_out × (10000 − bps) / 10000)into the memo (querier_quotes.go). At execution the swap is refunded whenswap output − outbound fee < LIM(swap_current.go: "deduct L1 outbound fee to ensure user receives their limit amount after fees"). SoLIMis what the recipient is guaranteed, in 1e8 units, if the swap executes. The adapter recomputes it and rejects the quote if the memo carries anything else. Tolerance must be an integer in 1..9999 bps (liquidity_tolerance_bps=0means "no limit", so zero tolerance yields no route). The oldertolerance_bpsparameter ignores fees and makes small swaps fail (emit asset … less than price limit); it is not used.fees(fees.assetis always the output asset;total = affiliate + outbound + liquidity, checked):liquidity→protocol(slip fee kept by the pools),outbound→network(gas of the vault's outbound transaction on the destination chain, dynamic). BothincludedInOutput: true,estimated: true.affiliatemust be0. The sender's own source-chain network fee is not in the quote (onlyrecommended_gas_rate/gas_rate_units).priceImpactBps=fees.slippage_bps.estimatedSeconds=total_swap_seconds(inbound confirmations + max(outbound delay, streaming time));nullwhen absent.inbound_confirmation_seconds,outbound_delay_secondsare inraw.expiresAt=expiry(block time + 15 min; observed 12–15 min ahead of the wall clock).- Streaming. THORChain runs the advanced swap queue: without any streaming parameter the network decides. When it
splits the swap the memo ends in
LIM/0/N("rapid streaming", N sub-swaps) and the quote is priced for that. The adapter reports it insteps[0].via[2]andraw.streaming(quoted,interval,quantity,maxQuantity,seconds). Observed: 0.00055 BTC → single swap; 20 BTC → ETH → 368–370 sub-swaps, ~12 min of streaming, 20 min outbound delay. A streaming swap can be partially filled: sub-swaps that missLIM/Nare skipped and the unswapped input is refunded (memos.html, Maya's streaming page says the same for the fork).minAmountOutis the floor for a full fill. - Minimums.
dust_threshold(source chain): an inbound at or below it is ignored.recommended_min_amount_in:max(outbound fee + dust of source, same for destination) × multiplier— below it a refund could not pay its own outbound fee. THORNode still answers a quote below that minimum (recorded: 3000 sats, minimum 6117); the adapter returns no route. steps: onenative_swap;contracts= the inbound vault (role: vault) and, for an EVM source, the router.
Refusals turned into "no route": amount less than dust threshold, amount less than min swap amount (recommended_min_amount_in: N),
pool(X) is not available, halts/pauses, not enough asset to pay for fees. refusing quote on node with stale state is
retried once (it normally lands on another node) and then reported as unavailable. Anything else throws.
4. Transaction shape per source chain
A swap is one transfer of the source asset to the current Asgard vault (inbound_address) carrying the memo
=:ASSET:DEST:LIM[/INTERVAL/QUANTITY]. There is no approval step except for ERC-20 tokens.
| Source | Shape (sending-transactions.html, quote notes) |
Memo carrier |
|---|---|---|
| BTC, LTC, DOGE, BCH | output 0 → vault, output 1 → change back to the address of input 0, output 2 → OP_RETURN. At most 10 outputs; no exotic scripts; LTC MWEB unsupported. Refunds go to the address of input 0, which must stay under the wallet's control. |
OP_RETURN, 80 bytes. Longer memos: first 79 bytes + ^, the rest hex-encoded in extra dust outputs — avoid. |
| ETH, AVAX, BNB, Base ETH (gas asset) | router.depositWithExpiry(vault, 0x0000000000000000000000000000000000000000, amount, memo, expiry) with value = amount. (The quote notes also allow a plain transfer to the vault with the memo hex-encoded in data.) |
string argument / calldata |
| ERC-20 | token.approve(router, amount) then router.depositWithExpiry(vault, token, amount, memo, expiry). expiry = unix seconds; the docs recommend now + 60 min; a transaction mined later reverts in the router. |
string argument |
| ATOM | MsgSend to the vault, fee in the base asset |
transaction memo |
| XRP | Payment to the vault. Only MemoData of a single memo is read. |
Memos[0].MemoData |
| TRX / TRC-20 USDT, SOL | transfer to the vault with a memo. Not described on the docs pages consulted; the quote notes field is empty for SOL. To be read from the chain clients' source before any implementation. |
TRON data / Solana memo instruction (unverified) |
Routers named by the network on 2026-11-10 (router in /thorchain/inbound_addresses, and in all six active vaults of
/thorchain/vaults/asgard): Ethereum 0xD37BbE5744D730a1d98d8DC97c42F0Ca46aD7146, Avalanche and Base
0x00dc6100103BC402d490aEE3F9a5560cBd91f1d4, BSC 0xb30ec53f98ff5947ede720d32ac2da7e52a5f56b. The docs give the router's
source but no address list; these addresses come from the network's own state through a single gateway. The adapter pins
them and rejects a quote naming any other router. Before the signer trusts them they must be checked against the verified
source on each block explorer (not done here).
Memo length. =:e:0x…40 hex…:1950047 is 54 bytes; a streaming suffix adds about 6. Fine for OP_RETURN. With an
ERC-20 target the full notation (ETH.USDC-0XA0B8…EB48, 51 bytes) does not fit in 80 bytes next to an EVM address, which is
why THORNode writes =:ETH.USDC:…. That abbreviation is resolved by the node by fuzzy match on chain + ticker. If the
signer builds the memo itself it should prefer the contract-suffix form (ETH.USDC-6EB48, accepted by the parser and by the
adapter's check) so the target is bound to the contract and not to a ticker.
What the wallet cannot build today (packages/core, not touched): the UTXO plugins have no OP_RETURN output and no
control over output order; Intent.memo is capped at 120 chars; TRON and BCH reject a memo; the XRPL plugin maps memo to a
destination tag only, not to Memos; there is no EVM contract-call intent for depositWithExpiry.
5. Refunds, and when funds are NOT refunded
A failed swap (limit not met, pool or trading halted, unparseable memo, unknown asset) is refunded by the network in an
outbound transaction to the sender address (UTXO: address of input 0; a DEST/REFUND memo could redirect it — the adapter
rejects such memos), less the outbound fee of the source chain. Hence refundable: true on every quote the adapter
returns, which are all at or above recommended_min_amount_in.
Not refunded (official docs and querier_quotes.go comments):
- amount at or below the dust threshold: the inbound is not observed at all (BTC 1000, LTC/AVAX 100 000, DOGE and XRP 100 000 000, in 1e8 units, on the day);
- amount that cannot pay the refund's outbound fee: "the refund will fail, and the user will lose the entire input amount";
- memo longer than 250 bytes: ignored;
- funds sent to a vault that is no longer active (cached address, delayed transaction);
- UTXO: wrong output order, more than 10 outputs, unsupported address or script types, change sent away from input 0;
- EVM: transaction mined after
expiryreverts (nothing leaves the wallet, gas is spent); "Do not send to or from contract addresses" (quote notes) — a refund to a contract sender may be unusable; - XRP sent from a shared/exchange address without a destination tag in the memo.
Not confirmed from the pages consulted: what happens to an ERC-20 transferred directly to the vault without the router, and to an inbound that arrives while its chain is halted. Treat both as loss/indefinite delay until verified.
6. What the isolated signer could verify, and what it could not
Could verify by itself, from data it already holds:
- Recipient: the memo's
DESTfield equals the wallet's own address on the destination chain (the signer derives it). - Target asset: the memo's
ASSETfield is a spelling of the registry asset (pinned notation table). - Minimum output: the memo's
LIM, converted from 1e8 units, is at least the minimum the user approved. - Nothing else rides along: handler is
=, exactly four fields, no/REFUND, no affiliate, no aggregator fields. - Amount sent, and for EVM: the router is a constant compiled into the signer, the selector is
depositWithExpiry, theassetargument is the registry contract,amountandvaluematch,expiryis within a short window, the allowance is exact.
Could not verify: that inbound_address is a genuine vault. It is the address of a threshold key held by the current
node set; it changes at every churn (CHURNINTERVAL = 43 200 blocks, about three days), several vaults are active at once
(six on the day; consecutive quotes named three different BTC vaults), and it is only published by the network's API. The
EVM router does not help: depositWithExpiry forwards to whatever vault it is given. A node, a gateway or anyone on the
path who substitutes that single field takes the whole input, and no memo check can detect it.
Mitigations used by integrators and recommended by the docs ("never cache", "confirm the latest vault", "confirm the network is not halted"), in increasing strength:
- take the vault from a fresh quote, send immediately, refuse after
expiry; - require the address to be in
GET /thorchain/vaults/asgardwithstatus: ActiveVault, and re-derive the address from the vaultpub_keylocally (binds address to key; the key itself is still API data); - churn checks:
vaults_migratinginGET /thorchain/network,HALTCHURNING, never aRetiringVault; - ask several independently operated THORNodes and require agreement (as set membership, since nodes may name different active vaults) — today only one keyless public host answers, so this needs a self-hosted node or keyed providers;
- verify the vault set with a Tendermint light client against THORChain's validator set (the only trust-minimised option; substantial work).
Until at least 2–4 exist in a form the signer can check without trusting the API service, quote_only is the honest class.
7. Tracking
GET /thorchain/tx/status/{inbound tx hash}— stagesinbound_observed,inbound_confirmation_counted,inbound_finalised,swap_status(with streaming progress),outbound_delay,outbound_signed(endpoint checked live).GET /thorchain/tx/{hash}for the observed transaction and its outbound hashes; MidgardGET https://gateway.liquify.com/chain/thorchain_midgard/v2/actions?txid={hash}for the final swap or refund action (Midgard reportedinSync: falseand took 11 s at one point during the checks).
8. Measured (2026-11-11 01:05–01:45 UTC)
Fixtures were recorded with packages/swap/test/fixtures/record.mjs. Through the adapter (scripts/smoke-swap-thorchain.mts,
quote + halt flags per call): 7 quotes, 2.1 s min, 3.4 s median, 5.2 s max, after up to four stale state refusals on
some pairs; one pair (SOL → USDC on Avalanche) could not be quoted in eight attempts in that run and had been quoted in an
earlier one (3.1 s). During that window the chain's block time ran about three minutes behind the wall clock, which is when
nodes refuse quotes. Earlier the same evening single calls took 0.15–0.7 s, with outliers at 12–20 s. Against CoinGecko the
40 USD quotes came out 0.8–2.4 % below the reference (the outbound fee alone is about 0.75 USD on BTC and 0.25 USD on
Ethereum); 20 BTC → ETH streamed came out 0.8–1.3 % below.
The router's default provider deadline is 2.5 s: with this gateway THORChain will often be reported as timeout. Either give
this provider a longer deadline or run a THORNode.
Execution (v6, 2026-11-11)
Sections 1–8 above describe the quote-only phase and are kept as written, except where §9 corrects them. Code:
packages/core/src/thorchain.ts (tables, memo, vault check, builders), services/isolated-signer/src/vaults.ts (the signer's
own operators), services/api/src/xswap.ts + xswap-state.ts (preparation, relay, tracking), apps/web/src/xswap.tsx.
Proof: npx tsx scripts/sim-thorchain.mts (nothing is broadcast).
9. Sources read for execution, and a correction to §1
| What | URL (read 2026-11-11) |
|---|---|
| Public endpoints | https://dev.thorchain.org/concepts/connecting-to-thorchain.html |
Memo grammar, limits (250 bytes; 80 in OP_RETURN), refunds |
https://dev.thorchain.org/concepts/memos.html |
| Inbound transaction rules per chain | https://dev.thorchain.org/concepts/sending-transactions.html |
| Inbound addresses, halts, "never cache", phishing warning | https://dev.thorchain.org/concepts/querying-thorchain.html |
| Vault statuses, churn, migration, how the inbound vault is chosen | https://dev.thorchain.org/bifrost/vault-behaviors.html |
| Operators listed for THORChain | https://raw.githubusercontent.com/cosmos/chain-registry/master/thorchain/chain.json |
| Router v4.1 verified source (Ethereum) | https://etherscan.io/address/0xD37BbE5744D730a1d98d8DC97c42F0Ca46aD7146#code |
Correction to §1 ("one usable endpoint"). That was true of the hosts tried on 2026-11-10. On 2026-11-11 five keyless
public THORNode APIs answered inbound_addresses, vaults/asgard, lastblock, pools, quote/swap and tx/status:
| id | base | where it is published |
|---|---|---|
liquify |
https://gateway.liquify.com/chain/thorchain_api |
THORChain developer docs; Cosmos chain registry |
rorcual |
https://api-thorchain.rorcual.xyz |
Cosmos chain registry |
ibs |
https://thorchain.ibs.team/api |
Cosmos chain registry |
thorwallet |
https://thornode.thorwallet.org |
THORWallet's public node (found by probing; not in the docs) |
keplr |
https://lcd-thorchain.keplr.app |
Keplr's public node (found by probing; not in the docs) |
Still unusable: thornode.thorchain.network, *.ninerealms.com (no DNS), thornode.thorswap.net (browser challenge),
api.thorchain.org (key). The official page itself says of public endpoints: "you can be phished and could send money to
the WRONG vault" and suggests comparing nodes or running a full node — which is what §10 does.
Observed behaviour: the five return identical vault sets; heights within ~25 blocks of each other; latency 0.1–6 s; a
quote is often refused by two or three of them at a time (refusing quote on node with stale state, or a timeout) while
the vault endpoints keep answering.
10. The vault-address problem and the defence
The problem. A swap is a payment to the current Asgard vault with a memo. The memo is fully checkable. The vault is
not: it is the address of a threshold key held by the node set, there are several at once (six on the day), the network
picks one per request, they all change at each churn, and only the network's nodes can say which keys are vaults. The
Ethereum router does not help: depositWithExpiry(vault, …) forwards to whatever vault it is given. Whoever substitutes
that one field takes the whole input.
What the signer proves alone, offline (verifyXswap, buildXswapBtcTx, buildXswapEvmTxs):
- the memo is
=:ASSET:DEST:LIMIT, rebuilt by the signer and required to equal the committed one:ASSETis the pinned spelling of a registry asset,DESTis the wallet's own address on the destination network (derived from its keys),LIMITis a plain decimal integer in 1e8 units worth at least the approved minimum (fromThorAmount(limit) ≥ minOut); scientific notation, streaming fields, a/REFUNDoverride and affiliate fields cannot be produced and are refused by the parser. A refund therefore returns to the sender, which is the wallet (UTXO: address of input 0; EVM:msg.sender); - the transaction: UTXO = all inputs the wallet's own, output 0 the vault, output 1 change to the wallet (always present),
output 2
OP_RETURNwith one push of the memo (≤ 80 bytes), nothing else, fee ≤ the approved maximum; Ethereum = one call to the pinned routerdepositWithExpiry(vault, asset | 0x0, amount, memo, expiry)withvalue = amountfor ETH, preceded for a token byapprove(router, amount)for the exact amount; - the vault address is the address of the committed vault public key (bech32
thorpub…→ secp256k1 key → P2WPKH / P2PKH / keccak address, computed locally). An API that keeps a genuine key and swaps the address is refused offline.
What cannot be proven offline, and the check that replaces a proof (checkThorVault): that the key is a current
Asgard vault. Before signing, the signer itself asks the five operators above — its own list, compiled into
dist/signer.mjs, over TLS, nothing passing through the API — for inbound_addresses, vaults/asgard and lastblock,
and signs only if:
- at least 3 distinct operators answered (two may be down), and
- every operator that answered: lists the key as
ActiveVaultserving the source chain, with the same churn height (status_since); reports the source chain neitherhaltednor trading-paused; names the pinned router (EVM); reports a dust threshold below the amount; and is within 50 THORChain blocks (5 min) of the others.
One dissenting answer refuses the signature (an honest operator that disagrees is the very signal). Nothing is cached. A refusal happens before the passkey is looked at and records nothing as spent. The API runs the same function for its own view when it prepares the swap and again just before it relays the signed transaction.
Why 3 of 5 with zero dissent: unanimity means an attacker gains nothing from one, two, three or four lying operators as long as one honest operator answers; the floor of 3 means silencing operators (which an attacker on the network path, or a simple outage, can do) is not enough either — at least three must actively vouch for the forged key. Requiring all five would make the swap unavailable whenever one public node is down, which was the case in most runs.
Exactly what remains trusted
- That the operators which answered (≥ 3 of the 5 pinned) are not all compromised or colluding at the same time, and
are in fact independent. Independence is assumed from their publishers, not proven: two of them could sit behind the
same infrastructure.
thorwalletandkeplrare public nodes of wallets, not endpoints documented by THORChain. - TLS and the WebPKI for those five host names, from the signer's machine.
- THORChain itself: that the vault's key holders (two-thirds honest, bonded) process the memo, pay the outbound and refund as documented. This is inherent to using THORChain and no client check can remove it.
- The pinned tables in
thorchain.ts(asset spellings, router address and bytecode hash, operator list).
No longer trusted: the wallet API (it can only make a swap fail), any single THORNode operator, the quote endpoint.
Second lines (corroboration, not proof) — each is fakeable by someone who parks capital, and is stated as such:
- API, at preparation: the wallet's own chain data sources must show the vault address holding ≥ 100 × the amount in the
source asset (BTC/LTC balance from the wallet's indexers; ETH balance on both RPCs; for a token,
router.vaultAllowance(vault, token)on both RPCs). Real vaults held ~70 BTC / 110–230 ETH / 150–500 k USDC each. - API: recent
TransferOutevents of the router with that vault as sender are counted (informational only). - API: the router bytecode must hash to the pinned value on both RPCs; the wallet address must carry no code.
- API: every operator's quote must contain, byte for byte, the memo this wallet builds for that quote's numbers; the
pool behind the memo spelling must be the pinned one,
Available, and the only pool with that chain and ticker.
Exposure. One swap is bounded by the signer's per-transfer limit of the source asset (about 50 USD) and its daily limit (about 100 USD); the limits are those of a send and were not changed. This bounds the loss; it is not the defence.
Midgard is not part of the check: the only public Midgard that answered (Liquify) does not proxy
thorchain/inbound_addresses and reported inSync: false.
11. What is enabled, what is not, and why
| Direction | Enabled |
|---|---|
| BTC (native SegWit) → ETH, USDC, USDT, DAI, WBTC on Ethereum; → LTC; → DOGE | yes |
| LTC (native SegWit) → the same Ethereum assets; → BTC; → DOGE | yes |
| ETH, USDC, USDT, DAI, WBTC on Ethereum → BTC, LTC, DOGE | yes |
| DOGE as source | no — THORChain's recommended inbound rate (750 000 koinu/byte) is far above the wallet's Dogecoin fee ceiling, which is not raised |
| BCH (either side) | no — Bitcoin Cash is a separate plugin builder with no memo output; CashAddr spelling in memos not verified |
| Base, Avalanche, BSC (either side) | no — they run other router builds (7 521 and 4 782 bytes of runtime code against 5 141 on Ethereum) whose sources were not read; outbound tracking relies on the router's TransferOut event |
| Cosmos, XRP, TRON, Solana | no — inbound transaction shapes not verified (§4) and no outbound verification written |
| Same-network swaps through THORChain | no (KyberSwap does those) |
| Streaming swaps | off — no streaming field is ever written; an amount THORChain would split (max_streaming_quantity > 1) is refused |
| Maya Protocol | no — see maya.md §8 (two operators only) |
Rules applied to every swap:
- Minimums. Refused at or below the source chain's dust threshold (signer and API) and below
recommended_min_amount_in(API, highest value of the quoting operators): below it a refund cannot pay its own fee and the input is lost. The outbound fee is shown and is already deducted from the expected output and from the minimum. - Price. Quotes from at least two operators, within 1 % of each other; the lowest is kept.
- Fee rate. UTXO: the higher of the wallet's own estimate and THORChain's
gas_rate, never above the wallet's own rate cap nor the signer's fee ceiling (otherwise refused). Ethereum:max(2 × base fee + tip, gas_rate gwei). - Validity. Passkey window 120 s.
expiry= creation + 15 min: on Ethereum the router reverts after it (gas only is lost); a UTXO transaction is never relayed, nor relayed again, in the last 3 minutes before it. Just before relaying, the API re-runs the vault check and refuses if the vault is no longer active or its churn height changed. - No replacement. The signer signs one transaction per proposal, ever; there is no fee bump.
- One at a time per source network, until the outcome (received or refunded) is known.
- Tracking.
tx/statusfrom the operators (two identical answers needed) says where to look. A swap isreceivedonly when the wallet's own chain data sources show the named outbound transaction confirmed, paying the wallet the expected asset, with THORChain's memoOUT:<inbound txid>(UTXO: transaction bytes hashed locally; Ethereum: the pinned router'sTransferOutevent on every RPC).REFUND:<inbound txid>givesrefunded, neverreceived. Overdue steps raise a hold (not_observed,outbound_late,outbound_not_seen,outbound_mismatch,vault_churned) and tracking continues; it never gives up and never guesses. All state is in the proposal row: it resumes after a restart. - Audit.
xswap.prepared,transfer.*,xswap.observed,xswap.outbound,xswap.received/xswap.refunded,xswap.hold(API chain);xswap.vault_confirmed(operators, height, churn height) andsign.ok/sign.denied(signer journal).
Signer outbound hosts added (an egress allowlist on the signer machine must include them): gateway.liquify.com,
api-thorchain.rorcual.xyz, thorchain.ibs.team, thornode.thorwallet.org, lcd-thorchain.keplr.app. Three GETs per
operator per signature, 7 s timeout, 400 kB cap, redirects refused.
12. Verified / not verified
Verified (2026-11-11, scripts/sim-thorchain.mts and tests):
- vault addresses derived locally equal the addresses the five operators publish (BTC, LTC, DOGE, ETH);
- the unanimity rule on live answers, and on the same answers with one lying, stale, halting or retiring operator;
- for 7 pairs, each operator's quote memo equals the wallet's memo byte for byte;
- a Bitcoin transaction signed by the real signer (fresh key, made-up coin), decoded with bitcoinjs-lib: three outputs in
order,
OP_RETURNbytes, fee at THORChain's rate — not broadcast; - on an anvil fork of Ethereum, the signer's calls mined against the real router:
Deposit(vault, 0x0 | USDC, amount, memo), vault ETH balance /vaultAllowanceup by exactly the amount, exact approval consumed,THORChain_Router: expiredafter expiry; - router v4.1 source read in full; bytecode hash identical on both registry RPCs.
Not verified
- Any transaction on a real network: no swap, outbound or refund has been executed by this wallet. THORChain cannot be forked.
- That THORChain observes a UTXO inbound whose
OP_RETURNis output 2 with change as output 1 exactly as built here (it is the documented shape; not exercised). - What happens to an inbound confirmed after its vault retired: the docs only say funds may go "to an unreachable address"; Bifrost watching retiring vaults is believed, not read in the source.
- The outbound verification code against a real THORChain outbound (written from the router source and the documented
OUT:/REFUND:memos; tested on scripted data only). Whether a UTXO outbound always carries the memo in a single-pushOP_RETURNwas not checked on-chain; if it does not, the swap stays on holdoutbound_mismatch(never a false success). - USDT, DAI and WBTC as sources on a fork (ETH and USDC were); Litecoin source signing end to end (same builder as Bitcoin, unit-tested only).
- That
refundon Ethereum returns tomsg.sender(stated by the docs as "the originator's address"; not read in Bifrost). - Independence of the five operators; the terms of use of the THORWallet and Keplr public nodes.
tx/statusfield shapes for a streaming or multi-outbound swap (not produced by this wallet).
13. Residual risks, plainly
- All answering operators compromised at once (or one actor behind ≥ 3 of them): loss of one swap amount, per-transfer limit.
- A churn between signature and confirmation of a UTXO inbound: bounded by the 15-minute window, THORChain's fee rate and the no-late-relay rule, not eliminated (a transaction can stay unconfirmed longer than expected and cannot be recalled).
- THORChain insolvency, halt after the deposit, or a bug: funds can be delayed or lost; nothing here can prevent it.
- A token memo
ETH.USDCdesignates the deepest pool with that ticker: if a deeper impostor pool were ever listed the output would be that token, to the wallet's own address. The API refuses as soon as a second pool with the ticker exists. - Public nodes are flaky: a swap will often fail to prepare or sign ("only 2 operators answered", "fewer than two quotes") and must simply be retried. Failing closed is intended.