Repository Public
1// THORChain native swaps: memo, units, vault key → address, the unanimity rule, and the source transactions.2// The vault key and addresses below were read from five public THORNode operators on 2026-11-11 (public network data).3import { describe, expect, it } from 'vitest';4import { randomBytes } from 'node:crypto';5import { secp256k1 } from '@noble/curves/secp256k1.js';6import { p2pkh, p2wpkh, Script, Transaction } from '@scure/btc-signer';7import { hex } from '@scure/base';8import { decodeFunctionData, erc20Abi } from 'viem';9import {10 btcParams, buildBtcTx, buildEvmTx, buildThorMemo, buildXswapBtcTx, buildXswapEvmTxs, canonicalBytes, checkThorVault, fetchThorView, fromThorAmount, IntentSchema, intentHash, NETWORKS, parseThorMemo, pickThorVault,11 THOR_ASSETS, THOR_ROUTER_ABI, THOR_ROUTERS, thorAsset, thorOpReturn, thorPairEnabled, thorTxId, thorVaultAddress, toThorAmount, utxoPaymentTo, verifyXswap, type Intent, type ThorAnswer, type ThorView,12} from '../src/index.ts';1314const VAULT_KEY = 'thorpub1addwnpepqgwpafz6l99mkevlz7spaezgzcxy5t2896hgm7e6kytqrkmulex4wxjp8az';15const VAULT = { bitcoin: 'bc1qup236qj2mr87ml9388ydm33tuhtdckk33j68qx', litecoin: 'ltc1qup236qj2mr87ml9388ydm33tuhtdckk34wqrck', dogecoin: 'DRbFs1GDf9F3MGnHy24vPP8h7wCE8nCBAU', ethereum: '0x718f6C9df785BC4e323086De024CA822efbE8aB9' };16const OTHER_KEY = 'thorpub1addwnpepqvlena222zluk5p3r0s9uey758rd43qedtxf2xalprynuaxp69rscr0jy4h';17const ROUTER = THOR_ROUTERS.ethereum!.address, USDC = NETWORKS.ethereum!.tokens.find((t) => t.symbol === 'USDC')!.id;18const priv = randomBytes(32), pub = secp256k1.getPublicKey(priv, true); // fresh key per run: no funds, never leaves the test19const ME_BTC = p2wpkh(pub, btcParams(NETWORKS.bitcoin!)).address!, ME_EVM = '0x70997970C51812dc3A010C7d01b50e0d17dc79C8';20const eth = thorAsset('ethereum', 'native'), btc = thorAsset('bitcoin', 'native'), usdc = thorAsset('ethereum', USDC);21const T0 = 1_794_000_000;2223function btcIntent(o: Partial<Intent> = {}, x: Partial<NonNullable<Intent['xswap']>> = {}): Intent {24 const limit = x.limit ?? '2098152';25 return IntentSchema.parse({ v: 1, proposalId: '7a1c1f0e-8f5f-4b1e-9c56-000000000001', keyId: 'spw-0123456789abcdef', network: 'bitcoin', chainRef: NETWORKS.bitcoin!.chainRef, asset: 'native', symbol: 'BTC', decimals: 8,26 amount: '60000', from: ME_BTC, to: VAULT.bitcoin, maxFee: '2000', createdAt: T0, expiresAt: T0 + 120, policyVersion: 3,27 btc: { inputs: [{ txid: 'ab'.repeat(32), vout: 1, value: '100000' }], change: '39000', fee: '1000' },28 xswap: { provider: 'thorchain', toNetwork: 'ethereum', toAsset: 'native', minOut: '20981520000000000', limit, memo: `=:e:${ME_EVM}:${limit}`, vault: VAULT.bitcoin, vaultPubKey: VAULT_KEY, router: '', expiry: T0 + 900, approveGas: '0', ...x }, ...o });29}30function evmIntent(o: Partial<Intent> = {}, x: Partial<NonNullable<Intent['xswap']>> = {}): Intent {31 return IntentSchema.parse({ v: 1, proposalId: '7a1c1f0e-8f5f-4b1e-9c56-000000000002', keyId: 'spw-0123456789abcdef', network: 'ethereum', chainRef: '1', asset: 'native', symbol: 'ETH', decimals: 18,32 amount: '20000000000000000', from: ME_EVM, to: VAULT.ethereum, maxFee: '3000000000000000', createdAt: T0, expiresAt: T0 + 120, policyVersion: 3,33 evm: { nonce: 7, gasLimit: '80000', maxFeePerGas: '20000000000', maxPriorityFeePerGas: '1000000000' },34 xswap: { provider: 'thorchain', toNetwork: 'bitcoin', toAsset: 'native', minOut: '54436', limit: '54436', memo: `=:b:${ME_BTC}:54436`, vault: VAULT.ethereum, vaultPubKey: VAULT_KEY, router: ROUTER, expiry: T0 + 900, approveGas: '0', ...x }, ...o });35}3637describe('units (THORChain counts every asset in 1e-8)', () => {38 it('converts exactly both ways and refuses what THORChain would not credit', () => {39 expect(toThorAmount(60_000n, 8)).toBe(60_000n);40 expect(toThorAmount(45_000_000n, 6)).toBe(4_500_000_000n);41 expect(toThorAmount(20_000_000_000_000_000n, 18)).toBe(2_000_000n);42 expect(() => toThorAmount(20_000_000_000_000_001n, 18)).toThrow(/finer/);43 expect(fromThorAmount(4_335_455_817n, 6)).toBe(43_354_558n); // rounded down: the chain cannot pay 0.17 of a base unit44 expect(fromThorAmount(2_098_152n, 18)).toBe(20_981_520_000_000_000n);45 expect(() => toThorAmount(-1n, 8)).toThrow();46 });47});4849describe('memo', () => {50 it('builds exactly what THORNode writes in its own quotes (recorded 2026-11-11) and reads it back', () => {51 // quote BTC.BTC -> ETH.ETH and -> ETH.USDC for destination 0x1111…: "=:e:0x1111…:2098152" and "=:ETH.USDC:0x1111…:4335455817"52 const d = '0x1111111111111111111111111111111111111111';53 expect(buildThorMemo(eth, d, 2_098_152n)).toBe('=:e:0x1111111111111111111111111111111111111111:2098152');54 expect(buildThorMemo(usdc, d, 4_335_455_817n)).toBe('=:ETH.USDC:0x1111111111111111111111111111111111111111:4335455817');55 expect(buildThorMemo(btc, 'bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq', 54_436n)).toBe('=:b:bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq:54436');56 expect(buildThorMemo(thorAsset('dogecoin', 'native'), 'DH5yaieqoZN36fDVciNyRueRGvGLR3mr7L', 66_148_801_492n)).toBe('=:d:DH5yaieqoZN36fDVciNyRueRGvGLR3mr7L:66148801492');57 for (const a of THOR_ASSETS) {58 const dest = a.network === 'ethereum' ? ME_EVM : a.network === 'bitcoin' ? ME_BTC : a.network === 'litecoin' ? VAULT.litecoin : VAULT.dogecoin;59 const m = buildThorMemo(a, dest, 123_456_789n), back = parseThorMemo(m);60 expect(back).toEqual({ asset: a, destination: dest, limit: 123_456_789n });61 expect(new TextEncoder().encode(m).length).toBeLessThanOrEqual(80); // every enabled destination fits in OP_RETURN62 }63 });64 it.each([65 ['another handler', `+:e:${ME_EVM}:1000`, /market swap/],66 ['long handler', `SWAP:e:${ME_EVM}:1000`, /market swap/],67 ['limit order', `=<:e:${ME_EVM}:1000`, /market swap/],68 ['no limit', `=:e:${ME_EVM}`, /no limit/],69 ['empty limit', `=:e:${ME_EVM}:`, /plain positive integer/],70 ['zero limit', `=:e:${ME_EVM}:0`, /plain positive integer/],71 ['scientific notation', `=:e:${ME_EVM}:2e6`, /plain positive integer/],72 ['leading zero', `=:e:${ME_EVM}:0123`, /plain positive integer/],73 ['streaming', `=:e:${ME_EVM}:1000/1/1`, /streaming/],74 ['rapid streaming', `=:e:${ME_EVM}:1000/0/5`, /streaming/],75 ['refund override', `=:e:${ME_EVM}/${ME_EVM}:1000`, /refund address/],76 ['affiliate', `=:e:${ME_EVM}:1000:t:10`, /affiliate/],77 ['empty affiliate fields', `=:e:${ME_EVM}:1000::`, /affiliate/],78 ['full notation (not what the wallet writes)', `=:ETH.ETH:${ME_EVM}:1000`, /not pinned/],79 ['unknown short code', `=:r:${ME_EVM}:1000`, /not pinned/],80 ['lower-case token', `=:eth.usdc:${ME_EVM}:1000`, /not pinned/],81 ['destination of another chain', `=:e:${ME_BTC}:1000`, /destination/],82 ['unchecksummed destination', `=:e:${ME_EVM.toLowerCase()}:1000`, /destination/],83 ['THORName destination', '=:e:somethorname:1000', /destination/],84 ['whitespace', `=:e:${ME_EVM}:1000 `, /ASCII/],85 ['non-ASCII', `=:e:${ME_EVM}:1000`, /ASCII/],86 ['too long', `=:e:${ME_EVM}:1${'0'.repeat(240)}`, /ASCII|plain positive/],87 ])('refuses %s', (_l, memo, re) => { expect(() => parseThorMemo(memo)).toThrow(re); });88 it('never writes a limit it cannot write in full', () => {89 expect(() => buildThorMemo(eth, ME_EVM, 0n)).toThrow(); expect(() => buildThorMemo(eth, ME_EVM, -5n)).toThrow();90 expect(() => buildThorMemo(eth, ME_EVM.toLowerCase(), 5n)).toThrow(/normalized/);91 expect(() => buildThorMemo(eth, ME_BTC, 5n)).toThrow();92 });93 it('only pinned assets; pairs need a source that is enabled and two different networks', () => {94 expect(() => thorAsset('ethereum', '0x514910771AF9Ca656af840dff83E8264EcF986CA')).toThrow(); // LINK: no Available pool95 expect(() => thorAsset('base', 'native')).toThrow(); expect(() => thorAsset('solana', 'native')).toThrow();96 expect(thorPairEnabled({ network: 'bitcoin', asset: 'native' }, { network: 'ethereum', asset: USDC })).toBe(true);97 expect(thorPairEnabled({ network: 'ethereum', asset: USDC.toLowerCase() }, { network: 'bitcoin', asset: 'native' })).toBe(true);98 expect(thorPairEnabled({ network: 'dogecoin', asset: 'native' }, { network: 'bitcoin', asset: 'native' })).toBe(false); // destination only99 expect(thorPairEnabled({ network: 'litecoin', asset: 'native' }, { network: 'dogecoin', asset: 'native' })).toBe(true);100 expect(thorPairEnabled({ network: 'ethereum', asset: 'native' }, { network: 'ethereum', asset: USDC })).toBe(false);101 expect(thorPairEnabled({ network: 'bitcoin', asset: 'native' }, { network: 'base', asset: 'native' })).toBe(false);102 });103});104105describe('vault key → address (derived locally, never taken from an API)', () => {106 it('gives the addresses the network publishes for that key', () => {107 for (const [n, a] of Object.entries(VAULT)) expect(thorVaultAddress(VAULT_KEY, n)).toBe(a);108 expect(thorVaultAddress(OTHER_KEY, 'bitcoin')).toBe('bc1qh2aq97wafs7px4jnajnpleu0njaqmque8dku3p');109 expect(thorVaultAddress(OTHER_KEY, 'ethereum').toLowerCase()).toBe('0x6b4068070d95d87b321909bb21c8979fba2b8876');110 });111 it('refuses anything that is not a secp256k1 THORChain key', () => {112 expect(() => thorVaultAddress(VAULT_KEY.slice(0, -1) + 'q', 'bitcoin')).toThrow(); // checksum113 expect(() => thorVaultAddress('mayapub1addwnpepqdskxhlw2esmzy82w', 'bitcoin')).toThrow();114 expect(() => thorVaultAddress(VAULT.bitcoin, 'bitcoin')).toThrow();115 expect(() => thorVaultAddress(VAULT_KEY, 'solana')).toThrow();116 });117});118119// ---- the unanimity rule ----120const view = (o: { height?: number; status?: string; halted?: boolean; paused?: boolean; router?: string | null; dust?: bigint; since?: number; key?: string; chains?: string[]; chain?: string } = {}): ThorView => ({121 height: o.height ?? 28_622_900,122 inbound: [{ chain: o.chain ?? 'BTC', pubKey: OTHER_KEY, address: 'bc1qh2aq97wafs7px4jnajnpleu0njaqmque8dku3p', router: o.router ?? null, halted: !!o.halted, paused: !!o.paused, gasRate: 3n, dustThreshold: o.dust ?? 1000n, outboundFee: 1059n }],123 vaults: [{ pubKey: o.key ?? VAULT_KEY, status: o.status ?? 'ActiveVault', statusSince: o.since ?? 28_203_871, chains: o.chains ?? ['BTC', 'ETH', 'LTC', 'DOGE'], members: 19 }, { pubKey: OTHER_KEY, status: 'ActiveVault', statusSince: 28_203_871, chains: ['BTC', 'ETH'], members: 18 }],124});125const ok = (n: number, v: ThorView = view()): ThorAnswer[] => Array.from({ length: n }, (_x, k) => ({ operator: `op${k}`, view: v }));126const WANT = { chain: 'BTC', vaultPubKey: VAULT_KEY, router: null, amount: 60_000n };127128describe('vault unanimity (the same rule runs in the signer and in the API)', () => {129 it('accepts when every operator that answered confirms, and reports who', () => {130 const v = checkThorVault([...ok(4), { operator: 'down', error: 'timeout' }], WANT);131 expect(v).toMatchObject({ operators: ['op0', 'op1', 'op2', 'op3'], silent: ['down'], statusSince: 28_203_871, gasRate: 3n, dustThreshold: 1000n, members: 19 });132 expect(checkThorVault(ok(3), WANT).operators).toHaveLength(3);133 });134 it('needs three distinct operators, whatever the caller asks for', () => {135 expect(() => checkThorVault([...ok(2), { operator: 'a', error: 'x' }, { operator: 'b', error: 'x' }, { operator: 'c', error: 'x' }], WANT)).toThrow(/only 2 THORNode operator/);136 expect(() => checkThorVault(ok(2), WANT, 1)).toThrow(/only 2/);137 expect(() => checkThorVault([], WANT)).toThrow(/only 0/);138 expect(() => checkThorVault([...ok(2), { operator: 'op0', view: view() }], WANT)).toThrow(/twice/); // one operator cannot vote twice139 });140 it.each([141 ['one lying operator that does not know the vault (an attacker\'s address named by the others would look like this to an honest node)', view({ key: 'thorpub1addwnpepq2dxvtsjwz8de5k96ct7aap2d7ccgjeekr02n0eudcankqa02d89udx7767' }), /does not list this vault/],142 ['an operator that lists it as retiring (churn in progress)', view({ status: 'RetiringVault' }), /RetiringVault/],143 ['an operator that lists it as inactive', view({ status: 'InactiveVault' }), /InactiveVault/],144 ['a stale operator (behind by more than five minutes of blocks)', view({ height: 28_622_900 - 51 }), /blocks behind/],145 ['an operator far ahead of the others (the others then look stale: refuse, never drop)', view({ height: 28_622_900 + 500 }), /blocks behind/],146 ['a halted chain', view({ halted: true }), /halted/],147 ['paused trading', view({ paused: true }), /paused/],148 ['another churn height', view({ since: 28_300_000 }), /churn height/],149 ['a vault that does not serve the chain', view({ chains: ['ETH'] }), /without BTC/],150 ['a dust threshold at the amount', view({ dust: 60_000n }), /dust/],151 ['a router on a chain that has none', view({ router: '0xD37BbE5744D730a1d98d8DC97c42F0Ca46aD7146' }), /router/],152 ['no inbound entry for the chain', view({ chain: 'ETH' }), /no inbound address/],153 ])('refuses on %s, even when four others agree', (_l, bad, re) => {154 expect(() => checkThorVault([...ok(4), { operator: 'odd', view: bad }], WANT)).toThrow(re);155 expect(() => checkThorVault([{ operator: 'odd', view: bad }, ...ok(4)], WANT)).toThrow(re);156 });157 it('checks the pinned router for an EVM source', () => {158 const w = { chain: 'ETH', vaultPubKey: VAULT_KEY, router: ROUTER, amount: 2_000_000n };159 expect(checkThorVault(ok(3, view({ chain: 'ETH', router: ROUTER.toLowerCase() })), w).operators).toHaveLength(3);160 expect(() => checkThorVault(ok(3, view({ chain: 'ETH', router: '0x00dc6100103BC402d490aEE3F9a5560cBd91f1d4' })), w)).toThrow(/another router/);161 expect(() => checkThorVault(ok(3, view({ chain: 'ETH' })), w)).toThrow(/another router/);162 });163 it('proposes the vault most operators name, and parses an operator\'s raw answers strictly', async () => {164 expect(pickThorVault([...ok(3), { operator: 'x', view: { ...view(), inbound: [{ ...view().inbound[0]!, pubKey: VAULT_KEY }] } }], 'BTC')).toBe(OTHER_KEY);165 expect(pickThorVault([{ operator: 'x', error: 'down' }], 'BTC')).toBeNull();166 const answers: Record<string, unknown> = {167 'https://n/thorchain/inbound_addresses': [{ chain: 'BTC', pub_key: VAULT_KEY, address: VAULT.bitcoin, halted: false, global_trading_paused: false, chain_trading_paused: true, gas_rate: '3', dust_threshold: '1000', outbound_fee: '1059' }],168 'https://n/thorchain/vaults/asgard': [{ pub_key: VAULT_KEY, status: 'ActiveVault', status_since: 28_203_871, chains: ['BTC'], membership: ['a', 'b'] }],169 'https://n/thorchain/lastblock': [{ chain: 'BTC', thorchain: 28_622_877 }, { chain: 'ETH', thorchain: 28_622_878 }],170 };171 const v = await fetchThorView('https://n', async (u) => answers[u]);172 expect(v).toMatchObject({ height: 28_622_878, inbound: [{ chain: 'BTC', paused: true, halted: false, gasRate: 3n, router: null }], vaults: [{ status: 'ActiveVault', members: 2 }] });173 await expect(fetchThorView('https://n', async (u) => (u.endsWith('lastblock') ? { height: 1 } : answers[u]))).rejects.toThrow();174 await expect(fetchThorView('https://n', async (u) => (u.endsWith('asgard') ? [] : answers[u]))).rejects.toThrow();175 });176});177178describe('intent', () => {179 it('is part of the approved hash, field by field', () => {180 const a = btcIntent(), h = (i: Intent) => Buffer.from(intentHash(i)).toString('hex');181 expect(h(a)).not.toBe(h({ ...a, xswap: { ...a.xswap!, vault: VAULT.bitcoin.replace('qx', 'qy') } }));182 expect(h(a)).not.toBe(h({ ...a, xswap: { ...a.xswap!, vaultPubKey: OTHER_KEY } }));183 expect(h(a)).not.toBe(h({ ...a, xswap: { ...a.xswap!, memo: a.xswap!.memo + '0' } }));184 expect(h(a)).not.toBe(h({ ...a, xswap: { ...a.xswap!, minOut: '1' } }));185 expect(h(a)).not.toBe(h({ ...a, xswap: { ...a.xswap!, expiry: T0 + 901 } }));186 expect(() => canonicalBytes({ ...a, bridge: { provider: 'cctp', toNetwork: 'base', toDomain: 6, maxFee: '0', minFinalityThreshold: 2000, messenger: 'x'.repeat(20), transmitter: 'x'.repeat(20), approveGas: '0' } } as Intent)).toThrow();187 });188 it('a swap intent is never signed as a plain transfer', () => {189 expect(() => buildBtcTx(btcIntent())).toThrow(/cross-chain swap/);190 expect(() => buildEvmTx(evmIntent())).toThrow(/cross-chain swap/);191 });192 it('accepts the swap it describes', () => {193 const p = verifyXswap(btcIntent(), ME_EVM);194 expect(p).toMatchObject({ amount: 60_000n, amount1e8: 60_000n, limit: 2_098_152n, memo: `=:e:${ME_EVM}:2098152`, want: { chain: 'BTC', vaultPubKey: VAULT_KEY, router: null, amount: 60_000n } });195 expect(verifyXswap(evmIntent(), ME_BTC).want).toEqual({ chain: 'ETH', vaultPubKey: VAULT_KEY, router: ROUTER, amount: 2_000_000n });196 });197 const OTHER_EVM = '0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC';198 it.each<[string, () => Intent, string, RegExp]>([199 ['a memo that pays another address', () => btcIntent({}, { memo: `=:e:${OTHER_EVM}:2098152` }), ME_EVM, /memo does not match/],200 ['a destination that is not the wallet\'s (signer derives its own)', () => btcIntent(), OTHER_EVM, /memo does not match/],201 ['a memo for another asset', () => btcIntent({}, { memo: `=:ETH.USDC:${ME_EVM}:2098152` }), ME_EVM, /memo does not match/],202 ['a memo with a lower limit than committed', () => btcIntent({}, { memo: `=:e:${ME_EVM}:1` }), ME_EVM, /memo does not match/],203 ['a limit below the approved minimum', () => btcIntent({}, { limit: '2098151', memo: `=:e:${ME_EVM}:2098151` }), ME_EVM, /below the approved minimum/],204 ['a limit shortened in scientific notation', () => btcIntent({}, { memo: `=:e:${ME_EVM}:2e6` }), ME_EVM, /memo does not match/],205 ['a streaming memo', () => btcIntent({}, { memo: `=:e:${ME_EVM}:2098152/1/1` }), ME_EVM, /memo does not match/],206 ['an affiliate', () => btcIntent({}, { memo: `=:e:${ME_EVM}:2098152:t:10` }), ME_EVM, /memo does not match/],207 ['a refund override', () => btcIntent({}, { memo: `=:e:${ME_EVM}/${ME_BTC}:2098152` }), ME_EVM, /memo does not match/],208 ['a vault address that is not the address of the vault key', () => btcIntent({ to: 'bc1qh2aq97wafs7px4jnajnpleu0njaqmque8dku3p' }, { vault: 'bc1qh2aq97wafs7px4jnajnpleu0njaqmque8dku3p' }), ME_EVM, /not the address of the committed vault key/],209 ['a transfer that goes elsewhere than the committed vault', () => btcIntent({ to: 'bc1qh2aq97wafs7px4jnajnpleu0njaqmque8dku3p' }), ME_EVM, /does not go to the committed vault/],210 ['a router on a UTXO swap', () => btcIntent({}, { router: ROUTER }), ME_EVM, /no router/],211 ['an unknown destination asset', () => btcIntent({}, { toAsset: '0x514910771AF9Ca656af840dff83E8264EcF986CA' }), ME_EVM, /cannot be swapped/],212 ['a destination-only source', () => btcIntent({ network: 'dogecoin', chainRef: NETWORKS.dogecoin!.chainRef, symbol: 'DOGE', from: VAULT.dogecoin, to: VAULT.dogecoin }), ME_EVM, /cannot be the input/],213 ['the same network on both sides', () => evmIntent({}, { toNetwork: 'ethereum', toAsset: USDC }), ME_EVM, /same network/],214 ['an expiry too far away', () => btcIntent({}, { expiry: T0 + 901 }), ME_EVM, /expiry/],215 ['an expiry before the approval window ends', () => btcIntent({}, { expiry: T0 + 120 }), ME_EVM, /expiry/],216 ['another router', () => evmIntent({}, { router: '0x00dc6100103BC402d490aEE3F9a5560cBd91f1d4' }), ME_BTC, /pinned THORChain router/],217 ['an amount finer than 1e-8', () => evmIntent({ amount: '20000000000000001' }), ME_BTC, /finer/],218 ['a destination tag riding along', () => btcIntent({ memo: '123' }), ME_EVM, /memo in the swap section/],219 ['wrong input metadata', () => btcIntent({ symbol: 'LTC' }), ME_EVM, /metadata/],220 ['another chain identity', () => btcIntent({ chainRef: NETWORKS.litecoin!.chainRef }), ME_EVM, /chain identity/],221 ['the wallet itself as vault', () => { const k = OTHER_KEY; return btcIntent({ from: thorVaultAddress(k, 'bitcoin'), to: thorVaultAddress(k, 'bitcoin') }, { vault: thorVaultAddress(k, 'bitcoin'), vaultPubKey: k }); }, ME_EVM, /wallet itself/],222 ])('refuses %s', (_l, make, dest, re) => { expect(() => verifyXswap(make(), dest)).toThrow(re); });223});224225describe('Bitcoin source transaction', () => {226 it('has exactly three outputs in THORChain\'s order — vault, change to the wallet, OP_RETURN(memo) — and signs', () => {227 const i = btcIntent(), tx = buildXswapBtcTx(i, ME_EVM);228 tx.sign(priv); tx.finalize();229 const back = Transaction.fromRaw(hex.decode(tx.hex), { allowUnknownOutputs: true });230 expect(back.outputsLength).toBe(3); expect(back.inputsLength).toBe(1);231 const P = btcParams(NETWORKS.bitcoin!), o = [0, 1, 2].map((k) => back.getOutput(k));232 expect(hex.encode(o[0]!.script!)).toBe(hex.encode(p2wpkh(hex.decode('021c1ea45af94bbb659f17a01ee448160c4a2d472ea' + 'e8dfb3ab11601db7cfe4d57'), P).script)); // the vault key itself233 expect(o[0]!.amount).toBe(60_000n);234 expect(hex.encode(o[1]!.script!)).toBe(hex.encode(p2wpkh(pub, P).script)); expect(o[1]!.amount).toBe(39_000n);235 expect(o[2]!.amount).toBe(0n);236 const memo = `=:e:${ME_EVM}:2098152`;237 expect(hex.encode(o[2]!.script!)).toBe('6a' + (memo.length).toString(16).padStart(2, '0') + Buffer.from(memo).toString('hex')); // OP_RETURN + one push238 expect(Script.decode(o[2]!.script!)).toHaveLength(2);239 expect(100_000n - o.reduce((s, x) => s + x!.amount!, 0n)).toBe(1000n); // the fee is what the intent says240 expect(back.getInput(0).sequence).toBe(0xfffffffd);241 expect(utxoPaymentTo(tx.hex, 'bitcoin', VAULT.bitcoin)).toEqual({ txid: tx.id, paid: 60_000n, memo });242 });243 it('a 76–80 byte memo uses OP_PUSHDATA1; longer does not fit', () => {244 expect(hex.encode(thorOpReturn('x'.repeat(80))).slice(0, 6)).toBe('6a4c50');245 expect(hex.encode(thorOpReturn('x'.repeat(75))).slice(0, 4)).toBe('6a4b');246 expect(() => thorOpReturn('x'.repeat(81))).toThrow();247 });248 it.each<[string, () => Intent, RegExp]>([249 ['no change (two outputs only)', () => btcIntent({ btc: { inputs: [{ txid: 'ab'.repeat(32), vout: 1, value: '61000' }], change: '0', fee: '1000' } }), /change/],250 ['dust change', () => btcIntent({ btc: { inputs: [{ txid: 'ab'.repeat(32), vout: 1, value: '61100' }], change: '100', fee: '1000' } }), /change/],251 ['a fee above the approved maximum', () => btcIntent({ btc: { inputs: [{ txid: 'ab'.repeat(32), vout: 1, value: '100000' }], change: '37000', fee: '3000' } }), /fee exceeds/],252 ['inputs that do not add up', () => btcIntent({ btc: { inputs: [{ txid: 'ab'.repeat(32), vout: 1, value: '100001' }], change: '39000', fee: '1000' } }), /inputs != outputs/],253 ['a duplicate input', () => btcIntent({ btc: { inputs: [{ txid: 'ab'.repeat(32), vout: 1, value: '50000' }, { txid: 'ab'.repeat(32), vout: 1, value: '50000' }], change: '39000', fee: '1000' } }), /duplicate/],254 ['a source that is not the wallet\'s script type', () => btcIntent({ from: p2pkh(pub, btcParams(NETWORKS.bitcoin!)).address! }), /SegWit/],255 ])('refuses %s', (_l, make, re) => { expect(() => buildXswapBtcTx(make(), ME_EVM)).toThrow(re); });256});257258describe('Ethereum source transaction', () => {259 it('native coin: one call to the pinned router, value = amount, memo and vault exactly as approved', () => {260 const { approve, deposit } = buildXswapEvmTxs(evmIntent(), ME_BTC);261 expect(approve).toBeNull();262 expect(deposit).toMatchObject({ to: ROUTER, value: 20_000_000_000_000_000n, nonce: 7, gas: 80_000n, chainId: 1 });263 const d = decodeFunctionData({ abi: THOR_ROUTER_ABI, data: deposit.data! });264 expect(d.functionName).toBe('depositWithExpiry');265 expect(d.args).toEqual([VAULT.ethereum, '0x0000000000000000000000000000000000000000', 20_000_000_000_000_000n, `=:b:${ME_BTC}:54436`, BigInt(T0 + 900)]);266 });267 it('token: exact-amount approval of the router, then the deposit with no value', () => {268 const i = evmIntent({ asset: USDC, symbol: 'USDC', decimals: 6, amount: '45000000' }, { approveGas: '70000' });269 const { approve, deposit } = buildXswapEvmTxs(i, ME_BTC);270 expect(approve).toMatchObject({ to: USDC, value: 0n, nonce: 7, gas: 70_000n });271 expect(decodeFunctionData({ abi: erc20Abi, data: approve!.data! }).args).toEqual([ROUTER, 45_000_000n]);272 expect(deposit).toMatchObject({ to: ROUTER, value: 0n, nonce: 8 });273 expect(decodeFunctionData({ abi: THOR_ROUTER_ABI, data: deposit.data! }).args).toEqual([VAULT.ethereum, USDC, 45_000_000n, `=:b:${ME_BTC}:54436`, BigInt(T0 + 900)]);274 });275 it.each<[string, () => Intent, RegExp]>([276 ['an approval for the native coin', () => evmIntent({}, { approveGas: '70000' }), /approval gas/],277 ['a fee above the approved maximum', () => evmIntent({ maxFee: '1000' }), /fee exceeds/],278 ['a gas limit out of range', () => evmIntent({ evm: { nonce: 7, gasLimit: '900000', maxFeePerGas: '20000000000', maxPriorityFeePerGas: '1000000000' } }), /gas limit/],279 ['a tip above the max fee', () => evmIntent({ evm: { nonce: 7, gasLimit: '80000', maxFeePerGas: '1', maxPriorityFeePerGas: '2' } }), /priority fee/],280 ])('refuses %s', (_l, make, re) => { expect(() => buildXswapEvmTxs(make(), ME_BTC)).toThrow(re); });281});282283it('spells a transaction id as THORChain does', () => { expect(thorTxId('0xabCDef')).toBe('ABCDEF'); expect(thorTxId('abcd')).toBe('ABCD'); });284