Repository Public
1// Native cross-chain swaps through THORChain, wallet to itself only (docs/swap/providers/thorchain.md).2//3// A THORChain swap is ONE transfer of the source asset to the current Asgard vault of the source chain, carrying the memo4// `=:ASSET:DEST:LIMIT`. Nothing here comes from a provider: the memo and the transaction are built from the approved intent5// and the tables pinned in this file.6//7// What the signer proves by itself, offline:8// - the memo: handler `=`, exactly four fields, the asset spelling pinned here for a registry asset, DEST = the wallet's own9// address on the destination chain, LIMIT a plain decimal integer (1e8 units) worth at least the approved minimum, no10// refund override (a refund returns to the sender = the wallet), no streaming field, no affiliate;11// - the transaction: UTXO = output 0 to the vault, output 1 change to the wallet, output 2 OP_RETURN(memo), nothing else;12// Ethereum = the pinned THORChain Router's depositWithExpiry(vault, asset, amount, memo, expiry), exact-amount approval;13// - that the vault ADDRESS is the address of the vault PUBLIC KEY the intent commits to (derived locally).14// What cannot be proven offline: that this public key is a current Asgard vault. It is a threshold key of the node set, it15// rotates at every churn and is only published by the network's nodes. The defence is checkThorVault() below: several16// independently operated THORNodes are asked directly (by the signer itself, and by the API) and must ALL name the key as an17// ActiveVault, on a chain that is not halted, at about the same block height. What remains trusted: collusion or simultaneous18// compromise of every operator that answered (at least THOR_MIN_OPERATORS), TLS, and THORChain itself.19//20// Router v4.1 on Ethereum, verified source read 2026-11-11 (etherscan, solc 0.8.13, not a proxy):21// depositWithExpiry requires block.timestamp < expiration, then deposit(): asset == 0 forwards msg.value to `vault`22// (vault.send) and ignores `amount`; a token requires msg.value == 0, pulls exactly `amount` from msg.sender by transferFrom23// into the router and credits it to `vault` (vaultAllowance); it emits Deposit(vault, asset, amount, memo). The vault is an24// argument: the router does not know which vaults are genuine.25import { Address, OutScript, p2pkh, p2wpkh, Script, Transaction } from '@scure/btc-signer';26import { bech32, hex } from '@scure/base';27import { secp256k1 } from '@noble/curves/secp256k1.js';28import { encodeFunctionData, erc20Abi, getAddress, keccak256, type Hex, type TransactionSerializableEIP1559 } from 'viem';29import { z } from 'zod';30import { baseUnits } from './amounts.ts';31import { normalizeAddress } from './address.ts';32import { btcParams, dustOf, estimateBtcVsize } from './build-btc.ts';33import { getAsset, getNetwork, NETWORKS, type NetworkDef } from './networks.ts';34import type { Intent } from './intent.ts';3536// ───────────────────────────── pinned tables ─────────────────────────────3738export interface ThorAsset {39 network: string; /** registry asset id ('native' or the contract as the registry spells it) */ asset: string; symbol: string; decimals: number;40 /** THORChain chain id and full pool notation */41 chain: string; notation: string;42 /** the spelling THORNode itself writes in the memo of a quote for this asset (pool short code, or CHAIN.TICKER for a token) */43 memo: string;44 /** may be the INPUT of a swap (a builder exists and was reviewed for this chain) */45 source: boolean;46}47/**48 * Assets that can be swapped. Every pool was `Available` on 2026-11-11 (the API re-reads the pools from two operators before49 * each swap; scripts/sim-thorchain.mts re-checks them). The contract in the notation must be a registry token of the network: resolved at load time, never by ticker.50 * A token's memo spelling `CHAIN.TICKER` is resolved by THORChain to the deepest pool with that chain and ticker; the four51 * tokens below are the only pools with their ticker on Ethereum, and the API refuses a swap if that ever changes.52 */53const PINNED: [network: string, notation: string, memo: string, source: boolean][] = [54 ['bitcoin', 'BTC.BTC', 'b', true],55 ['litecoin', 'LTC.LTC', 'l', true],56 // Dogecoin is a destination only: THORChain recommends 750 000 koinu per byte for an inbound (inbound_addresses, 2026-11-11),57 // far above the wallet's Dogecoin fee ceiling, which is not raised58 ['dogecoin', 'DOGE.DOGE', 'd', false],59 ['ethereum', 'ETH.ETH', 'e', true],60 ['ethereum', 'ETH.USDC-0XA0B86991C6218B36C1D19D4A2E9EB0CE3606EB48', 'ETH.USDC', true],61 ['ethereum', 'ETH.USDT-0XDAC17F958D2EE523A2206206994597C13D831EC7', 'ETH.USDT', true],62 ['ethereum', 'ETH.DAI-0X6B175474E89094C44DA98B954EEDEAC495271D0F', 'ETH.DAI', true],63 ['ethereum', 'ETH.WBTC-0X2260FAC5E5542A773AA44FBCFEDF7C193BC2C599', 'ETH.WBTC', true],64];65export const THOR_ASSETS: ThorAsset[] = PINNED.map(([network, notation, memo, source]) => {66 const n = NETWORKS[network]; if (!n) throw new Error(`THORChain table: unknown network ${network}`);67 const [chain, rest] = notation.split('.') as [string, string], dash = rest.indexOf('-');68 if (dash < 0) { if (n.native.symbol.toUpperCase() !== rest) throw new Error(`THORChain table: ${notation} is not the gas asset of ${network}`); return { network, asset: 'native', symbol: n.native.symbol, decimals: n.native.decimals, chain, notation, memo, source }; }69 const hits = n.tokens.filter((t) => t.id.toUpperCase() === rest.slice(dash + 1));70 if (hits.length !== 1 || hits[0]!.symbol.toUpperCase() !== rest.slice(0, dash)) throw new Error(`THORChain table: ${notation} is not a registry token of ${network}`);71 return { network, asset: hits[0]!.id, symbol: hits[0]!.symbol, decimals: hits[0]!.decimals, chain, notation, memo, source };72});73export function thorAsset(network: string, asset: string): ThorAsset {74 const a = THOR_ASSETS.find((x) => x.network === network && (x.asset === 'native' ? asset === 'native' : x.asset.toLowerCase() === asset.toLowerCase()));75 if (!a) throw new Error('this asset cannot be swapped through THORChain by this wallet');76 return a;77}78export const thorPairEnabled = (from: { network: string; asset: string }, to: { network: string; asset: string }): boolean => {79 try { return thorAsset(from.network, from.asset).source && !!thorAsset(to.network, to.asset) && from.network !== to.network; } catch { return false; }80};8182/** THORChain Router per EVM source network: address named by the network on every operator, SHA-256 of its runtime bytecode on both registry RPCs. */83export const THOR_ROUTERS: Record<string, { address: string; codeSha256: string; reviewed: string }> = {84 ethereum: { address: '0xD37BbE5744D730a1d98d8DC97c42F0Ca46aD7146', codeSha256: '2073bd2f5d34e2b610bc41d1b52ef0a8c3ec5000480c6f3e157dc486e1f02ea8',85 reviewed: '2026-11-11, verified source read in full on etherscan (THORChain_Router v4.1, solc 0.8.13, not a proxy); bytecode hashed on both registry RPCs; deposit proven on a fork' },86};87export const THOR_ROUTER_ABI = [88 { type: 'function', name: 'depositWithExpiry', stateMutability: 'payable', outputs: [], inputs: [{ name: 'vault', type: 'address' }, { name: 'asset', type: 'address' }, { name: 'amount', type: 'uint256' }, { name: 'memo', type: 'string' }, { name: 'expiration', type: 'uint256' }] },89 { type: 'function', name: 'vaultAllowance', stateMutability: 'view', outputs: [{ type: 'uint256' }], inputs: [{ name: 'vault', type: 'address' }, { name: 'token', type: 'address' }] },90 { type: 'event', name: 'Deposit', inputs: [{ name: 'to', type: 'address', indexed: true }, { name: 'asset', type: 'address', indexed: true }, { name: 'amount', type: 'uint256', indexed: false }, { name: 'memo', type: 'string', indexed: false }] },91 { type: 'event', name: 'TransferOut', inputs: [{ name: 'vault', type: 'address', indexed: true }, { name: 'to', type: 'address', indexed: true }, { name: 'asset', type: 'address', indexed: false }, { name: 'amount', type: 'uint256', indexed: false }, { name: 'memo', type: 'string', indexed: false }] },92] as const;9394/**95 * Independently operated public THORNode APIs (2026-11-11). The first three are published by THORChain's documentation and96 * the Cosmos chain registry; the last two are the public nodes of two wallets. This list is compiled into the signer: the API97 * cannot change which operators the signer asks.98 */99export const THORNODE_OPERATORS: { id: string; base: string }[] = [100 { id: 'liquify', base: 'https://gateway.liquify.com/chain/thorchain_api' },101 { id: 'rorcual', base: 'https://api-thorchain.rorcual.xyz' },102 { id: 'ibs', base: 'https://thorchain.ibs.team/api' },103 { id: 'thorwallet', base: 'https://thornode.thorwallet.org' },104 { id: 'keplr', base: 'https://lcd-thorchain.keplr.app' },105];106/** Fewer answers than this = no signature. With five pinned operators, two may be down. */107export const THOR_MIN_OPERATORS = 3;108/** Operators must be within this many THORChain blocks of each other (6 s blocks: five minutes). */109export const THOR_MAX_HEIGHT_SPREAD = 50;110/** The router call reverts after this many seconds; a UTXO inbound is never (re)broadcast after it. */111export const THOR_EXPIRY_SECONDS = 900;112113// ───────────────────────────── 1e8 units ─────────────────────────────114115const pow10 = (n: number) => 10n ** BigInt(n);116/** Base units → THORChain 1e8 units. Throws when the amount has a part below 1e-8 that THORChain would not credit. */117export function toThorAmount(base: bigint, decimals: number): bigint {118 if (base < 0n || !Number.isInteger(decimals) || decimals < 0 || decimals > 36) throw new Error('bad amount');119 if (decimals <= 8) return base * pow10(8 - decimals);120 if (base % pow10(decimals - 8) !== 0n) throw new Error('amount is finer than THORChain\'s 8 decimals');121 return base / pow10(decimals - 8);122}123/** THORChain 1e8 units → base units, rounded DOWN (a chain cannot pay a fraction of its base unit). */124export function fromThorAmount(units: bigint, decimals: number): bigint {125 if (units < 0n || !Number.isInteger(decimals) || decimals < 0 || decimals > 36) throw new Error('bad amount');126 return decimals >= 8 ? units * pow10(decimals - 8) : units / pow10(8 - decimals);127}128129// ───────────────────────────── memo ─────────────────────────────130131const LIMIT = /^[1-9]\d{0,24}$/; // plain decimal: no sign, no leading zero, no exponent ("1e6" would be accepted by THORChain and is refused here)132export const THOR_MEMO_MAX = 250, THOR_MEMO_MAX_UTXO = 80;133export interface ThorMemo { asset: ThorAsset; destination: string; limit: bigint }134135/** `=:ASSET:DEST:LIMIT` for a pinned asset. The limit is written in full: nothing is rounded or shortened. */136export function buildThorMemo(to: ThorAsset, destination: string, limit: bigint): string {137 if (limit <= 0n || !LIMIT.test(limit.toString())) throw new Error('memo limit must be a positive integer');138 if (normalizeAddress(to.network, destination) !== destination) throw new Error('memo destination is not a normalized address of the destination network');139 if (!/^[A-Za-z0-9]{20,100}$/.test(destination)) throw new Error('memo destination has characters a memo cannot carry');140 const memo = `=:${to.memo}:${destination}:${limit}`;141 if (memo.length > THOR_MEMO_MAX) throw new Error('memo too long');142 return memo;143}144/** Strict inverse of buildThorMemo. Anything this wallet never writes — another handler, a refund override, streaming or145 * affiliate fields, scientific notation, an asset spelling that is not pinned — is refused. */146export function parseThorMemo(memo: string): ThorMemo {147 if (typeof memo !== 'string' || memo.length > THOR_MEMO_MAX || !/^[\x21-\x7e]+$/.test(memo)) throw new Error('memo is not printable ASCII of an acceptable length');148 const p = memo.split(':');149 if (p[0] !== '=') throw new Error('memo is not a market swap');150 if (p.length !== 4) throw new Error(p.length > 4 ? 'memo carries affiliate or extra fields' : 'memo has no limit');151 const [, a, dest, lim] = p as [string, string, string, string];152 if (dest.includes('/')) throw new Error('memo carries a refund address override');153 if (lim.includes('/')) throw new Error('memo carries streaming parameters');154 if (!LIMIT.test(lim)) throw new Error('memo limit is not a plain positive integer');155 const asset = THOR_ASSETS.find((x) => x.memo === a);156 if (!asset) throw new Error('memo names an asset spelling that is not pinned');157 if (!/^[A-Za-z0-9]{20,100}$/.test(dest)) throw new Error('memo destination is malformed');158 try { if (normalizeAddress(asset.network, dest) !== dest) throw new Error('x'); } catch { throw new Error('memo destination is not a normalized address of the destination network'); }159 return { asset, destination: dest, limit: BigInt(lim) };160}161162// ───────────────────────────── vault key → address ─────────────────────────────163164const AMINO_SECP = 'eb5ae98721';165/** The 33-byte secp256k1 key inside a `thorpub1addwnpep…` string (bech32 of the amino-prefixed compressed key). */166export function thorPubKeyBytes(pubKey: string): Uint8Array {167 if (typeof pubKey !== 'string' || !/^thorpub1[02-9ac-hj-np-z]{50,80}$/.test(pubKey)) throw new Error('not a THORChain public key');168 const d = bech32.decodeToBytes(pubKey);169 if (d.prefix !== 'thorpub' || d.bytes.length !== 38 || hex.encode(d.bytes.slice(0, 5)) !== AMINO_SECP) throw new Error('not a secp256k1 THORChain public key');170 const key = d.bytes.slice(5);171 secp256k1.Point.fromBytes(key); // must be a point of the curve172 return key;173}174/** Inverse of thorPubKeyBytes (tests and the proof script build keys no node knows with it). */175export function encodeThorPubKey(key: Uint8Array): string {176 if (key.length !== 33) throw new Error('not a compressed key');177 return bech32.encode('thorpub', bech32.toWords(new Uint8Array([...hex.decode(AMINO_SECP), ...key])), 200);178}179/** The address a vault key controls on a wallet network (native SegWit / legacy P2PKH for Bitcoin-style chains, the usual keccak address for EVM). */180export function thorVaultAddress(pubKey: string, network: string): string {181 const n = getNetwork(network), key = thorPubKeyBytes(pubKey);182 if (n.family === 'evm') return getAddress(`0x${keccak256(secp256k1.Point.fromBytes(key).toBytes(false).slice(1)).slice(-40)}`);183 if (n.family === 'btc' && n.utxo) { const a = (n.utxo.kind === 'pkh' ? p2pkh(key, btcParams(n)) : p2wpkh(key, btcParams(n))).address; if (a) return a; }184 throw new Error('no vault address derivation for this network');185}186187// ───────────────────────────── the vault check (shared by the signer and the API) ─────────────────────────────188189const digits = z.string().regex(/^(0|[1-9]\d{0,38})$/);190const InboundSchema = z.array(z.looseObject({ chain: z.string().regex(/^[A-Z0-9]{2,10}$/), pub_key: z.string().max(120), address: z.string().min(20).max(160), router: z.string().regex(/^0x[0-9a-fA-F]{40}$/).optional(),191 halted: z.boolean(), global_trading_paused: z.boolean().optional(), chain_trading_paused: z.boolean().optional(), gas_rate: digits.optional(), dust_threshold: digits.optional(), outbound_fee: digits.optional() })).min(1).max(100);192const VaultsSchema = z.array(z.looseObject({ pub_key: z.string().max(120), status: z.string().max(40), status_since: z.number().int().nonnegative().optional(), chains: z.array(z.string().max(12)).max(60).optional(),193 membership: z.array(z.string().max(120)).max(200).optional() })).min(1).max(60);194const LastBlockSchema = z.array(z.looseObject({ chain: z.string().max(12), thorchain: z.number().int().positive() })).min(1).max(100);195196export interface ThorView {197 /** THORChain block height the node is at */198 height: number;199 inbound: { chain: string; pubKey: string; address: string; router: string | null; halted: boolean; paused: boolean; gasRate: bigint | null; dustThreshold: bigint; outboundFee: bigint | null }[];200 vaults: { pubKey: string; status: string; statusSince: number; chains: string[]; members: number }[];201}202export type ThorAnswer = { operator: string; view: ThorView } | { operator: string; error: string };203export type ThorGet = (url: string) => Promise<unknown>;204205/** One operator's view: `inbound_addresses`, the Asgard vault set and the node's height. Throws on any malformed answer. */206export async function fetchThorView(base: string, get: ThorGet): Promise<ThorView> {207 const [inb, vaults, last] = await Promise.all([get(`${base}/thorchain/inbound_addresses`), get(`${base}/thorchain/vaults/asgard`), get(`${base}/thorchain/lastblock`)]);208 const i = InboundSchema.parse(inb), v = VaultsSchema.parse(vaults), l = LastBlockSchema.parse(last);209 return {210 height: Math.max(...l.map((x) => x.thorchain)),211 inbound: i.map((x) => ({ chain: x.chain, pubKey: x.pub_key, address: x.address, router: x.router ?? null, halted: x.halted, paused: !!x.global_trading_paused || !!x.chain_trading_paused,212 gasRate: x.gas_rate !== undefined ? BigInt(x.gas_rate) : null, dustThreshold: BigInt(x.dust_threshold ?? '0'), outboundFee: x.outbound_fee !== undefined ? BigInt(x.outbound_fee) : null })),213 vaults: v.map((x) => ({ pubKey: x.pub_key, status: x.status, statusSince: x.status_since ?? 0, chains: x.chains ?? [], members: x.membership?.length ?? 0 })),214 };215}216/** Ask every operator at once; an operator that fails or answers nonsense is reported, not retried. */217export async function fetchThorViews(get: ThorGet, operators = THORNODE_OPERATORS): Promise<ThorAnswer[]> {218 return Promise.all(operators.map(async (o): Promise<ThorAnswer> => {219 try { return { operator: o.id, view: await fetchThorView(o.base, get) }; } catch (e) { return { operator: o.id, error: (e as Error).message.slice(0, 120) || 'no answer' }; }220 }));221}222223export interface ThorVaultWant { /** THORChain chain id of the source */ chain: string; vaultPubKey: string; /** pinned router of the source network, or null for a chain without router */ router: string | null; /** what is sent, 1e8 units */ amount: bigint }224export interface ThorVaultVerdict { operators: string[]; silent: string[]; height: number; /** height at which the vault became active (its churn) */ statusSince: number; /** highest values named by any operator */ gasRate: bigint | null; dustThreshold: bigint; outboundFee: bigint | null; members: number }225226/**227 * The unanimity rule. Throws unless at least `min` distinct operators answered and EVERY operator that answered228 * - is within THOR_MAX_HEIGHT_SPREAD blocks of the others (a lagging node does not know the last churn);229 * - lists the vault key as an `ActiveVault` serving the chain, with the same churn height;230 * - reports the chain neither halted nor paused, and (EVM) the pinned router;231 * - reports a dust threshold below the amount.232 * One dissenting answer is enough to refuse: an honest operator that disagrees is exactly the signal this check exists for.233 * Operators that did not answer are tolerated only while `min` others did.234 */235export function checkThorVault(answers: ThorAnswer[], want: ThorVaultWant, min = THOR_MIN_OPERATORS): ThorVaultVerdict {236 const seen = new Set<string>(), views: { operator: string; view: ThorView }[] = [], silent: string[] = [];237 for (const a of answers) {238 if (seen.has(a.operator)) throw new Error('an operator was counted twice');239 seen.add(a.operator);240 if ('view' in a) views.push(a); else silent.push(a.operator);241 }242 if (views.length < Math.max(min, THOR_MIN_OPERATORS)) throw new Error(`only ${views.length} THORNode operator(s) answered, ${Math.max(min, THOR_MIN_OPERATORS)} are required${silent.length ? ` (silent: ${silent.join(', ')})` : ''}`);243 const heights = views.map((v) => v.view.height), top = Math.max(...heights);244 let statusSince = -1, gasRate: bigint | null = null, dust = 0n, outboundFee: bigint | null = null, members = 0;245 for (const { operator, view } of views) {246 const no = (why: string): never => { throw new Error(`THORNode operator ${operator} ${why}; the vault is not confirmed`); };247 if (top - view.height > THOR_MAX_HEIGHT_SPREAD) no(`is ${top - view.height} blocks behind the others`);248 const hits = view.vaults.filter((v) => v.pubKey === want.vaultPubKey);249 if (hits.length !== 1) no('does not list this vault');250 const v = hits[0]!;251 if (v.status !== 'ActiveVault') no(`lists this vault as ${v.status.slice(0, 20)}`);252 if (!v.chains.includes(want.chain)) no(`lists this vault without ${want.chain}`);253 if (statusSince === -1) statusSince = v.statusSince; else if (v.statusSince !== statusSince) no('gives another churn height for this vault');254 const inb = view.inbound.filter((x) => x.chain === want.chain);255 if (inb.length !== 1) no(`has no inbound address for ${want.chain}`);256 const e = inb[0]!;257 if (e.halted) no(`reports ${want.chain} halted`);258 if (e.paused) no(`reports trading paused on ${want.chain}`);259 if (want.router === null ? e.router !== null : !e.router || e.router.toLowerCase() !== want.router.toLowerCase()) no('names another router');260 if (want.amount <= e.dustThreshold) no('reports a dust threshold at or above the amount');261 if (e.gasRate !== null && (gasRate === null || e.gasRate > gasRate)) gasRate = e.gasRate;262 if (e.dustThreshold > dust) dust = e.dustThreshold;263 if (e.outboundFee !== null && (outboundFee === null || e.outboundFee > outboundFee)) outboundFee = e.outboundFee;264 if (members === 0 || v.members < members) members = v.members;265 }266 return { operators: views.map((v) => v.operator), silent, height: top, statusSince, gasRate, dustThreshold: dust, outboundFee, members };267}268/** The vault key the most operators currently name as the inbound address of a chain (ties: the first in operator order). */269export function pickThorVault(answers: ThorAnswer[], chain: string): string | null {270 const votes = new Map<string, number>();271 for (const a of answers) if ('view' in a) for (const x of a.view.inbound) if (x.chain === chain) votes.set(x.pubKey, (votes.get(x.pubKey) ?? 0) + 1);272 let best: string | null = null, n = 0;273 for (const [k, v] of votes) if (v > n) { best = k; n = v; }274 return best;275}276277// ───────────────────────────── the intent ─────────────────────────────278279export interface XswapPlan { src: ThorAsset; dst: ThorAsset; amount: bigint; amount1e8: bigint; limit: bigint; minOut: bigint; memo: string; want: ThorVaultWant }280281/** Check every field of a cross-chain swap intent. `ownDestination` is the wallet's address on the destination network, as282 * the caller derives it (the signer from its keys). Throws on any mismatch. */283export function verifyXswap(i: Intent, ownDestination: string): XswapPlan {284 const n = getNetwork(i.network), x = i.xswap;285 if (!x) throw new Error('not a cross-chain swap intent');286 if (i.swap || i.bridge) throw new Error('an intent is one operation only');287 if (x.provider !== 'thorchain') throw new Error('unknown cross-chain provider');288 if (i.chainRef !== n.chainRef) throw new Error('chain identity mismatch');289 if (i.memo !== undefined) throw new Error('a cross-chain swap carries its memo in the swap section only');290 const src = thorAsset(i.network, i.asset), dst = thorAsset(x.toNetwork, x.toAsset);291 if (!src.source) throw new Error(`${src.symbol} on ${n.name} cannot be the input of a THORChain swap in this wallet`);292 if (src.network === dst.network) throw new Error('source and destination are the same network');293 const reg = getAsset(i.network, i.asset); // throws unless in the registry294 if (i.asset !== src.asset || reg.symbol !== i.symbol || reg.decimals !== i.decimals || src.decimals !== i.decimals) throw new Error('input asset metadata mismatch');295 if (x.toAsset !== dst.asset || getAsset(x.toNetwork, x.toAsset).decimals !== dst.decimals) throw new Error('output asset metadata mismatch');296 if (!ownDestination || normalizeAddress(x.toNetwork, ownDestination) !== ownDestination) throw new Error('no wallet address on the destination network');297 const amount = baseUnits(i.amount), limit = baseUnits(x.limit), minOut = baseUnits(x.minOut);298 if (amount <= 0n || limit <= 0n || minOut <= 0n) throw new Error('amounts must be positive');299 const amount1e8 = toThorAmount(amount, src.decimals);300 if (amount1e8 <= 0n) throw new Error('amount below THORChain precision');301 if (fromThorAmount(limit, dst.decimals) < minOut) throw new Error('memo limit is below the approved minimum');302 const memo = buildThorMemo(dst, ownDestination, limit);303 if (memo !== x.memo) throw new Error('memo does not match what this wallet builds for the approved swap');304 const back = parseThorMemo(memo);305 if (back.asset !== dst || back.destination !== ownDestination || back.limit !== limit) throw new Error('memo does not read back as the approved swap');306 if (new TextEncoder().encode(memo).length > (n.family === 'btc' ? THOR_MEMO_MAX_UTXO : THOR_MEMO_MAX)) throw new Error('memo too long for the source chain');307 // the vault address must be the address of the committed vault key, and never the wallet itself308 if (i.to !== x.vault) throw new Error('the transfer does not go to the committed vault');309 if (thorVaultAddress(x.vaultPubKey, i.network) !== x.vault) throw new Error('vault address is not the address of the committed vault key');310 if (x.vault.toLowerCase() === i.from.toLowerCase()) throw new Error('vault is the wallet itself');311 let router: string | null = null;312 if (n.family === 'evm') {313 const pin = THOR_ROUTERS[i.network];314 if (!pin) throw new Error(`THORChain swaps from ${n.name} are not enabled`);315 if (x.router !== pin.address) throw new Error('router is not the pinned THORChain router');316 if (x.vault.toLowerCase() === pin.address.toLowerCase()) throw new Error('vault is the router');317 router = pin.address;318 } else if (n.family === 'btc') {319 if (x.router !== '' || x.approveGas !== '0') throw new Error('a UTXO swap has no router and no approval');320 } else throw new Error('THORChain swaps are not defined for this network family');321 if (!(x.expiry > i.expiresAt) || x.expiry > i.createdAt + THOR_EXPIRY_SECONDS) throw new Error('swap expiry outside the allowed window');322 return { src, dst, amount, amount1e8, limit, minOut, memo, want: { chain: src.chain, vaultPubKey: x.vaultPubKey, router, amount: amount1e8 } };323}324325// ───────────────────────────── source transactions ─────────────────────────────326327const DEPOSIT_GAS_MIN = 30_000n, DEPOSIT_GAS_MAX = 400_000n, APPROVE_GAS_MAX = 120_000n, ZERO = '0x0000000000000000000000000000000000000000';328329export function xswapDepositData(i: Intent, ownDestination: string): Hex {330 const p = verifyXswap(i, ownDestination), x = i.xswap!;331 return encodeFunctionData({ abi: THOR_ROUTER_ABI, functionName: 'depositWithExpiry', args: [x.vault as Hex, (p.src.asset === 'native' ? ZERO : p.src.asset) as Hex, p.amount, p.memo, BigInt(x.expiry)] });332}333/** Ethereum source: an exact-amount approval when asked (tokens only), then the router deposit. */334export function buildXswapEvmTxs(i: Intent, ownDestination: string): { approve: TransactionSerializableEIP1559 | null; deposit: TransactionSerializableEIP1559 } {335 const p = verifyXswap(i, ownDestination), n = getNetwork(i.network), e = i.evm, x = i.xswap!;336 if (n.family !== 'evm' || !e) throw new Error('not an EVM intent');337 const gas = baseUnits(e.gasLimit), maxFeePerGas = baseUnits(e.maxFeePerGas), tip = baseUnits(e.maxPriorityFeePerGas), approveGas = baseUnits(x.approveGas);338 if (tip > maxFeePerGas) throw new Error('priority fee above max fee');339 if (gas < DEPOSIT_GAS_MIN || gas > DEPOSIT_GAS_MAX) throw new Error('deposit gas limit out of range');340 if (approveGas !== 0n && (p.src.asset === 'native' || approveGas < 30_000n || approveGas > APPROVE_GAS_MAX)) throw new Error('approval gas out of range');341 if ((gas + approveGas) * maxFeePerGas > baseUnits(i.maxFee)) throw new Error('fee exceeds approved maximum');342 const common = { type: 'eip1559' as const, chainId: Number(n.chainRef), maxFeePerGas, maxPriorityFeePerGas: tip };343 const approve = approveGas === 0n ? null : { ...common, nonce: e.nonce, gas: approveGas, to: p.src.asset as Hex, value: 0n,344 data: encodeFunctionData({ abi: erc20Abi, functionName: 'approve', args: [x.router as Hex, p.amount] }) }; // exact amount, never unlimited345 const deposit = { ...common, nonce: e.nonce + (approve ? 1 : 0), gas, to: x.router as Hex, value: p.src.asset === 'native' ? p.amount : 0n, data: xswapDepositData(i, ownDestination) };346 return { approve, deposit };347}348349/** The OP_RETURN script of a memo (one data push). */350export function thorOpReturn(memo: string): Uint8Array {351 const b = new TextEncoder().encode(memo);352 if (b.length < 1 || b.length > THOR_MEMO_MAX_UTXO) throw new Error('memo does not fit in OP_RETURN');353 return Script.encode(['RETURN', b]);354}355/** Bytes the OP_RETURN output adds to a transaction: value (8) + script length (1) + script. */356export const thorOpReturnSize = (memo: string) => 9 + thorOpReturn(memo).length;357/** Upper bound of the virtual size of a swap transaction: inputs, vault output, change output, OP_RETURN. */358export const estimateXswapVsize = (nInputs: number, vault: string, memo: string, n: NetworkDef) => estimateBtcVsize(nInputs, vault, true, n) + thorOpReturnSize(memo);359360/**361 * Bitcoin-style source: exactly three outputs, in the order THORChain requires — the vault, the change back to the wallet362 * (the address of input 0, where a refund would return), the memo. All inputs are the wallet's own coins.363 */364export function buildXswapBtcTx(i: Intent, ownDestination: string, aux?: Record<string, unknown>): Transaction {365 const p = verifyXswap(i, ownDestination), n = getNetwork(i.network);366 if (n.family !== 'btc' || !n.utxo || !i.btc) throw new Error('not a UTXO intent');367 const P = btcParams(n), DUST = dustOf(n);368 if (normalizeAddress(i.network, i.to) !== i.to) throw new Error('vault address not normalized');369 const fromDecoded = Address(P).decode(i.from);370 if (fromDecoded.type !== n.utxo.kind) throw new Error(n.utxo.kind === 'wpkh' ? 'source must be native SegWit (P2WPKH)' : 'source must be P2PKH');371 if (Address(P).decode(i.to).type !== n.utxo.kind) throw new Error('vault address is not of the expected script type');372 const script = OutScript.encode(fromDecoded);373 const change = baseUnits(i.btc.change), fee = baseUnits(i.btc.fee);374 if (p.amount < DUST) throw new Error('amount below dust');375 if (change < DUST) throw new Error('a swap transaction always returns change to the wallet (change below dust)');376 if (fee <= 0n || fee > baseUnits(i.maxFee)) throw new Error('fee exceeds approved maximum');377 const prev = (aux?.prevTx ?? {}) as Record<string, string>, seen = new Set<string>();378 let total = 0n;379 const tx = new Transaction({ allowUnknownOutputs: true });380 for (const u of i.btc.inputs) {381 const k = `${u.txid}:${u.vout}`;382 if (seen.has(k)) throw new Error('duplicate input');383 seen.add(k);384 const v = baseUnits(u.value);385 total += v;386 if (n.utxo.kind === 'wpkh') tx.addInput({ txid: hex.decode(u.txid), index: u.vout, witnessUtxo: { script, amount: v }, sequence: 0xfffffffd });387 else { // legacy signatures do not commit to the input value: the previous transaction is checked against txid, script and value388 const raw = prev[u.txid];389 if (!raw) throw new Error('missing previous transaction for a legacy input');390 const pt = Transaction.fromRaw(hex.decode(raw), { allowUnknownOutputs: true });391 if (pt.id !== u.txid) throw new Error('previous transaction does not match input txid');392 const o = pt.getOutput(u.vout);393 if (o.amount !== v || !o.script || hex.encode(o.script) !== hex.encode(script)) throw new Error('previous output does not match the approved input');394 tx.addInput({ txid: hex.decode(u.txid), index: u.vout, nonWitnessUtxo: hex.decode(raw), sequence: 0xfffffffd });395 }396 }397 if (total !== p.amount + change + fee) throw new Error('inputs != outputs + fee');398 tx.addOutputAddress(i.to, p.amount, P);399 tx.addOutputAddress(i.from, change, P);400 tx.addOutput({ script: thorOpReturn(p.memo), amount: 0n });401 if (tx.outputsLength !== 3) throw new Error('unexpected output count');402 return tx;403}404405/**406 * What a Bitcoin-style transaction pays to an address, and the memo it carries. Used to check, with the wallet's own data407 * sources, the outbound transaction THORChain names: the bytes are hashed here, so the answer is about that txid and nothing else.408 */409export function utxoPaymentTo(rawHex: string, network: string, address: string): { txid: string; paid: bigint; memo: string | null } {410 const n = getNetwork(network);411 if (n.family !== 'btc' || !n.utxo) throw new Error('not a UTXO network');412 const tx = Transaction.fromRaw(hex.decode(rawHex), { allowUnknownOutputs: true, allowUnknownInputs: true });413 const want = hex.encode(OutScript.encode(Address(btcParams(n)).decode(address)));414 let paid = 0n, memo: string | null = null;415 for (let k = 0; k < tx.outputsLength; k++) {416 const o = tx.getOutput(k);417 if (!o.script) continue;418 if (hex.encode(o.script) === want) paid += o.amount ?? 0n;419 else if (o.script[0] === 0x6a && memo === null) { try { const d = Script.decode(o.script); if (d.length === 2 && d[1] instanceof Uint8Array) memo = new TextDecoder().decode(d[1]); } catch { /* not a single push */ } }420 }421 return { txid: tx.id, paid, memo };422}423424/** The transaction id as THORChain spells it (upper-case hex, no 0x). */425export const thorTxId = (txid: string) => txid.replace(/^0x/, '').toUpperCase();426