diff --git a/e2e/arbitrum/lbtcUsdt0.spec.ts b/e2e/arbitrum/lbtcUsdt0.spec.ts index 32e5288da..292592895 100644 --- a/e2e/arbitrum/lbtcUsdt0.spec.ts +++ b/e2e/arbitrum/lbtcUsdt0.spec.ts @@ -1,18 +1,30 @@ import type { Page } from "@playwright/test"; +import { oftAbi } from "boltz-swaps/oft"; import { type Address, + type Hex, type PublicClient, + createPublicClient, + createWalletClient, + defineChain, getAddress, + http, + isAddressEqual, parseAbi, + parseEventLogs, parseUnits, } from "viem"; import { config } from "../../src/config"; +import dict from "../../src/i18n/i18n"; import { expect, shouldRunArbitrumE2e, test } from "../fixtures/arbitrum"; +import { injectWalletProvider } from "../fixtures/ethereum"; import { elementsSendToAddress, generateBitcoinBlock, generateLiquidBlock, + getCurrentSwapId, + getLiquidAddress, verifyRescueFile, } from "../utils"; @@ -29,12 +41,37 @@ const erc20Abi = parseAbi([ ]); const lbtcSendAmount = "0.001"; -const quoteRequestTimeout = 60_000; -const quoteReadinessTimeout = 90_000; +const usdt0EthSendAmount = "40"; const swapClaimTimeout = 75_000; const swapClaimTestTimeout = 150_000; +const rpcTimeout = 30_000; +const ethereumRpcReadyTimeout = 15_000; +const quoteRequestTimeout = 10_000; +const quoteReadinessTimeout = 30_000; +const networkSelectorProbeTimeout = 500; +const assetSelectTimeout = 3_000; +const uiQuoteTimeout = 30_000; +const uiActionTimeout = 10_000; +const walletConnectTimeout = 10_000; +const providerModalTimeout = 1_000; +const swapCreateTimeout = 30_000; +const bridgeActionTimeout = 20_000; +const bridgeTxTimeout = 20_000; +const stablesBridgeTestTimeout = 90_000; -const getRegtestTokenAddress = (asset: "USDT0" | "TBTC"): Address => { +const ethereumRpcUrl = () => + `http://127.0.0.1:${process.env.ETHEREUM_E2E_PORT ?? "18546"}`; + +const ethereumE2eChain = defineChain({ + id: 1, + name: "Ethereum E2E", + nativeCurrency: { name: "Ether", symbol: "ETH", decimals: 18 }, + rpcUrls: { default: { http: [ethereumRpcUrl()] } }, +}); + +const getRegtestTokenAddress = ( + asset: "USDT0" | "USDT0-ETH" | "TBTC", +): Address => { const address = config.assets?.[asset]?.token?.address; if (address === undefined) { throw new Error(`missing ${asset} token address`); @@ -43,6 +80,61 @@ const getRegtestTokenAddress = (asset: "USDT0" | "TBTC"): Address => { return getAddress(address); }; +const createEthereumClient = () => { + const rpcUrl = ethereumRpcUrl(); + return createPublicClient({ + chain: { + ...ethereumE2eChain, + rpcUrls: { default: { http: [rpcUrl] } }, + }, + transport: http(rpcUrl, { timeout: rpcTimeout }), + }); +}; + +const waitForEthereumRpc = async (publicClient: PublicClient) => { + await expect + .poll( + async () => { + try { + return await publicClient.getChainId(); + } catch { + return 0; + } + }, + { + timeout: ethereumRpcReadyTimeout, + message: "Ethereum e2e RPC is ready", + }, + ) + .toBe(ethereumE2eChain.id); +}; + +const getStablesE2eAccountIndex = () => { + const index = Number(process.env.STABLES_E2E_ACCOUNT_INDEX ?? "1"); + if (!Number.isInteger(index) || index < 0) { + throw new Error( + "STABLES_E2E_ACCOUNT_INDEX must be a non-negative integer", + ); + } + + return index; +}; + +const getStablesE2eWalletAddress = async ( + publicClient: PublicClient, +): Promise
=> { + const accounts = (await publicClient.request({ + method: "eth_accounts", + params: [], + } as never)) as Address[]; + const walletAddress = accounts[getStablesE2eAccountIndex()]; + if (walletAddress === undefined) { + throw new Error("STABLES_E2E_ACCOUNT_INDEX is not available in Anvil"); + } + + return getAddress(walletAddress); +}; + const waitForDexQuote = async (args: { tokenIn: Address; tokenOut: Address; @@ -80,10 +172,67 @@ const waitForDexQuote = async (args: { throw new Error(`quote not ready for ${args.label}: ${String(lastError)}`); }; +type StoredBridgeSwap = { + bridge?: { txHash?: Hex }; +}; + +const getStoredSwap = async ( + page: Page, + id: string, +): Promise => + await page.evaluate( + async ({ id }) => + await new Promise((resolve, reject) => { + const request = indexedDB.open("swaps"); + request.onerror = () => reject(request.error); + request.onsuccess = () => { + const db = request.result; + const transaction = db.transaction( + "keyvaluepairs", + "readonly", + ); + const getRequest = transaction + .objectStore("keyvaluepairs") + .get(id); + getRequest.onsuccess = () => { + db.close(); + resolve(getRequest.result ?? null); + }; + getRequest.onerror = () => reject(getRequest.error); + }; + }), + { id }, + ); + +const waitForBridgeTxHash = async (page: Page, id: string): Promise => { + await expect + .poll(async () => (await getStoredSwap(page, id))?.bridge?.txHash, { + timeout: bridgeTxTimeout, + }) + .toMatch(/^0x[0-9a-fA-F]{64}$/); + + return (await getStoredSwap(page, id))!.bridge!.txHash!; +}; + +const bridgeCanonicalAsset = (asset: string) => + asset.startsWith("USDT0") ? "USDT0" : asset; + const chooseAsset = async (page: Page, asset: string) => { - await page.getByTestId(`select-${asset}`).click(); + const canonical = bridgeCanonicalAsset(asset); + await page.getByTestId(`select-${canonical}`).click(); + + if ( + canonical !== asset || + (await page + .getByTestId("network-back") + .isVisible({ timeout: networkSelectorProbeTimeout }) + .catch(() => false)) + ) { + await page.getByTestId(`select-${asset}`).click(); + } + await expect(page.locator(".asset-select-overlay")).toBeHidden({ - timeout: 5_000, + timeout: assetSelectTimeout, }); }; @@ -105,7 +254,7 @@ const createSwap = async ( receiveAsset: string, destinationAddress: string, sendAmount: string, - options?: { skipGoto?: boolean }, + options?: { skipGoto?: boolean; walletAddress?: Address }, ) => { if (options?.skipGoto !== true) { await page.goto("/"); @@ -115,17 +264,37 @@ const createSwap = async ( await page.getByTestId("sendAmount").fill(sendAmount); const receiveAmount = page.getByTestId("receiveAmount"); - await expect(receiveAmount).not.toHaveValue("", { timeout: 60_000 }); - await expect(receiveAmount).not.toHaveValue("0", { timeout: 60_000 }); + await expect(receiveAmount).not.toHaveValue("", { + timeout: uiQuoteTimeout, + }); + await expect(receiveAmount).not.toHaveValue("0", { + timeout: uiQuoteTimeout, + }); + + if (options?.walletAddress !== undefined) { + await connectWallet(page, options.walletAddress); + } const createButton = page.getByTestId("create-swap-button"); - await expect(createButton).toBeEnabled({ timeout: 60_000 }); + await expect(createButton).toBeEnabled({ timeout: uiActionTimeout }); await createButton.click(); - await verifyRescueFile(page); - await expect(page.locator("div[data-status='swap.created']")).toBeVisible({ - timeout: 60_000, + const downloadButton = page.getByRole("button", { + name: dict.en.download_new_key, }); + const swapReady = page + .locator("div[data-status='swap.created']") + .or(page.locator("div[data-status='invoice.set']")); + + await expect(swapReady.or(downloadButton)).toBeVisible({ + timeout: swapCreateTimeout, + }); + if (await downloadButton.isVisible().catch(() => false)) { + await verifyRescueFile(page); + } + await expect(swapReady).toBeVisible({ timeout: swapCreateTimeout }); + + return getCurrentSwapId(page); }; const getTokenBalance = async ( @@ -140,6 +309,102 @@ const getTokenBalance = async ( args: [owner], }); +const expectEthereumWalletReady = async ( + publicClient: PublicClient, + owner: Address, +) => { + const token = getRegtestTokenAddress("USDT0-ETH"); + const tokenCode = await publicClient.getCode({ address: token }); + if (tokenCode === undefined || tokenCode === "0x") { + throw new Error("Ethereum e2e fork is missing the USDT0-ETH contract"); + } + + expect(await publicClient.getBalance({ address: owner })).toBeGreaterThan( + 0n, + ); + expect( + await getTokenBalance(publicClient, token, owner), + ).toBeGreaterThanOrEqual(parseUnits(usdt0EthSendAmount, 6)); +}; + +const connectWallet = async (page: Page, walletAddress: Address) => { + const connect = page.getByRole("button", { + name: new RegExp(dict.en.connect_wallet, "i"), + }); + const connectedAddress = page.locator(`text=${walletAddress.slice(0, 8)}`); + + await expect(connectedAddress.or(connect)).toBeVisible({ + timeout: walletConnectTimeout, + }); + + if (await connect.isVisible().catch(() => false)) { + await connect.click(); + + const modal = page.locator("[data-testid='wallet-connect-modal']"); + if ( + await modal + .isVisible({ timeout: providerModalTimeout }) + .catch(() => false) + ) { + await modal + .locator(".provider-modal-entry-wrapper") + .filter({ hasText: /metamask|browser native/i }) + .first() + .click(); + } + } + + await expect(connectedAddress).toBeVisible({ + timeout: walletConnectTimeout, + }); +}; + +const clickSendBridge = async (page: Page, walletAddress: Address) => { + await connectWallet(page, walletAddress); + + const approve = page.getByRole("button", { name: /^approve$/i }); + const send = page.getByRole("button", { name: /^send$/i }); + + await expect(send.or(approve)).toBeVisible({ + timeout: bridgeActionTimeout, + }); + if (await approve.isVisible().catch(() => false)) { + await approve.click(); + } + + await expect(send).toBeEnabled({ timeout: bridgeActionTimeout }); + await send.click(); +}; + +const expectOftSendTx = async ( + publicClient: PublicClient, + txHash: Hex, + walletAddress: Address, +) => { + const transaction = await publicClient.getTransaction({ + hash: txHash, + }); + const receipt = await publicClient.waitForTransactionReceipt({ + hash: txHash, + timeout: bridgeTxTimeout, + }); + expect(receipt.status).toBe("success"); + + const [sent] = parseEventLogs({ + abi: oftAbi, + eventName: "OFTSent", + logs: receipt.logs, + }); + + expect(sent).toBeDefined(); + expect(isAddressEqual(sent.address, getAddress(transaction.to!))).toBe( + true, + ); + expect(isAddressEqual(sent.args.fromAddress, walletAddress)).toBe(true); + expect(sent.args.amountSentLD).toBeGreaterThan(0n); + expect(sent.args.amountReceivedLD).toBeGreaterThan(0n); +}; + describeArbitrumE2e("Arbitrum stablecoin e2e", () => { test.describe.configure({ mode: "serial" }); @@ -200,4 +465,50 @@ describeArbitrumE2e("Arbitrum stablecoin e2e", () => { ); expect(balanceAfter).toBeGreaterThan(balanceBefore); }); + + test("sends USDT0-ETH OFT bridge tx for an L-BTC chain swap", async ({ + page, + }) => { + test.setTimeout(stablesBridgeTestTimeout); + + const ethereum = createEthereumClient(); + const walletAddress = await getStablesE2eWalletAddress(ethereum); + const walletClient = createWalletClient({ + account: walletAddress, + chain: ethereumE2eChain, + transport: http(ethereumRpcUrl(), { timeout: rpcTimeout }), + }); + + await waitForEthereumRpc(ethereum); + await expectEthereumWalletReady(ethereum, walletAddress); + await injectWalletProvider({ + page, + publicClient: ethereum, + walletClient, + chain: ethereumE2eChain, + }); + + await waitForDexQuote({ + tokenIn: getRegtestTokenAddress("USDT0"), + tokenOut: getRegtestTokenAddress("TBTC"), + amountIn: parseUnits("39.996", 6), + label: "USDT0 -> TBTC", + }); + + const swapId = await createSwap( + page, + "USDT0-ETH", + "L-BTC", + await getLiquidAddress(), + usdt0EthSendAmount, + { walletAddress }, + ); + await clickSendBridge(page, walletAddress); + + await expectOftSendTx( + ethereum, + await waitForBridgeTxHash(page, swapId), + walletAddress, + ); + }); }); diff --git a/e2e/fixtures/ethereum.ts b/e2e/fixtures/ethereum.ts index 73cfc0b24..3e6653458 100644 --- a/e2e/fixtures/ethereum.ts +++ b/e2e/fixtures/ethereum.ts @@ -1,5 +1,7 @@ -import { test as base } from "@playwright/test"; +import { type Page, test as base } from "@playwright/test"; import { + type Chain, + type PublicClient, createPublicClient, createWalletClient, defineChain, @@ -20,6 +22,170 @@ type EthereumFixtures = { injectProvider: () => Promise; }; +export const injectWalletProvider = async ({ + page, + publicClient, + walletClient, + chain, +}: { + page: Page; + publicClient: PublicClient; + walletClient: ReturnType; + chain: Chain; +}) => { + await page.exposeFunction( + "__rpc", + async (method: string, params: unknown[]) => { + return await publicClient.request({ + method, + params, + } as never); + }, + ); + + await page.exposeFunction( + "__sendTx", + async (tx: { + to?: string; + value?: string; + data?: string; + gas?: string; + gasPrice?: string; + maxFeePerGas?: string; + maxPriorityFeePerGas?: string; + nonce?: string; + }) => { + return await walletClient.sendTransaction({ + account: walletClient.account, + chain, + to: tx.to as `0x${string}` | undefined, + value: tx.value ? BigInt(tx.value) : undefined, + data: tx.data as `0x${string}` | undefined, + gas: tx.gas ? BigInt(tx.gas) : undefined, + gasPrice: tx.gasPrice ? BigInt(tx.gasPrice) : undefined, + maxFeePerGas: tx.maxFeePerGas + ? BigInt(tx.maxFeePerGas) + : undefined, + maxPriorityFeePerGas: tx.maxPriorityFeePerGas + ? BigInt(tx.maxPriorityFeePerGas) + : undefined, + nonce: tx.nonce ? Number(tx.nonce) : undefined, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any); + }, + ); + + const walletAddress = walletClient.account!.address; + const chainIdHex = "0x" + chain.id.toString(16); + + await page.addInitScript( + ({ addr, chainId }) => { + const listeners: Record< + string, + Array<(...args: unknown[]) => void> + > = {}; + + const provider = { + isMetaMask: true, + isConnected: () => true, + chainId, + selectedAddress: addr, + + request: async ({ + method, + params, + }: { + method: string; + params?: unknown[]; + }) => { + switch (method) { + case "eth_requestAccounts": + case "eth_accounts": + return [addr]; + case "eth_chainId": + return chainId; + case "net_version": + return String(parseInt(chainId, 16)); + case "wallet_switchEthereumChain": + setTimeout(() => { + (listeners["chainChanged"] || []).forEach( + (cb) => cb(chainId), + ); + }, 100); + return null; + case "wallet_addEthereumChain": + return null; + case "eth_sendTransaction": + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-return + return await (window as any).__sendTx( + (params as unknown[])[0], + ); + default: + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-return + return await (window as any).__rpc( + method, + params || [], + ); + } + }, + + send: (method: string, params?: unknown[]) => + provider.request({ method, params }), + + on: (event: string, callback: (...args: unknown[]) => void) => { + if (!listeners[event]) listeners[event] = []; + listeners[event].push(callback); + }, + removeListener: ( + event: string, + callback: (...args: unknown[]) => void, + ) => { + if (listeners[event]) { + listeners[event] = listeners[event].filter( + (cb) => cb !== callback, + ); + } + }, + removeAllListeners: (event?: string) => { + if (event) { + delete listeners[event]; + } else { + Object.keys(listeners).forEach( + (key) => delete listeners[key], + ); + } + }, + }; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (window as any).ethereum = provider; + + const announceProvider = () => { + window.dispatchEvent( + new CustomEvent("eip6963:announceProvider", { + detail: Object.freeze({ + info: { + uuid: "playwright-mock", + name: "MetaMask", + icon: "data:image/svg+xml,", + rdns: "io.metamask", + }, + provider, + }), + }), + ); + }; + + window.addEventListener( + "eip6963:requestProvider", + announceProvider, + ); + announceProvider(); + }, + { addr: walletAddress, chainId: chainIdHex }, + ); +}; + export const test = base.extend({ // eslint-disable-next-line no-empty-pattern walletClient: async ({}, use) => { @@ -54,148 +220,12 @@ export const test = base.extend({ transport: http(), }); - await page.exposeFunction( - "__rpc", - async (method: string, params: unknown[]) => { - return await publicClient.request({ - method, - params, - } as never); - }, - ); - - await page.exposeFunction( - "__sendTx", - async (tx: { - to?: string; - value?: string; - data?: string; - gas?: string; - }) => { - return await walletClient.sendTransaction({ - account: walletClient.account, - chain: rskRegtest, - to: tx.to as `0x${string}`, - value: tx.value ? BigInt(tx.value) : undefined, - data: tx.data as `0x${string}`, - gas: tx.gas ? BigInt(tx.gas) : undefined, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } as any); - }, - ); - - const walletAddress = walletClient.account!.address; - const chainIdHex = "0x" + rskRegtest.id.toString(16); - - await page.addInitScript( - ({ addr, chainId }) => { - const listeners: Record< - string, - Array<(...args: unknown[]) => void> - > = {}; - - const provider = { - isMetaMask: true, - isConnected: () => true, - chainId, - selectedAddress: addr, - - request: async ({ - method, - params, - }: { - method: string; - params?: unknown[]; - }) => { - switch (method) { - case "eth_requestAccounts": - case "eth_accounts": - return [addr]; - case "eth_chainId": - return chainId; - case "net_version": - return String(parseInt(chainId, 16)); - case "wallet_switchEthereumChain": - setTimeout(() => { - ( - listeners["chainChanged"] || [] - ).forEach((cb) => cb(chainId)); - }, 100); - return null; - case "wallet_addEthereumChain": - return null; - case "eth_sendTransaction": - // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-return - return await (window as any).__sendTx( - (params as unknown[])[0], - ); - default: - // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-return - return await (window as any).__rpc( - method, - params || [], - ); - } - }, - - send: (method: string, params?: unknown[]) => - provider.request({ method, params }), - - on: ( - event: string, - callback: (...args: unknown[]) => void, - ) => { - if (!listeners[event]) listeners[event] = []; - listeners[event].push(callback); - }, - removeListener: ( - event: string, - callback: (...args: unknown[]) => void, - ) => { - if (listeners[event]) { - listeners[event] = listeners[event].filter( - (cb) => cb !== callback, - ); - } - }, - removeAllListeners: (event?: string) => { - if (event) { - delete listeners[event]; - } else { - Object.keys(listeners).forEach( - (key) => delete listeners[key], - ); - } - }, - }; - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (window as any).ethereum = provider; - - const announceProvider = () => { - window.dispatchEvent( - new CustomEvent("eip6963:announceProvider", { - detail: Object.freeze({ - info: { - uuid: "playwright-mock", - name: "MetaMask", - icon: "data:image/svg+xml,", - rdns: "io.metamask", - }, - provider, - }), - }), - ); - }; - - window.addEventListener( - "eip6963:requestProvider", - announceProvider, - ); - announceProvider(); - }, - { addr: walletAddress, chainId: chainIdHex }, - ); + await injectWalletProvider({ + page, + publicClient, + walletClient, + chain: rskRegtest, + }); }; await use(inject); diff --git a/src/components/Fees.tsx b/src/components/Fees.tsx index aa9d84cfb..958ec3181 100644 --- a/src/components/Fees.tsx +++ b/src/components/Fees.tsx @@ -183,6 +183,13 @@ const Fees = () => { } const initiatingPair = pair(); + if (initiatingPair.needsNetworkForQuote) { + setMinimum(0); + setMaximum(Number.MAX_SAFE_INTEGER); + setLimitsLoading(false); + return; + } + setLimitsLoading(true); void Promise.all([ initiatingPair.getMinimum(), diff --git a/src/components/LockupEvm.tsx b/src/components/LockupEvm.tsx index 9800b60bf..f4170d854 100644 --- a/src/components/LockupEvm.tsx +++ b/src/components/LockupEvm.tsx @@ -588,6 +588,7 @@ const LockupEvm = (props: { timeoutBlockHeight: number; hops?: EncodedHop[]; bridge?: BridgeDetail; + bridgeAmount?: bigint; }) => { const { slippage } = useGlobalContext(); const { getErc20Swap, signer } = useWeb3Signer(); @@ -623,6 +624,11 @@ const LockupEvm = (props: { createEffect(() => { void (async () => { if (props.bridge !== undefined) { + if (props.bridgeAmount !== undefined) { + setBridgeValue(props.bridgeAmount); + return; + } + if (!hasHopsBefore() || props.hops === undefined) { throw new Error( `bridge swap ${props.swapId} is missing a lockup-side DEX hop`, diff --git a/src/configs/regtest.ts b/src/configs/regtest.ts index e0f69eddc..6a2bd5acd 100644 --- a/src/configs/regtest.ts +++ b/src/configs/regtest.ts @@ -3,9 +3,11 @@ import { AssetKind, Explorer, NetworkTransport } from "boltz-swaps/types"; import { type Config, baseConfig, chooseUrl } from "src/configs/base"; const mainnetPreset = buildMainnetConfig({ - filterAssets: (asset) => asset === "TBTC" || asset === "USDT0", + filterAssets: (asset) => + asset === "TBTC" || asset === "USDT0" || asset === "USDT0-ETH", rpcUrls: { ARB: ["http://127.0.0.1:18545"], + ETH: ["http://127.0.0.1:18546"], }, }); @@ -18,6 +20,7 @@ const stablecoins = { }, }, USDT0: mainnetPreset.assets.USDT0, + "USDT0-ETH": mainnetPreset.assets["USDT0-ETH"], }; const config = { diff --git a/src/status/InvoiceSet.tsx b/src/status/InvoiceSet.tsx index 4185a70b1..e0eef3fa1 100644 --- a/src/status/InvoiceSet.tsx +++ b/src/status/InvoiceSet.tsx @@ -11,6 +11,7 @@ import { type SubmarineSwap, getLockupGasAbstraction, getPreBridgeDetail, + getPreDexQuoteAmount, } from "../utils/swapCreator"; const InvoiceSet = () => { @@ -48,6 +49,7 @@ const InvoiceSet = () => { : undefined } bridge={getPreBridgeDetail(submarine.bridge)} + bridgeAmount={getPreDexQuoteAmount(submarine.dex)} /> ); diff --git a/src/status/SwapCreated.tsx b/src/status/SwapCreated.tsx index 662877045..ccf91d1d7 100644 --- a/src/status/SwapCreated.tsx +++ b/src/status/SwapCreated.tsx @@ -14,6 +14,7 @@ import { type ReverseSwap, getLockupGasAbstraction, getPreBridgeDetail, + getPreDexQuoteAmount, } from "../utils/swapCreator"; const SwapCreated = () => { @@ -59,6 +60,7 @@ const SwapCreated = () => { : undefined } bridge={getPreBridgeDetail(chain.bridge)} + bridgeAmount={getPreDexQuoteAmount(chain.dex)} /> diff --git a/src/utils/swapCreator.ts b/src/utils/swapCreator.ts index 725f14c96..2bb034ca0 100644 --- a/src/utils/swapCreator.ts +++ b/src/utils/swapCreator.ts @@ -222,6 +222,16 @@ export const getPostBridgeDetail = ( ): BridgeDetail | undefined => bridge?.position === SwapPosition.Post ? bridge : undefined; +export const getPreDexQuoteAmount = (dex?: DexDetail): bigint | undefined => { + if (dex?.position !== SwapPosition.Pre) { + return undefined; + } + + return typeof dex.quoteAmount === "string" + ? BigInt(dex.quoteAmount) + : BigInt(Math.round(dex.quoteAmount)); +}; + const generatePreimage = ({ asset, keyIndex, diff --git a/tests/utils/swapCreator.spec.ts b/tests/utils/swapCreator.spec.ts index 75a067886..a327f18dc 100644 --- a/tests/utils/swapCreator.spec.ts +++ b/tests/utils/swapCreator.spec.ts @@ -3,11 +3,13 @@ import { BridgeKind, SwapPosition, SwapType } from "boltz-swaps/types"; import { BTC, LBTC, LN, USDT0 } from "../../src/consts/Assets"; import { type BridgeDetail, + type DexDetail, type SwapBase, getFinalAssetReceive, getFinalAssetSend, getPostBridgeDetail, getPreBridgeDetail, + getPreDexQuoteAmount, noGasAbstraction, } from "../../src/utils/swapCreator"; @@ -22,6 +24,13 @@ const makeBridge = ( position, }); +const makeDex = (position: SwapPosition, quoteAmount: number | string) => + ({ + hops: [], + position, + quoteAmount, + }) as DexDetail; + // Minimal SwapBase builder — only fields read by the tested helpers matter. const makeSwap = (overrides: Partial): SwapBase => ({ @@ -68,6 +77,25 @@ describe("getPostBridgeDetail", () => { }); }); +describe("getPreDexQuoteAmount", () => { + test("returns the quote amount for pre-DEX details", () => { + expect( + getPreDexQuoteAmount(makeDex(SwapPosition.Pre, "40000000")), + ).toBe(40000000n); + }); + + test("rounds legacy numeric quote amounts", () => { + expect(getPreDexQuoteAmount(makeDex(SwapPosition.Pre, 1.6))).toBe(2n); + }); + + test("returns undefined for post-DEX or missing details", () => { + expect( + getPreDexQuoteAmount(makeDex(SwapPosition.Post, "40000000")), + ).toBeUndefined(); + expect(getPreDexQuoteAmount(undefined)).toBeUndefined(); + }); +}); + describe("getFinalAssetSend", () => { test("returns bridge.sourceAsset when a pre-bridge is attached", () => { const swap = makeSwap({