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", () => {