diff --git a/e2e/chainSwaps/zeroAmount.spec.ts b/e2e/chainSwaps/zeroAmount.spec.ts new file mode 100644 index 00000000..9ef96d29 --- /dev/null +++ b/e2e/chainSwaps/zeroAmount.spec.ts @@ -0,0 +1,51 @@ +import { expect, test } from "@playwright/test"; + +import { getBitcoinAddress } from "../utils"; + +test.describe("Chain Swap 0-amount", () => { + test("should allow 0-amount chain swaps", async ({ page }) => { + await page.goto("/"); + + const assetSelector = page.locator("div[class='asset asset-LN'] div"); + await assetSelector.click(); + + await page.locator("div[data-testid='select-L-BTC']").click(); + + const inputOnchainAddress = page.locator( + "input[data-testid='onchainAddress']", + ); + await inputOnchainAddress.fill(await getBitcoinAddress()); + + const buttonCreateSwap = page.locator( + "button[data-testid='create-swap-button']", + ); + await buttonCreateSwap.click(); + + const skipDownload = page.getByText("Skip download"); + await skipDownload.click(); + }); + + test("should not allow 0-amount chain swaps when sending RBTC", async ({ + page, + }) => { + await page.goto("/"); + + const assetSelector = page.locator("div[class='asset asset-LN'] div"); + await assetSelector.click(); + + await page.locator("div[data-testid='select-RBTC']").click(); + + const inputOnchainAddress = page.locator( + "input[data-testid='onchainAddress']", + ); + await inputOnchainAddress.fill(await getBitcoinAddress()); + + const buttonCreateSwap = page.locator( + "button[data-testid='create-swap-button']", + ); + const createButton = await buttonCreateSwap.elementHandle(); + await expect(createButton.getAttribute("disabled")).resolves.toEqual( + "", + ); + }); +}); diff --git a/src/components/AddressInput.tsx b/src/components/AddressInput.tsx index 5562e030..6f9cc9b5 100644 --- a/src/components/AddressInput.tsx +++ b/src/components/AddressInput.tsx @@ -63,10 +63,11 @@ const AddressInput = () => { break; } } catch (e) { - log.debug(`Invalid address input: ${formatError(e)}`); - setAddressValid(false); + if (inputValue.length !== 0) { + log.debug(`Invalid address input: ${formatError(e)}`); + const msg = t("invalid_address", { asset: assetReceive() }); input.classList.add("invalid"); input.setCustomValidity(msg); diff --git a/src/components/CreateButton.tsx b/src/components/CreateButton.tsx index 242d74c7..90aa573d 100644 --- a/src/components/CreateButton.tsx +++ b/src/components/CreateButton.tsx @@ -108,7 +108,16 @@ export const CreateButton = () => { setButtonLabel({ key: "invalid_pair" }); return; } - if (!amountValid()) { + if ( + !amountValid() && + // Chain swaps with 0-amount that do not have RBTC as sending asset + // can skip this check + !( + swapType() === SwapType.Chain && + assetSend() !== RBTC && + sendAmount().isZero() + ) + ) { const lessThanMin = Number(sendAmount()) < minimum(); setButtonLabel({ key: lessThanMin ? "minimum_amount" : "maximum_amount", diff --git a/src/components/PayInvoice.tsx b/src/components/PayInvoice.tsx index 6b21ae08..59b301d4 100644 --- a/src/components/PayInvoice.tsx +++ b/src/components/PayInvoice.tsx @@ -5,9 +5,8 @@ import { Show } from "solid-js"; import CopyButton from "../components/CopyButton"; import QrCode from "../components/QrCode"; import { BTC } from "../consts/Assets"; -import { Denomination } from "../consts/Enums"; import { useGlobalContext } from "../context/Global"; -import { formatAmount } from "../utils/denomination"; +import { formatAmount, formatDenomination } from "../utils/denomination"; import { clipboard, cropString, isMobile } from "../utils/helper"; import { invoicePrefix } from "../utils/invoice"; import { enableWebln } from "../utils/webln"; @@ -30,8 +29,7 @@ const PayInvoice = (props: { sendAmount: number; invoice: string }) => { denomination(), separator(), ), - denomination: - denomination() === Denomination.Sat ? "sats" : BTC, + denomination: formatDenomination(denomination(), BTC), })}
diff --git a/src/components/PayOnchain.tsx b/src/components/PayOnchain.tsx index 061d20f3..822e19d2 100644 --- a/src/components/PayOnchain.tsx +++ b/src/components/PayOnchain.tsx @@ -1,73 +1,121 @@ import { BigNumber } from "bignumber.js"; -import { Show } from "solid-js"; +import { Show, createMemo, createResource } from "solid-js"; import CopyButton from "../components/CopyButton"; import QrCode from "../components/QrCode"; import { BTC } from "../consts/Assets"; -import { Denomination } from "../consts/Enums"; +import { SwapType } from "../consts/Enums"; import { useGlobalContext } from "../context/Global"; -import { formatAmount } from "../utils/denomination"; -import { clipboard, cropString, isMobile } from "../utils/helper"; +import { getPairs } from "../utils/boltzClient"; +import { formatAmount, formatDenomination } from "../utils/denomination"; +import { clipboard, cropString, getPair, isMobile } from "../utils/helper"; +import LoadingSpinner from "./LoadingSpinner"; const PayOnchain = (props: { - asset: string; + type: SwapType; + assetSend: string; + assetReceive: string; expectedAmount: number; address: string; bip21: string; }) => { - const { t, denomination, separator } = useGlobalContext(); + const { t, denomination, separator, setPairs, pairs } = useGlobalContext(); + + const [pairsFetch] = createResource(async () => { + if (pairs() !== undefined) { + return pairs(); + } + + const p = await getPairs(); + setPairs(p); + return p; + }); + + const headerText = createMemo(() => { + const denom = formatDenomination(denomination(), props.assetSend); + + if (props.expectedAmount > 0) { + return t("send_to", { + denomination: denom, + amount: formatAmount( + BigNumber(props.expectedAmount), + denomination(), + separator(), + ), + }); + } + + if (pairs() === undefined) { + return ""; + } + + const pair = getPair( + pairs(), + props.type, + props.assetSend, + props.assetReceive, + ); + return t("send_between", { + denomination: denom, + min: formatAmount( + BigNumber(pair.limits.minimal), + denomination(), + separator(), + ), + max: formatAmount( + BigNumber(pair.limits.maximal), + denomination(), + separator(), + ), + }); + }); return ( -
-

- {t("send_to", { - amount: formatAmount( - BigNumber(props.expectedAmount), - denomination(), - separator(), - ), - denomination: - denomination() === Denomination.Sat - ? "sats" - : props.asset, - })} -

-
- - - -
-

clipboard(props.address)} - class="address-box break-word"> - {cropString(props.address)} -

- -
-

{t("warning_expiry")}

-
- + }> +
+

{headerText()}


- - {t("open_in_wallet")} + + - -
-
- - formatAmount( - BigNumber(props.expectedAmount), - denomination(), - separator(), - ) - } - /> - - +
+

clipboard(props.address)} + class="address-box break-word"> + {cropString(props.address)} +

+ +
+

{t("warning_expiry")}

+
+ +
+ + {t("open_in_wallet")} + +
+
+
+ 0}> + + formatAmount( + BigNumber(props.expectedAmount), + denomination(), + separator(), + ) + } + /> + + + + +
-
+
); }; diff --git a/src/i18n/i18n.ts b/src/i18n/i18n.ts index 86a3228d..b29ac3b2 100644 --- a/src/i18n/i18n.ts +++ b/src/i18n/i18n.ts @@ -60,6 +60,8 @@ const dict = { pay_timeout_blockheight: "Timeout block height", pay_expected_amount: "Expected amount", send_to: "Send {{ amount }} {{ denomination }} to", + send_between: + "Send between {{ min }} and {{ max }} {{ denomination }} to", pay_invoice_to: "Pay this invoice about {{ amount }} {{ denomination }}", pay_address: "Address", @@ -284,6 +286,8 @@ const dict = { pay_timeout_blockheight: "Timeout Blockhöhe", pay_expected_amount: "Erwarteter Betrag", send_to: "Sende {{ amount }} {{ denomination }} an", + send_between: + "Sende zwischen {{ min }} und {{ max }} {{ denomination }} an", pay_invoice_to: "Zahle diese Rechnung über {{ amount }} {{ denomination }}", pay_address: "Adresse", @@ -517,6 +521,7 @@ const dict = { pay_timeout_blockheight: "Altura del bloque de tiempo de espera", pay_expected_amount: "Importe esperado", send_to: "Enviar {{ amount }} {{ denomination }} a", + send_between: "Enviar entre {{ min }} y {{ max }} {{ denomination }} a", pay_invoice_to: "Pague esta factura de {{ amount }} {{ denomination }}", pay_address: "Dirección", no_wallet: "Monedero no está instalado", @@ -746,6 +751,7 @@ const dict = { pay_expected_amount: "预期金额", send_to: "请确保将准确的金额{{ amount }}{{ denomination }}发送到下面显示的地址。否则,交换将失败。最好使用“复制BIP21”按钮,并将其粘贴到您钱包的发送屏幕中。", + send_between: "在 {{ min }} 和 {{ max }} {{ denomination }} 之间发送至", pay_invoice_to: "支付金额为{{ amount }}{{ denomination }}的发票", pay_address: "地址", no_wallet: "未安装钱包", @@ -954,6 +960,8 @@ const dict = { pay_expected_amount: "予想金額", send_to: "{{ amount }} {{ denomination }} を以下のアドレスへ送金して下さい", + send_between: + "{{ min }} から {{ max }} {{ denomination }} を送信してください", pay_invoice_to: "このインボイスを支払う {{ amount }} {{ denomination }}", pay_address: "アドレス", diff --git a/src/pages/Create.tsx b/src/pages/Create.tsx index c72ec72a..7d6663c2 100644 --- a/src/pages/Create.tsx +++ b/src/pages/Create.tsx @@ -195,6 +195,15 @@ const Create = () => { setCustomValidity("", false); const amount = Number(sendAmount()); + if ( + swapType() === SwapType.Chain && + assetSend() !== RBTC && + amount === 0 + ) { + setAmountValid(true); + return; + } + const lessThanMin = amount < minimum(); if (lessThanMin || amount > maximum()) { diff --git a/src/pages/Hero.tsx b/src/pages/Hero.tsx index a415fbb9..c17c3db2 100644 --- a/src/pages/Hero.tsx +++ b/src/pages/Hero.tsx @@ -7,12 +7,16 @@ import bitcoin from "../assets/bitcoin-icon.svg"; import lightning from "../assets/lightning-icon.svg"; import liquid from "../assets/liquid-icon.svg"; import rbtc from "../assets/rootstock-icon.svg"; +import { BTC } from "../consts/Assets"; import { Denomination } from "../consts/Enums"; import { useGlobalContext } from "../context/Global"; import Create from "../pages/Create"; import "../style/hero.scss"; import { getNodeStats } from "../utils/boltzClient"; -import { formatAmountDenomination } from "../utils/denomination"; +import { + formatAmountDenomination, + formatDenomination, +} from "../utils/denomination"; export const Hero = () => { const navigate = useNavigate(); @@ -102,10 +106,10 @@ export const Hero = () => { {formatStatsAmount(capacity(), denomination())}{" "} {t("capacity", { - denomination: - denomination() === Denomination.Sat - ? "sats" - : "BTC", + denomination: formatDenomination( + denomination(), + BTC, + ), })}
diff --git a/src/status/InvoiceSet.tsx b/src/status/InvoiceSet.tsx index 43f012ad..98517ee3 100644 --- a/src/status/InvoiceSet.tsx +++ b/src/status/InvoiceSet.tsx @@ -6,7 +6,7 @@ import PayOnchain from "../components/PayOnchain"; import { RBTC } from "../consts/Assets"; import { usePayContext } from "../context/Pay"; import { decodeInvoice } from "../utils/invoice"; -import { SubmarineSwap, getRelevantAssetForSwap } from "../utils/swapCreator"; +import { SubmarineSwap } from "../utils/swapCreator"; const InvoiceSet = () => { const { swap } = usePayContext(); @@ -21,7 +21,9 @@ const InvoiceSet = () => { when={submarine.assetSend === RBTC} fallback={ { when={chain.assetSend === RBTC} fallback={ + denom === Denomination.Sat ? "sats" : asset; + export const convertAmount = (amount: BigNumber, denom: string): BigNumber => { switch (denom) { case Denomination.Btc: diff --git a/src/utils/helper.ts b/src/utils/helper.ts index 6cadbd49..cacfe866 100644 --- a/src/utils/helper.ts +++ b/src/utils/helper.ts @@ -68,6 +68,8 @@ export const getPair = < assetSend: string, assetReceive: string, ): T | undefined => { + if (pairs === undefined) return undefined; + const pairSwapType = pairs[swapType]; if (pairSwapType === undefined) return undefined; const pairAssetSend = pairSwapType[coalesceLn(assetSend)]; diff --git a/src/utils/swapCreator.ts b/src/utils/swapCreator.ts index e4c4752e..6a097a46 100644 --- a/src/utils/swapCreator.ts +++ b/src/utils/swapCreator.ts @@ -168,7 +168,9 @@ export const createChain = async ( const res = await createChainSwap( assetSend, assetReceive, - Number(sendAmount), + sendAmount.isZero() || sendAmount.isNaN() + ? undefined + : Number(sendAmount), crypto.sha256(preimage).toString("hex"), claimKeys?.publicKey.toString("hex"), refundKeys?.publicKey.toString("hex"), diff --git a/src/utils/validation.ts b/src/utils/validation.ts index 57f513ee..d9de91c8 100644 --- a/src/utils/validation.ts +++ b/src/utils/validation.ts @@ -85,8 +85,14 @@ const validateBip21 = ( return false; } + const params = new URLSearchParams(bip21Split[1]); + + if (expectedAmount === 0) { + return !params.has("amount"); + } + return ( - new URLSearchParams(bip21Split[1]).get("amount") === + params.get("amount") === formatAmountDenomination( BigNumber(expectedAmount), Denomination.Btc, @@ -218,11 +224,14 @@ const validateChainSwap = async ( details: ChainSwapDetails, ) => { if (side === Side.Send) { - if (details.amount !== swap.sendAmount) { + if (swap.sendAmount > 0 && details.amount !== swap.sendAmount) { return false; } } else { - if (details.amount <= swap.receiveAmount) { + if ( + swap.receiveAmount > 0 && + details.amount <= swap.receiveAmount + ) { return false; } } diff --git a/tests/components/PayOnchain.spec.tsx b/tests/components/PayOnchain.spec.tsx index 66cbb804..42bb5832 100644 --- a/tests/components/PayOnchain.spec.tsx +++ b/tests/components/PayOnchain.spec.tsx @@ -2,8 +2,9 @@ import { fireEvent, render, screen } from "@solidjs/testing-library"; import PayOnchain from "../../src/components/PayOnchain"; import { BTC } from "../../src/consts/Assets"; -import { Denomination } from "../../src/consts/Enums"; +import { Denomination, SwapType } from "../../src/consts/Enums"; import { TestComponent, contextWrapper, globalSignals } from "../helper"; +import { pairs } from "../pairs"; /* eslint-disable @typescript-eslint/unbound-method */ @@ -16,7 +17,9 @@ describe("PayOnchain", () => { <> { wrapper: contextWrapper, }, ); + globalSignals.setPairs(pairs); // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-expect-error diff --git a/tests/utils/denomination.spec.ts b/tests/utils/denomination.spec.ts index f6197d30..29cba9ab 100644 --- a/tests/utils/denomination.spec.ts +++ b/tests/utils/denomination.spec.ts @@ -1,10 +1,12 @@ import { BigNumber } from "bignumber.js"; +import { BTC, LBTC } from "../../src/consts/Assets"; import { Denomination } from "../../src/consts/Enums"; import { calculateDigits, convertAmount, formatAmount, + formatDenomination, getValidationRegex, } from "../../src/utils/denomination"; @@ -94,4 +96,14 @@ describe("denomination utils", () => { expect(regex.test(amount)).toEqual(valid); }); }); + + test.each` + denomination | input | expected + ${Denomination.Sat} | ${BTC} | ${"sats"} + ${Denomination.Sat} | ${LBTC} | ${"sats"} + ${Denomination.Btc} | ${BTC} | ${BTC} + ${Denomination.Btc} | ${LBTC} | ${LBTC} + `("should format denomination", ({ denomination, input, expected }) => { + expect(formatDenomination(denomination, input)).toEqual(expected); + }); }); diff --git a/tests/utils/validation.spec.ts b/tests/utils/validation.spec.ts index b398edd6..4a7ac8fc 100644 --- a/tests/utils/validation.spec.ts +++ b/tests/utils/validation.spec.ts @@ -232,6 +232,76 @@ describe("validate responses", () => { }, ); }); + + describe("Chain Swap", () => { + const zeroAmount = { + referralId: "boltz_webapp_desktop", + id: "eFoBgGFfDHo4", + claimDetails: { + serverPublicKey: + "0271067a9bbccfe069d924c03e87ef1532fa53e8af9995ac1a450ce395dd368593", + amount: 996479, + lockupAddress: + "bcrt1pfw5r4mufen4faygxzhmez5qzzqsd7e826q3zxl6kmawlf9gjfhwqzf6fsn", + timeoutBlockHeight: 296, + swapTree: { + claimLeaf: { + version: 192, + output: "82012088a914dce766e849984cb5499623d7d8851a34f895f6a08820cceea6857e5f8dcd4f44d26adc49473d6d7b51e059d5eea0dd936eaeae383450ac", + }, + refundLeaf: { + version: 192, + output: "2071067a9bbccfe069d924c03e87ef1532fa53e8af9995ac1a450ce395dd368593ad022801b1", + }, + }, + }, + lockupDetails: { + blindingKey: + "259b78561186fbad872025f0bf62b8a410fcb4f45f5d3b1ea6606fdff9191156", + serverPublicKey: + "026f9151f5a85c9215ffadc7786c6b4c21cf1195aef8bc7fad51307ddaaa433a85", + amount: 0, + lockupAddress: + "el1pqtx5366l50zk6zrs02sulma5zqqzm0ft9sa8w8gt403c8daynl33ydjxfwusv5ucqgd538qwktl5p7ne7aruhuan497ctusqpypq6c8xt8sl7vgfpkj8", + timeoutBlockHeight: 1853, + swapTree: { + claimLeaf: { + version: 196, + output: "82012088a914dce766e849984cb5499623d7d8851a34f895f6a088206f9151f5a85c9215ffadc7786c6b4c21cf1195aef8bc7fad51307ddaaa433a85ac", + }, + refundLeaf: { + version: 196, + output: "2052c3bd8caf5e574317e63e5a6fc1e70f55cba2207be95f3074891c3afcb19637ad023d07b1", + }, + }, + bip21: "liquidnetwork:el1pqtx5366l50zk6zrs02sulma5zqqzm0ft9sa8w8gt403c8daynl33ydjxfwusv5ucqgd538qwktl5p7ne7aruhuan497ctusqpypq6c8xt8sl7vgfpkj8?label=Send%20to%20BTC%20address&assetid=5ac9f65c0efcc4775e0baec4ec03abdde22473cd3cf33c0419ca290e0751b225", + }, + type: SwapType.Chain, + useRif: false, + assetSend: "L-BTC", + assetReceive: "BTC", + date: 1731666190381, + version: 3, + sendAmount: 0, + receiveAmount: 995035, + claimAddress: "bcrt1qlc26kc7s6gu94za4ajf95n42ra2t90cu89pnrn", + preimage: + "fb3c3e61382685211dd5f03e35b4d0df9fbfddb4059a8d2dfaae7f580f001d8c", + claimPrivateKey: + "9ed53293ce06b679796035026b419d6f8d8732336b4908000ca51253bb4d21eb", + refundPrivateKey: + "297be239304392970158ba6dea46b058b868ac7e797ce8a0931d2ac78043465e", + status: "transaction.claimed", + claimTx: + "ab466d2ec7150d37fc487bc9e9670fb871048c0cceac2debc430fe75d9bc242f", + }; + + test("should validate with 0-amount", async () => { + await expect( + validateResponse(zeroAmount, {} as never, Buffer), + ).resolves.toEqual(true); + }); + }); }); describe("validate invoices", () => {