SPB Git

Repository Public

thorchain.test.ts 24.6 KB 284 lines typescript

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
Files main
Menu

Theme