SPB Git

Repository Public

thorchain.md 32.8 KB 397 lines markdown

1# THORChain — execution dossier23**Status (v6, 2026-11-11): executed by the wallet for the pairs of §11** — BTC and LTC ↔ ETH / USDC / USDT / DAI / WBTC on4Ethereum, and BTC ↔ LTC, → DOGE. Every other pair stays price discovery only. Read §10 first: it states the vault-address5problem, the defence, and exactly what remains trusted. Sections 1–8 are the dossier of the quote-only phase.67Status in the quote-only phase: **price discovery only** (`executability: quote_only`, `atomic: false`). Adapter:8`packages/swap/src/providers/thorchain.ts`. Everything below was read from the live network and from the official sources on9**2026-11-10 (America/Toronto; 2026-11-11 UTC)**, THORNode `3.21.3`. Where something could not be verified it says so.1011## 1. Sources1213| What | URL |14|---|---|15| Endpoints (official list) | https://dev.thorchain.org/concepts/connecting-to-thorchain.html |16| Swap quote guide | https://dev.thorchain.org/swap-guide/quickstart-guide.html |17| Memo grammar | https://dev.thorchain.org/concepts/memos.html |18| How to send the inbound transaction, per chain | https://dev.thorchain.org/concepts/sending-transactions.html |19| Asset notation | https://dev.thorchain.org/concepts/asset-notation.html |20| Quote implementation (limit, expiry, minimum, memo) | https://gitlab.com/thorchain/thornode/-/blob/develop/x/thorchain/querier_quotes.go |21| Limit enforcement at execution | https://gitlab.com/thorchain/thornode/-/blob/develop/x/thorchain/swap_current.go |22| Memo serialisation / parsing | https://gitlab.com/thorchain/thornode/-/blob/develop/x/thorchain/memo/memo_swap.go |23| EVM router source | https://gitlab.com/thorchain/thornode/-/blob/develop/chain/ethereum/contracts/THORChain_Router.sol |2425**Endpoint actually usable without a key: one.** The docs list `gateway.liquify.com/chain/thorchain_api` (Liquify) and26`thornode.thorchain.network`. On the day of the check `thornode.thorchain.network`, `midgard.thorchain.network` and the former27Nine Realms hosts (`thornode.ninerealms.com`, `midgard.ninerealms.com`) **did not resolve** (no A record, checked against281.1.1.1 and 8.8.8.8); `api.thorchain.org` answers `401 API key required`; `thornode.thorswap.net` sits behind a browser29challenge. The adapter therefore pins `https://gateway.liquify.com/chain/thorchain_api/thorchain`. Consequence for the next30phase: a "several independent THORNodes must agree" check **cannot be built from keyless public endpoints today** (§6).3132The Liquify gateway load-balances over nodes that are not always in the same state. Observed during this work: latencies from330.15 s to 20 s, timeouts, and one node answering `refusing quote on node with stale state` for minutes while the others34advanced; block heights seen in the `grpc-metadata-x-cosmos-block-height` header differed by up to 15 blocks between two35consecutive calls. Rate limit: **50 000 requests / day / IP** (docs, and `x-ratelimit-limit-day: 50000` in the answers); the36docs ask for an `x-client-id` header (the adapter sends `sp-wallet`) and for exponential backoff on 429/503. No key, no sign-up.37No terms-of-use document for the gateway is referenced by the THORChain docs; none was reviewed.3839## 2. What is served4041Chain → wallet network: `BTC` bitcoin, `ETH` ethereum, `LTC` litecoin, `DOGE` dogecoin, `BCH` bitcoincash, `AVAX` avalanche,42`BSC` bsc, `BASE` base, `GAIA` cosmos, `XRP` xrpl, `TRON` tron, `SOL` solana. (`ZEC` and the native `THOR.*` assets have no43wallet network.)4445`GET /thorchain/pools` returned 44 pools, 36 `Available`. A pool is served only if it is `Available` **and** its asset is in46the wallet registry, matched by chain + contract address (never by ticker). That leaves 21 assets:4748- gas assets: BTC, ETH, LTC, DOGE, BCH, AVAX, BNB (BSC), ETH (Base), ATOM, XRP, TRX, SOL;49- Ethereum: USDC `0xA0b8…eB48`, USDT `0xdAC1…1ec7`, DAI `0x6B17…1d0F`, WBTC `0x2260…C599`, AAVE `0x7Fc6…DaE9`;50- Avalanche: USDC `0xB97E…8a6E`, USDT `0x9702…A8c7`; Base: USDC `0x8335…2913`; TRON: USDT `TR7N…Lj6t`.5152Not served although the wallet has the token: `ETH.LINK` (pool is `Staged`, the node answers `pool(ETH.LINK-…) is not53available`), every Solana SPL token (only `SOL.SOL` has a pool), BSC stablecoins (the Available BSC pools are Binance-peg54USDC/USDT, which the wallet registry does not list), Arbitrum/Optimism/Polygon (no THORChain chain client).5556The list is pinned in the adapter and re-checked by `scripts/smoke-swap-thorchain.mts`, which fails when a pinned pool is no57longer `Available` and warns when a new registry asset becomes available.5859**Halts.** `GET /thorchain/inbound_addresses` gives, per chain, `halted` (chain halt: `HALT<CHAIN>CHAIN`, solvency halts),60`chain_trading_paused` (`HALT<CHAIN>TRADING`) and `global_trading_paused` (`HALTTRADING`); THORNode derives them from mimir.61The adapter reads that endpoint together with every quote (flags cached 15 s) and returns no route when the source or the62destination chain is halted or paused; if the flags cannot be read it fails closed. This is not theoretical: during the smoke63runs `HALTBTCTRADING`, `HALTSIGNINGBTC` and `HALTXRPTRADING` were set and cleared within minutes, and XRP stayed halted.64`GET /thorchain/mimir` is printed by the smoke script for the record.6566## 3. The quote, and how each `SwapQuote` field is filled6768`GET /thorchain/quote/swap?from_asset=BTC.BTC&to_asset=ETH.ETH&amount=55000&destination=<recipient>&liquidity_tolerance_bps=<slippageBps>`6970No affiliate, no refund-address override, no streaming parameter (see below).7172- **Units.** Every amount is an integer in 1e-8 units, whatever the asset. In: `floor(base / 10^(d−8))` for d ≥ 8,73  `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 most74  9 999 999 999 wei): the protocol does not credit it. Pure `BigInt`; property-tested.75- **`amountOut`** = `expected_amount_out`: already net of the liquidity fee and of the outbound fee.76- **`minAmountOut`** = the memo limit. With `liquidity_tolerance_bps` THORNode writes77  `LIM = floor(expected_amount_out × (10000 − bps) / 10000)` into the memo (`querier_quotes.go`). At execution the swap is78  refunded when `swap output − outbound fee < LIM` (`swap_current.go`: "deduct L1 outbound fee to ensure user receives79  their limit amount after fees"). So `LIM` is what the recipient is guaranteed, in 1e8 units, if the swap executes. The80  adapter recomputes it and rejects the quote if the memo carries anything else. Tolerance must be an integer in 1..9999 bps81  (`liquidity_tolerance_bps=0` means "no limit", so zero tolerance yields no route). The older `tolerance_bps` parameter82  ignores fees and makes small swaps fail (`emit asset … less than price limit`); it is not used.83- **`fees`** (`fees.asset` is always the output asset; `total = affiliate + outbound + liquidity`, checked):84  `liquidity` → `protocol` (slip fee kept by the pools), `outbound` → `network` (gas of the vault's outbound transaction on85  the destination chain, dynamic). Both `includedInOutput: true`, `estimated: true`. `affiliate` must be `0`. The sender's86  own source-chain network fee is **not** in the quote (only `recommended_gas_rate` / `gas_rate_units`).87- **`priceImpactBps`** = `fees.slippage_bps`. **`estimatedSeconds`** = `total_swap_seconds` (inbound confirmations +88  max(outbound delay, streaming time)); `null` when absent. `inbound_confirmation_seconds`, `outbound_delay_seconds` are in `raw`.89- **`expiresAt`** = `expiry` (block time + 15 min; observed 12–15 min ahead of the wall clock).90- **Streaming.** THORChain runs the advanced swap queue: without any streaming parameter the network decides. When it91  splits the swap the memo ends in `LIM/0/N` ("rapid streaming", N sub-swaps) and the quote is priced for that. The adapter92  reports it in `steps[0].via[2]` and `raw.streaming` (`quoted`, `interval`, `quantity`, `maxQuantity`, `seconds`). Observed:93  0.00055 BTC → single swap; 20 BTC → ETH → 368–370 sub-swaps, ~12 min of streaming, 20 min outbound delay.94  A streaming swap can be **partially filled**: sub-swaps that miss `LIM/N` are skipped and the unswapped input is refunded95  (memos.html, Maya's streaming page says the same for the fork). `minAmountOut` is the floor for a full fill.96- **Minimums.** `dust_threshold` (source chain): an inbound at or below it is ignored. `recommended_min_amount_in`:97  `max(outbound fee + dust of source, same for destination) × multiplier` — below it a refund could not pay its own outbound98  fee. THORNode still answers a quote below that minimum (recorded: 3000 sats, minimum 6117); the adapter returns no route.99- **`steps`**: one `native_swap`; `contracts` = the inbound vault (`role: vault`) and, for an EVM source, the router.100101Refusals turned into "no route": `amount less than dust threshold`, `amount less than min swap amount (recommended_min_amount_in: N)`,102`pool(X) is not available`, halts/pauses, `not enough asset to pay for fees`. `refusing quote on node with stale state` is103retried once (it normally lands on another node) and then reported as `unavailable`. Anything else throws.104105## 4. Transaction shape per source chain106107A swap is one transfer of the source asset to the **current Asgard vault** (`inbound_address`) carrying the memo108`=:ASSET:DEST:LIM[/INTERVAL/QUANTITY]`. There is no approval step except for ERC-20 tokens.109110| Source | Shape (sending-transactions.html, quote `notes`) | Memo carrier |111|---|---|---|112| 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. |113| 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 |114| 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 |115| ATOM | `MsgSend` to the vault, fee in the base asset | transaction `memo` |116| XRP | `Payment` to the vault. Only `MemoData` of a single memo is read. | `Memos[0].MemoData` |117| 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) |118119Routers named by the network on 2026-11-10 (`router` in `/thorchain/inbound_addresses`, and in all six active vaults of120`/thorchain/vaults/asgard`): Ethereum `0xD37BbE5744D730a1d98d8DC97c42F0Ca46aD7146`, Avalanche and Base121`0x00dc6100103BC402d490aEE3F9a5560cBd91f1d4`, BSC `0xb30ec53f98ff5947ede720d32ac2da7e52a5f56b`. The docs give the router's122source but **no address list**; these addresses come from the network's own state through a single gateway. The adapter pins123them and rejects a quote naming any other router. Before the signer trusts them they must be checked against the verified124source on each block explorer (not done here).125126**Memo length.** `=:e:0x…40 hex…:1950047` is 54 bytes; a streaming suffix adds about 6. Fine for `OP_RETURN`. With an127ERC-20 target the full notation (`ETH.USDC-0XA0B8…EB48`, 51 bytes) does not fit in 80 bytes next to an EVM address, which is128why THORNode writes `=:ETH.USDC:…`. That abbreviation is resolved by the node by fuzzy match on chain + ticker. If the129signer builds the memo itself it should prefer the contract-suffix form (`ETH.USDC-6EB48`, accepted by the parser and by the130adapter's check) so the target is bound to the contract and not to a ticker.131132**What the wallet cannot build today** (`packages/core`, not touched): the UTXO plugins have no `OP_RETURN` output and no133control over output order; `Intent.memo` is capped at 120 chars; TRON and BCH reject a memo; the XRPL plugin maps `memo` to a134destination tag only, not to `Memos`; there is no EVM contract-call intent for `depositWithExpiry`.135136## 5. Refunds, and when funds are NOT refunded137138A failed swap (limit not met, pool or trading halted, unparseable memo, unknown asset) is refunded by the network in an139outbound transaction **to the sender address** (UTXO: address of input 0; a `DEST/REFUND` memo could redirect it — the adapter140rejects such memos), **less the outbound fee of the source chain**. Hence `refundable: true` on every quote the adapter141returns, which are all at or above `recommended_min_amount_in`.142143Not refunded (official docs and `querier_quotes.go` comments):144145- amount **at or below the dust threshold**: the inbound is not observed at all (BTC 1000, LTC/AVAX 100 000, DOGE and XRP146  100 000 000, in 1e8 units, on the day);147- amount that cannot pay the refund's outbound fee: "the refund will fail, and the user will lose the entire input amount";148- **memo longer than 250 bytes**: ignored;149- funds sent to a **vault that is no longer active** (cached address, delayed transaction);150- UTXO: wrong output order, more than 10 outputs, unsupported address or script types, change sent away from input 0;151- EVM: transaction mined after `expiry` reverts (nothing leaves the wallet, gas is spent); "Do not send to or from contract152  addresses" (quote notes) — a refund to a contract sender may be unusable;153- XRP sent from a shared/exchange address without a destination tag in the memo.154155Not confirmed from the pages consulted: what happens to an ERC-20 transferred directly to the vault without the router, and156to an inbound that arrives while its chain is halted. Treat both as loss/indefinite delay until verified.157158## 6. What the isolated signer could verify, and what it could not159160Could verify by itself, from data it already holds:161162- **Recipient**: the memo's `DEST` field equals the wallet's own address on the destination chain (the signer derives it).163- **Target asset**: the memo's `ASSET` field is a spelling of the registry asset (pinned notation table).164- **Minimum output**: the memo's `LIM`, converted from 1e8 units, is at least the minimum the user approved.165- **Nothing else rides along**: handler is `=`, exactly four fields, no `/REFUND`, no affiliate, no aggregator fields.166- **Amount** sent, and for EVM: the router is a constant compiled into the signer, the selector is `depositWithExpiry`, the167  `asset` argument is the registry contract, `amount` and `value` match, `expiry` is within a short window, the allowance is exact.168169Could **not** verify: that `inbound_address` is a genuine vault. It is the address of a threshold key held by the current170node set; it changes at every churn (`CHURNINTERVAL` = 43 200 blocks, about three days), several vaults are active at once171(six on the day; consecutive quotes named three different BTC vaults), and it is only published by the network's API. The172EVM router does not help: `depositWithExpiry` forwards to whatever `vault` it is given. A node, a gateway or anyone on the173path who substitutes that single field takes the whole input, and no memo check can detect it.174175Mitigations used by integrators and recommended by the docs ("never cache", "confirm the latest vault", "confirm the network176is not halted"), in increasing strength:1771781. take the vault from a fresh quote, send immediately, refuse after `expiry`;1792. require the address to be in `GET /thorchain/vaults/asgard` with `status: ActiveVault`, and re-derive the address from180   the vault `pub_key` locally (binds address to key; the key itself is still API data);1813. churn checks: `vaults_migrating` in `GET /thorchain/network`, `HALTCHURNING`, never a `RetiringVault`;1824. ask several **independently operated** THORNodes and require agreement (as set membership, since nodes may name different183   active vaults) — today only one keyless public host answers, so this needs a self-hosted node or keyed providers;1845. verify the vault set with a Tendermint light client against THORChain's validator set (the only trust-minimised option;185   substantial work).186187Until at least 2–4 exist in a form the signer can check without trusting the API service, `quote_only` is the honest class.188189## 7. Tracking190191- `GET /thorchain/tx/status/{inbound tx hash}` — stages `inbound_observed`, `inbound_confirmation_counted`,192  `inbound_finalised`, `swap_status` (with streaming progress), `outbound_delay`, `outbound_signed` (endpoint checked live).193- `GET /thorchain/tx/{hash}` for the observed transaction and its outbound hashes; Midgard194  `GET https://gateway.liquify.com/chain/thorchain_midgard/v2/actions?txid={hash}` for the final swap or refund action195  (Midgard reported `inSync: false` and took 11 s at one point during the checks).196197## 8. Measured (2026-11-11 01:05–01:45 UTC)198199Fixtures were recorded with `packages/swap/test/fixtures/record.mjs`. Through the adapter (`scripts/smoke-swap-thorchain.mts`,200quote + 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 on201some pairs; one pair (SOL → USDC on Avalanche) could not be quoted in eight attempts in that run and had been quoted in an202earlier one (3.1 s). During that window the chain's block time ran about three minutes behind the wall clock, which is when203nodes refuse quotes. Earlier the same evening single calls took 0.15–0.7 s, with outliers at 12–20 s. Against CoinGecko the20440 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 on205Ethereum); 20 BTC → ETH streamed came out 0.8–1.3 % below.206207The router's default provider deadline is 2.5 s: with this gateway THORChain will often be reported as `timeout`. Either give208this provider a longer deadline or run a THORNode.209210---211212# Execution (v6, 2026-11-11)213214Sections 1–8 above describe the quote-only phase and are kept as written, except where §9 corrects them. Code:215`packages/core/src/thorchain.ts` (tables, memo, vault check, builders), `services/isolated-signer/src/vaults.ts` (the signer's216own operators), `services/api/src/xswap.ts` + `xswap-state.ts` (preparation, relay, tracking), `apps/web/src/xswap.tsx`.217Proof: `npx tsx scripts/sim-thorchain.mts` (nothing is broadcast).218219## 9. Sources read for execution, and a correction to §1220221| What | URL (read 2026-11-11) |222|---|---|223| Public endpoints | https://dev.thorchain.org/concepts/connecting-to-thorchain.html |224| Memo grammar, limits (250 bytes; 80 in `OP_RETURN`), refunds | https://dev.thorchain.org/concepts/memos.html |225| Inbound transaction rules per chain | https://dev.thorchain.org/concepts/sending-transactions.html |226| Inbound addresses, halts, "never cache", phishing warning | https://dev.thorchain.org/concepts/querying-thorchain.html |227| Vault statuses, churn, migration, how the inbound vault is chosen | https://dev.thorchain.org/bifrost/vault-behaviors.html |228| Operators listed for THORChain | https://raw.githubusercontent.com/cosmos/chain-registry/master/thorchain/chain.json |229| Router v4.1 verified source (Ethereum) | https://etherscan.io/address/0xD37BbE5744D730a1d98d8DC97c42F0Ca46aD7146#code |230231**Correction to §1 ("one usable endpoint").** That was true of the hosts tried on 2026-11-10. On 2026-11-11 five keyless232public THORNode APIs answered `inbound_addresses`, `vaults/asgard`, `lastblock`, `pools`, `quote/swap` and `tx/status`:233234| id | base | where it is published |235|---|---|---|236| `liquify` | `https://gateway.liquify.com/chain/thorchain_api` | THORChain developer docs; Cosmos chain registry |237| `rorcual` | `https://api-thorchain.rorcual.xyz` | Cosmos chain registry |238| `ibs` | `https://thorchain.ibs.team/api` | Cosmos chain registry |239| `thorwallet` | `https://thornode.thorwallet.org` | THORWallet's public node (found by probing; not in the docs) |240| `keplr` | `https://lcd-thorchain.keplr.app` | Keplr's public node (found by probing; not in the docs) |241242Still unusable: `thornode.thorchain.network`, `*.ninerealms.com` (no DNS), `thornode.thorswap.net` (browser challenge),243`api.thorchain.org` (key). The official page itself says of public endpoints: *"you can be phished and could send money to244the WRONG vault"* and suggests comparing nodes or running a full node — which is what §10 does.245Observed behaviour: the five return identical vault sets; heights within ~25 blocks of each other; latency 0.1–6 s; a246quote is often refused by two or three of them at a time (`refusing quote on node with stale state`, or a timeout) while247the vault endpoints keep answering.248249## 10. The vault-address problem and the defence250251**The problem.** A swap is a payment to the current Asgard vault with a memo. The memo is fully checkable. The vault is252not: it is the address of a threshold key held by the node set, there are several at once (six on the day), the network253picks one per request, they all change at each churn, and only the network's nodes can say which keys are vaults. The254Ethereum router does not help: `depositWithExpiry(vault, …)` forwards to whatever `vault` it is given. Whoever substitutes255that one field takes the whole input.256257**What the signer proves alone, offline** (`verifyXswap`, `buildXswapBtcTx`, `buildXswapEvmTxs`):258259- the memo is `=:ASSET:DEST:LIMIT`, rebuilt by the signer and required to equal the committed one: `ASSET` is the pinned260  spelling of a registry asset, `DEST` is the wallet's own address on the destination network (derived from its keys),261  `LIMIT` is a plain decimal integer in 1e8 units worth at least the approved minimum (`fromThorAmount(limit) ≥ minOut`);262  scientific notation, streaming fields, a `/REFUND` override and affiliate fields cannot be produced and are refused by263  the parser. A refund therefore returns to the sender, which is the wallet (UTXO: address of input 0; EVM: `msg.sender`);264- the transaction: UTXO = all inputs the wallet's own, output 0 the vault, output 1 change to the wallet (always present),265  output 2 `OP_RETURN` with one push of the memo (≤ 80 bytes), nothing else, fee ≤ the approved maximum;266  Ethereum = one call to the pinned router `depositWithExpiry(vault, asset | 0x0, amount, memo, expiry)` with267  `value = amount` for ETH, preceded for a token by `approve(router, amount)` for the exact amount;268- the vault **address is the address of the committed vault public key** (bech32 `thorpub…` → secp256k1 key → P2WPKH /269  P2PKH / keccak address, computed locally). An API that keeps a genuine key and swaps the address is refused offline.270271**What cannot be proven offline, and the check that replaces a proof** (`checkThorVault`): that the key is a current272Asgard vault. Before signing, **the signer itself** asks the five operators above — its own list, compiled into273`dist/signer.mjs`, over TLS, nothing passing through the API — for `inbound_addresses`, `vaults/asgard` and `lastblock`,274and signs only if:275276- at least **3** distinct operators answered (two may be down), and277- **every** operator that answered: lists the key as `ActiveVault` serving the source chain, with the same churn height278  (`status_since`); reports the source chain neither `halted` nor trading-paused; names the pinned router (EVM); reports a279  dust threshold below the amount; and is within 50 THORChain blocks (5 min) of the others.280281One dissenting answer refuses the signature (an honest operator that disagrees is the very signal). Nothing is cached.282A refusal happens before the passkey is looked at and records nothing as spent. The API runs the same function for its283own view when it prepares the swap and again just before it relays the signed transaction.284285Why 3 of 5 with zero dissent: unanimity means an attacker gains nothing from one, two, three or four lying operators as286long as one honest operator answers; the floor of 3 means silencing operators (which an attacker on the network path, or287a simple outage, can do) is not enough either — at least three must actively vouch for the forged key. Requiring all five288would make the swap unavailable whenever one public node is down, which was the case in most runs.289290**Exactly what remains trusted**2912921. That the operators which answered (≥ 3 of the 5 pinned) are not all compromised or colluding **at the same time**, and293   are in fact independent. Independence is assumed from their publishers, not proven: two of them could sit behind the294   same infrastructure. `thorwallet` and `keplr` are public nodes of wallets, not endpoints documented by THORChain.2952. TLS and the WebPKI for those five host names, from the signer's machine.2963. THORChain itself: that the vault's key holders (two-thirds honest, bonded) process the memo, pay the outbound and297   refund as documented. This is inherent to using THORChain and no client check can remove it.2984. The pinned tables in `thorchain.ts` (asset spellings, router address and bytecode hash, operator list).299300No longer trusted: the wallet API (it can only make a swap fail), any single THORNode operator, the quote endpoint.301302**Second lines (corroboration, not proof)** — each is fakeable by someone who parks capital, and is stated as such:303304- API, at preparation: the wallet's own chain data sources must show the vault address holding ≥ 100 × the amount in the305  source asset (BTC/LTC balance from the wallet's indexers; ETH balance on both RPCs; for a token,306  `router.vaultAllowance(vault, token)` on both RPCs). Real vaults held ~70 BTC / 110–230 ETH / 150–500 k USDC each.307- API: recent `TransferOut` events of the router with that vault as sender are counted (informational only).308- API: the router bytecode must hash to the pinned value on both RPCs; the wallet address must carry no code.309- API: every operator's quote must contain, **byte for byte**, the memo this wallet builds for that quote's numbers; the310  pool behind the memo spelling must be the pinned one, `Available`, and the only pool with that chain and ticker.311312**Exposure.** One swap is bounded by the signer's per-transfer limit of the source asset (about 50 USD) and its daily313limit (about 100 USD); the limits are those of a send and were not changed. This bounds the loss; it is not the defence.314315Midgard is not part of the check: the only public Midgard that answered (Liquify) does not proxy316`thorchain/inbound_addresses` and reported `inSync: false`.317318## 11. What is enabled, what is not, and why319320| Direction | Enabled |321|---|---|322| BTC (native SegWit) → ETH, USDC, USDT, DAI, WBTC on Ethereum; → LTC; → DOGE | yes |323| LTC (native SegWit) → the same Ethereum assets; → BTC; → DOGE | yes |324| ETH, USDC, USDT, DAI, WBTC on Ethereum → BTC, LTC, DOGE | yes |325| 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 |326| BCH (either side) | **no** — Bitcoin Cash is a separate plugin builder with no memo output; CashAddr spelling in memos not verified |327| 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 |328| Cosmos, XRP, TRON, Solana | **no** — inbound transaction shapes not verified (§4) and no outbound verification written |329| Same-network swaps through THORChain | **no** (KyberSwap does those) |330| Streaming swaps | **off** — no streaming field is ever written; an amount THORChain would split (`max_streaming_quantity > 1`) is refused |331| Maya Protocol | **no** — see `maya.md` §8 (two operators only) |332333Rules applied to every swap:334335- **Minimums.** Refused at or below the source chain's dust threshold (signer and API) and below336  `recommended_min_amount_in` (API, highest value of the quoting operators): below it a refund cannot pay its own fee and337  the input is lost. The outbound fee is shown and is already deducted from the expected output and from the minimum.338- **Price.** Quotes from at least two operators, within 1 % of each other; the lowest is kept.339- **Fee rate.** UTXO: the higher of the wallet's own estimate and THORChain's `gas_rate`, never above the wallet's own340  rate cap nor the signer's fee ceiling (otherwise refused). Ethereum: `max(2 × base fee + tip, gas_rate gwei)`.341- **Validity.** Passkey window 120 s. `expiry` = creation + 15 min: on Ethereum the router reverts after it (gas only is342  lost); a UTXO transaction is never relayed, nor relayed again, in the last 3 minutes before it. Just before relaying,343  the API re-runs the vault check and refuses if the vault is no longer active or its churn height changed.344- **No replacement.** The signer signs one transaction per proposal, ever; there is no fee bump.345- **One at a time** per source network, until the outcome (received or refunded) is known.346- **Tracking.** `tx/status` from the operators (two identical answers needed) says where to look. A swap is `received`347  only when the wallet's own chain data sources show the named outbound transaction confirmed, paying the wallet the348  expected asset, with THORChain's memo `OUT:<inbound txid>` (UTXO: transaction bytes hashed locally; Ethereum: the349  pinned router's `TransferOut` event on every RPC). `REFUND:<inbound txid>` gives `refunded`, never `received`.350  Overdue steps raise a hold (`not_observed`, `outbound_late`, `outbound_not_seen`, `outbound_mismatch`, `vault_churned`)351  and tracking continues; it never gives up and never guesses. All state is in the proposal row: it resumes after a restart.352- **Audit.** `xswap.prepared`, `transfer.*`, `xswap.observed`, `xswap.outbound`, `xswap.received` / `xswap.refunded`,353  `xswap.hold` (API chain); `xswap.vault_confirmed` (operators, height, churn height) and `sign.ok` / `sign.denied` (signer journal).354355Signer outbound hosts added (an egress allowlist on the signer machine must include them): `gateway.liquify.com`,356`api-thorchain.rorcual.xyz`, `thorchain.ibs.team`, `thornode.thorwallet.org`, `lcd-thorchain.keplr.app`. Three GETs per357operator per signature, 7 s timeout, 400 kB cap, redirects refused.358359## 12. Verified / not verified360361Verified (2026-11-11, `scripts/sim-thorchain.mts` and tests):362363- vault addresses derived locally equal the addresses the five operators publish (BTC, LTC, DOGE, ETH);364- the unanimity rule on live answers, and on the same answers with one lying, stale, halting or retiring operator;365- for 7 pairs, each operator's quote memo equals the wallet's memo byte for byte;366- a Bitcoin transaction signed by the real signer (fresh key, made-up coin), decoded with bitcoinjs-lib: three outputs in367  order, `OP_RETURN` bytes, fee at THORChain's rate — not broadcast;368- on an anvil fork of Ethereum, the signer's calls mined against the real router: `Deposit(vault, 0x0 | USDC, amount, memo)`,369  vault ETH balance / `vaultAllowance` up by exactly the amount, exact approval consumed, `THORChain_Router: expired` after expiry;370- router v4.1 source read in full; bytecode hash identical on both registry RPCs.371372**Not verified**373374- Any transaction on a real network: no swap, outbound or refund has been executed by this wallet. THORChain cannot be forked.375- That THORChain observes a UTXO inbound whose `OP_RETURN` is output 2 with change as output 1 exactly as built here376  (it is the documented shape; not exercised).377- What happens to an inbound confirmed after its vault retired: the docs only say funds may go "to an unreachable378  address"; Bifrost watching retiring vaults is believed, not read in the source.379- The outbound verification code against a real THORChain outbound (written from the router source and the documented380  `OUT:`/`REFUND:` memos; tested on scripted data only). Whether a UTXO outbound always carries the memo in a single-push381  `OP_RETURN` was not checked on-chain; if it does not, the swap stays on hold `outbound_mismatch` (never a false success).382- 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).383- That `refund` on Ethereum returns to `msg.sender` (stated by the docs as "the originator's address"; not read in Bifrost).384- Independence of the five operators; the terms of use of the THORWallet and Keplr public nodes.385- `tx/status` field shapes for a streaming or multi-outbound swap (not produced by this wallet).386387## 13. Residual risks, plainly3883891. All answering operators compromised at once (or one actor behind ≥ 3 of them): loss of one swap amount, per-transfer limit.3902. A churn between signature and confirmation of a UTXO inbound: bounded by the 15-minute window, THORChain's fee rate391   and the no-late-relay rule, not eliminated (a transaction can stay unconfirmed longer than expected and cannot be recalled).3923. THORChain insolvency, halt after the deposit, or a bug: funds can be delayed or lost; nothing here can prevent it.3934. A token memo `ETH.USDC` designates the deepest pool with that ticker: if a deeper impostor pool were ever listed the394   output would be that token, to the wallet's own address. The API refuses as soon as a second pool with the ticker exists.3955. Public nodes are flaky: a swap will often fail to prepare or sign ("only 2 operators answered", "fewer than two396   quotes") and must simply be retried. Failing closed is intended.397
Files main
Menu

Theme