diff --git a/.prettierrc b/.prettierrc index 773c52b0..038fa7cb 100644 --- a/.prettierrc +++ b/.prettierrc @@ -4,5 +4,6 @@ "importOrderSeparation": true, "importOrderSortSpecifiers": true, "tabWidth": 4, - "bracketSameLine": true + "bracketSameLine": true, + "proseWrap": "always" } diff --git a/docs/README.md b/docs/README.md index 48270857..cfa48cb5 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,7 +1,7 @@ --- description: >- - We encourage our technical users to check the code and run the web app locally - from source following the instructions below. + We encourage our technical users to check the code and run the web app + locally from source following the instructions below. cover: .gitbook/assets/boltz-web_app_header.png coverY: 0 --- @@ -10,13 +10,20 @@ coverY: 0 ## Dependencies -Make sure to have the latest [Node.js LTS and NPM](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm) installed. We recommend using [nvm](https://github.com/nvm-sh/nvm#install--update-script) to manage npm installs: `nvm install --lts` +Make sure to have the latest +[Node.js LTS and NPM](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm) +installed. We recommend using +[nvm](https://github.com/nvm-sh/nvm#install--update-script) to manage npm +installs: `nvm install --lts` ### Run -Clone the repository, change to the project folder and run `npm install` to install all dependencies. Then `npm run mainnet && npm run build` and `npx serve dist` to bring it up. +Clone the repository, change to the project folder and run `npm install` to +install all dependencies. Then `npm run mainnet && npm run build` and +`npx serve dist` to bring it up. -Open [http://localhost:3000](http://localhost:3000) in your browser and start swapping! +Open [http://localhost:3000](http://localhost:3000) in your browser and start +swapping! ## With Docker diff --git a/docs/pwa.md b/docs/pwa.md index bd73f780..0a56b4f1 100644 --- a/docs/pwa.md +++ b/docs/pwa.md @@ -1,8 +1,8 @@ --- description: >- - For improved censorship resistance and privacy, Boltz is not available in app - stores, but can be installed as Progressive Web App (PWA) on all Android and - iOS devices, as well as desktop computers. + For improved censorship resistance and privacy, Boltz is not available in + app stores, but can be installed as Progressive Web App (PWA) on all Android + and iOS devices, as well as desktop computers. --- # 📲 Install as App @@ -11,7 +11,10 @@ description: >- ## Android -1. Open [boltz.exchange](https://boltz.exchange) in a mobile browser like [Chrome](https://www.google.com/chrome/) or [Vanadium](https://github.com/GrapheneOS/Vanadium), open the browser menu and tap "Install app": +1. Open [boltz.exchange](https://boltz.exchange) in a mobile browser like + [Chrome](https://www.google.com/chrome/) or + [Vanadium](https://github.com/GrapheneOS/Vanadium), open the browser menu and + tap "Install app":
@@ -19,11 +22,13 @@ description: >-
-3. Now you find Boltz as App Icon on your home screen which you can use just like any other app. +3. Now you find Boltz as App Icon on your home screen which you can use just + like any other app. ## iOS -1. Open [boltz.exchange](https://boltz.exchange) in your Safari mobile browser and tap the share button: +1. Open [boltz.exchange](https://boltz.exchange) in your Safari mobile browser + and tap the share button:
@@ -35,13 +40,16 @@ description: >-
-4. Now you find Boltz as App Icon on your home screen which you can use just like any other app. +4. Now you find Boltz as App Icon on your home screen which you can use just + like any other app. ## Desktop -Here an example how to install Boltz as App on a Ubuntu Desktop Computer using [Chromium](https://www.chromium.org/Home/): +Here an example how to install Boltz as App on a Ubuntu Desktop Computer using +[Chromium](https://www.chromium.org/Home/): -1. Open [boltz.exchange](https://boltz.exchange) and click the install icon that automatically appears: +1. Open [boltz.exchange](https://boltz.exchange) and click the install icon that + automatically appears:
diff --git a/docs/urlParams.md b/docs/urlParams.md new file mode 100644 index 00000000..9c491b57 --- /dev/null +++ b/docs/urlParams.md @@ -0,0 +1,33 @@ +--- +description: >- + To prefill certain inputs of the the site, URL query parameters can be used. + Those parameters are documented here. +--- + +# URL query parameters + +## Embedding + +When `embed` is set to `1`, only the swap box will be shown. + +## Destination + +`destination` prefills either the onchain address or invoice input field. The +inferred asset takes precedence over `receiveAsset` and in case a lightning +invoice is set, its amount takes precedence over all other inputs to set the +amount. + +## Assets + +`sendAsset` and `receiveAsset` can be used to set the assets. Possible values +are: + +- `LN` +- `BTC` +- `L-BTC` +- `RBTC` + +## Amounts + +`sendAmount` or `receiveAmount` set the respective amounts. Value is denominated +in satoshis and `sendAmount` takes precedence. diff --git a/e2e/submarineSwap.spec.ts b/e2e/submarineSwap.spec.ts index c237da1f..df5c72b2 100644 --- a/e2e/submarineSwap.spec.ts +++ b/e2e/submarineSwap.spec.ts @@ -28,9 +28,7 @@ test.describe("Submarine swap", () => { await expect(inputSendAmount).toHaveValue(sendAmount); const invoiceInput = page.locator("textarea[data-testid='invoice']"); - await invoiceInput.fill( - JSON.parse(await generateInvoiceLnd(1000000)).payment_request, - ); + await invoiceInput.fill(await generateInvoiceLnd(1000000)); const buttonCreateSwap = page.locator( "button[data-testid='create-swap-button']", ); diff --git a/e2e/urlParams.spec.ts b/e2e/urlParams.spec.ts new file mode 100644 index 00000000..e1eead80 --- /dev/null +++ b/e2e/urlParams.spec.ts @@ -0,0 +1,102 @@ +import { expect, test } from "@playwright/test"; +import BigNumber from "bignumber.js"; + +import { Denomination } from "../src/consts/Enums"; +import { formatAmount } from "../src/utils/denomination"; +import { + generateInvoiceLnd, + getBitcoinAddress, + getLiquidAddress, +} from "./utils"; + +test.describe("URL params", () => { + test("BTC address destination", async ({ page }) => { + const address = await getBitcoinAddress(); + + await page.goto(`/?destination=${address}`); + const receiveAsset = page.locator(".asset-BTC"); + expect(receiveAsset).toBeDefined(); + + const onchainAddress = page.getByTestId("onchainAddress"); + await expect(onchainAddress).toHaveValue(address); + }); + + test("L-BTC address destination", async ({ page }) => { + const address = await getLiquidAddress(); + + await page.goto(`/?destination=${address}`); + const receiveAsset = page.locator(".asset-L-BTC"); + expect(receiveAsset).toBeDefined(); + + const onchainAddress = page.getByTestId("onchainAddress"); + await expect(onchainAddress).toHaveValue(address); + }); + + test("Lightning invoice destination", async ({ page }) => { + const amount = 100_000; + const invoice = await generateInvoiceLnd(amount); + + await page.goto(`/?destination=${invoice}`); + const receiveAsset = page.locator(".asset-LN"); + expect(receiveAsset).toBeDefined(); + + const invoiceInput = page.getByTestId("invoice"); + await expect(invoiceInput).toHaveValue(invoice); + + const receiveAmount = page.getByTestId("receiveAmount"); + await expect(receiveAmount).toHaveValue( + formatAmount(BigNumber(amount), Denomination.Sat, "."), + ); + }); + + test("should set send amount", async ({ page }) => { + const amount = 210_000; + await page.goto(`/?sendAmount=${amount}`); + + const sendAmount = page.getByTestId("sendAmount"); + await expect(sendAmount).toHaveValue( + formatAmount(BigNumber(amount), Denomination.Sat, "."), + ); + }); + + test("should set receive amount", async ({ page }) => { + const amount = 210_000; + await page.goto(`/?receiveAmount=${amount}`); + + const receiveAmount = page.getByTestId("receiveAmount"); + await expect(receiveAmount).toHaveValue( + formatAmount(BigNumber(amount), Denomination.Sat, "."), + ); + }); + + test("should not set receive amount when send amount is set", async ({ + page, + }) => { + const sendAmount = 100_000; + const receiveAmount = 210_000; + await page.goto( + `/?sendAmount=${sendAmount}&receiveAmount=${receiveAmount}`, + ); + + const sendAmountInput = page.getByTestId("sendAmount"); + await expect(sendAmountInput).toHaveValue( + formatAmount(BigNumber(sendAmount), Denomination.Sat, "."), + ); + }); + + test("should not set amount when lightning invoice is set", async ({ + page, + }) => { + const invoiceAmount = 200_000; + const invoice = await generateInvoiceLnd(invoiceAmount); + + const sendAmount = 100_000; + + await page.goto(`/?sendAmount=${sendAmount}&destination=${invoice}`); + + const receiveAmount = page.getByTestId("receiveAmount"); + await expect(receiveAmount).toHaveValue( + formatAmount(BigNumber(invoiceAmount), Denomination.Sat, "."), + ); + }); +}); diff --git a/e2e/utils.ts b/e2e/utils.ts index 15a5caa7..2762f8e0 100644 --- a/e2e/utils.ts +++ b/e2e/utils.ts @@ -28,6 +28,10 @@ export const getBitcoinAddress = async (): Promise => { return execCommand("bitcoin-cli-sim-client getnewaddress"); }; +export const getLiquidAddress = async (): Promise => { + return execCommand("elements-cli-sim-client getnewaddress"); +}; + export const bitcoinSendToAddress = async ( address: string, amount: string, @@ -63,5 +67,7 @@ export const payInvoiceLnd = async (invoice: string): Promise => { }; export const generateInvoiceLnd = async (amount: number): Promise => { - return execCommand(`lncli-sim 1 addinvoice --amt ${amount}`); + return JSON.parse( + await execCommand(`lncli-sim 1 addinvoice --amt ${amount}`), + ).payment_request; }; diff --git a/src/consts/Assets.ts b/src/consts/Assets.ts index 4528fc39..797d6bcc 100644 --- a/src/consts/Assets.ts +++ b/src/consts/Assets.ts @@ -4,3 +4,5 @@ export const LBTC = "L-BTC"; export const RBTC = "RBTC"; export type AssetType = typeof LN | typeof BTC | typeof LBTC | typeof RBTC; + +export const assets = [LN, BTC, LBTC, RBTC]; diff --git a/src/context/Create.tsx b/src/context/Create.tsx index af34f75d..df4bffc1 100644 --- a/src/context/Create.tsx +++ b/src/context/Create.tsx @@ -1,5 +1,6 @@ import { makePersisted } from "@solid-primitives/storage"; import BigNumber from "bignumber.js"; +import type { Network as LiquidNetwork } from "liquidjs-lib/src/networks"; import { Accessor, Setter, @@ -10,9 +11,113 @@ import { } from "solid-js"; import { config } from "../config"; -import { LN, RBTC } from "../consts/Assets"; +import { BTC, LBTC, LN, RBTC, assets } from "../consts/Assets"; import { Side, SwapType } from "../consts/Enums"; import { DictKey } from "../i18n/i18n"; +import { getAddress, getNetwork } from "../utils/compat"; +import { getUrlParam, urlParamIsSet } from "../utils/urlParams"; + +const setDestination = ( + setAssetReceive: Setter, + setInvoice: Setter, + setOnchainAddress: Setter, +) => { + const isValidForAsset = ( + asset: typeof BTC | typeof LBTC, + address: string, + ) => { + try { + getAddress(asset).toOutputScript( + address, + getNetwork(asset) as LiquidNetwork, + ); + return true; + } catch (e) { + return false; + } + }; + + const destination = getUrlParam("destination"); + if (urlParamIsSet(destination)) { + if (isValidForAsset(BTC, destination)) { + setAssetReceive(BTC); + setOnchainAddress(destination); + return BTC; + } else if (isValidForAsset(LBTC, destination)) { + setAssetReceive(LBTC); + setOnchainAddress(destination); + return LBTC; + } else { + setAssetReceive(LN); + setInvoice(destination); + return LN; + } + } + + return undefined; +}; + +const isValidAsset = (asset: string) => + urlParamIsSet(asset) && assets.includes(asset); + +const parseAmount = (amount: string): BigNumber | undefined => { + if (!urlParamIsSet(amount)) { + return undefined; + } + + const parsedAmount = new BigNumber(amount); + if (parsedAmount.isNaN()) { + return undefined; + } + return parsedAmount; +}; + +const handleUrlParams = ( + setAssetSend: Setter, + setAssetReceive: Setter, + setInvoice: Setter, + setOnchainAddress: Setter, + setAmountChanged: Setter, + setSendAmount: Setter, + setReceiveAmount: Setter, +) => { + const destinationAsset = setDestination( + setAssetReceive, + setInvoice, + setOnchainAddress, + ); + + const sendAsset = getUrlParam("sendAsset"); + if (isValidAsset(sendAsset)) { + setAssetSend(sendAsset); + } + + // The type of the destination takes precedence + if (destinationAsset === undefined) { + const receiveAsset = getUrlParam("receiveAsset"); + if (isValidAsset(receiveAsset)) { + setAssetReceive(receiveAsset); + } + } + + // Lightning invoice amounts take precedence + if (destinationAsset !== LN) { + const sendAmount = parseAmount(getUrlParam("sendAmount")); + if (sendAmount) { + setAmountChanged(Side.Send); + setSendAmount(sendAmount); + } + + if (sendAmount === undefined) { + const receiveAmount = parseAmount(getUrlParam("receiveAmount")); + + if (receiveAmount) { + setAmountChanged(Side.Receive); + setReceiveAmount(BigNumber(receiveAmount)); + } + } + } +}; export type CreateContextType = { swapType: Accessor; @@ -135,6 +240,16 @@ const CreateProvider = (props: { children: any }) => { const [boltzFee, setBoltzFee] = createSignal(0); const [minerFee, setMinerFee] = createSignal(0); + handleUrlParams( + setAssetSend, + setAssetReceive, + setInvoice, + setOnchainAddress, + setAmountChanged, + setSendAmount, + setReceiveAmount, + ); + return ( { setWasmSupported(checkWasmSupported()); // check referral - const refParam = new URLSearchParams(window.location.search).get("ref"); + const refParam = getUrlParam("ref"); if (refParam && refParam !== "") { setRef(refParam); - window.history.replaceState( - {}, - document.title, - window.location.pathname, - ); } - if (detectEmbedded()) { + if (isEmbed()) { setEmbedded(true); + setHideHero(true); } const [browserNotification, setBrowserNotification] = makePersisted( diff --git a/src/utils/urlParams.ts b/src/utils/urlParams.ts new file mode 100644 index 00000000..6ef2924c --- /dev/null +++ b/src/utils/urlParams.ts @@ -0,0 +1,27 @@ +const searchParams = () => new URLSearchParams(window.location.search); + +export const isEmbed = (): boolean => { + const param = searchParams().get("embed"); + return param && param === "1"; +}; + +export const getUrlParam = (name: string): string => { + const param = searchParams().get(name); + if (param) { + resetUrlParam(name); + } + return param; +}; + +export const urlParamIsSet = (param: string) => param && param !== ""; + +export const resetUrlParam = (name: string) => { + const params = searchParams(); + params.delete(name); + + window.history.replaceState( + {}, + document.title, + `${window.location.pathname}?${params.toString()}`, + ); +};