Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions e2e/chainSwaps/chainSwaps.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { expect, test } from "@playwright/test";
import BigNumber from "bignumber.js";

import { LBTC } from "../../src/consts/Assets";
import { btcToSat, satToBtc } from "../../src/utils/denomination";
import {
bitcoinSendToAddress,
Expand All @@ -12,9 +13,11 @@ import {
generateBitcoinBlock,
generateInvoiceWithRoutingHint,
generateLiquidBlock,
getAddressUtxos,
getBitcoinAddress,
getBitcoinWalletTx,
getLiquidAddress,
getLiquidUnconfidentialAddress,
setDisableAllSigners,
verifyRescueFile,
} from "../utils";
Expand Down Expand Up @@ -155,6 +158,71 @@ test.describe("Chain swap", () => {
);
});

// The claim reserves the unconfidential surcharge out of its output, so the
// swap has to request that much more for the payee to land the full amount
test("BTC/LN with Magic Routing Hint to an unconfidential address", async ({
page,
}) => {
await page.goto("/");

await page.locator("div[class='asset asset-BTC'] div").click();
await page.getByTestId("select-LN").click();

const liquidAddress = await getLiquidUnconfidentialAddress();
const invoice = await generateInvoiceWithRoutingHint(
liquidAddress,
btcToSat(BigNumber("0.0009")).toNumber(),
);
await page.locator("input[data-testid='invoice']").fill(invoice);

const bip21 = new URL((await fetchBip21Invoice(invoice)).bip21);
const bip21AmountSats = btcToSat(
BigNumber(bip21.searchParams.get("amount") ?? 0),
).toNumber();

await page.getByTestId("create-swap-button").click();
await verifyRescueFile(page);

await expect(
page.locator("span[class='optimized-route']"),
).toBeVisible();

await page.locator("p[data-testid='copy-box']").click();
const copyAddress = await page.evaluate(() =>
navigator.clipboard.readText(),
);

await page
.getByTestId("pay-onchain-buttons")
.getByText("amount")
.click();
const sendAmount = await page.evaluate(() =>
navigator.clipboard.readText(),
);

await bitcoinSendToAddress(
copyAddress,
satToBtc(BigNumber(sendAmount)).toString(),
);
await generateBitcoinBlock();

await expect(
page.locator("div[data-status='transaction.claimed']"),
).toBeVisible({ timeout: 15_000 });
await generateLiquidBlock();

await expect
.poll(
async () =>
(await getAddressUtxos(LBTC, liquidAddress)).reduce(
(sum, utxo) => sum + (utxo.value ?? 0),
0,
),
{ timeout: 30_000 },
)
.toBe(bip21AmountSats);
});

test("L-BTC/BTC with zeroConf toggle automatically claims swap", async ({
page,
}) => {
Expand Down
2 changes: 1 addition & 1 deletion e2e/chainSwaps/quoteAcceptanceCrash.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ test.describe("ChainSwap replacement quote acceptance crash", () => {
const quoteAmount: number = (await (await quoteFetched).json()).amount;

const claimFee = await getLbtcBtcClaimFee();
const expectedReceive = quoteAmount - claimFee - 1;
const expectedReceive = quoteAmount - claimFee;

// Let the acceptance POST reach the backend, but never deliver the
// response to the app: the crash window between the server accepting
Expand Down
2 changes: 1 addition & 1 deletion e2e/chainSwaps/zeroAmount.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ test.describe("Chain Swap 0-amount", () => {
const txInfo = JSON.parse(await getElementsWalletTx(txId!));
expectApproxBtcAmount(
txInfo.amount.bitcoin.toString(),
"0.00997297",
"0.00997298",
amountBufferSats,
);
});
Expand Down
62 changes: 62 additions & 0 deletions e2e/reverseSwap.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@ import { SwapType } from "boltz-swaps/types";
import { calculateSendAmount } from "../src/utils/calculate";
import { btcToSat, satToBtc } from "../src/utils/denomination";
import {
elementsGetDecodedTransaction,
expectApproxAmount,
expectApproxBtcAmount,
generateBitcoinBlock,
getBitcoinAddress,
getBitcoinWalletTx,
getLiquidUnconfidentialAddress,
getReversePairFees,
payInvoiceLnd,
payInvoiceLndBackground,
Expand Down Expand Up @@ -86,6 +88,66 @@ test.describe("reverseSwap", () => {
expectApproxBtcAmount(txInfo.amount.toString(), receiveAmount);
});

test("Reverse swap LN/L-BTC to an unconfidential address", async ({
page,
}) => {
await page.goto("/");

await page
.locator(
"div:nth-child(3) > .asset-wrap > .asset > .asset-selection > .arrow-down",
)
.click();
await page.getByTestId("select-L-BTC").click();

const receiveAmount = "0.001";
await page.getByTestId("receiveAmount").fill(receiveAmount);

const claimAddress = await getLiquidUnconfidentialAddress();
expect(claimAddress.startsWith("ert1")).toBe(true);
await page.getByTestId("onchainAddress").fill(claimAddress);

await page.getByTestId("create-swap-button").click();

await page.locator("span[class='btn']").click();
const invoice = await page.evaluate(() =>
navigator.clipboard.readText(),
);
await payInvoiceLnd(invoice);

const txIdLink = page.getByText("open claim transaction");
await expect(txIdLink).toBeVisible({ timeout: 30_000 });

// Liquid claim links carry a "#blinded=" fragment; strip it for the txid
const txId = (await txIdLink.getAttribute("href"))!
.split("/")
.pop()!
.split("#")[0];

const tx = await elementsGetDecodedTransaction(txId);

const claimed = tx.vout.find(
(out) => out.scriptPubKey.address === claimAddress,
);
expect(claimed).toBeDefined();
expect(btcToSat(BigNumber(claimed!.value!)).toNumber()).toEqual(
btcToSat(BigNumber(receiveAmount)).toNumber(),
);

// boltz-core injects it because no other output is blinded
expect(
tx.vout.filter((out) => out.scriptPubKey.type === "nulldata"),
).toHaveLength(1);

const fee = tx.vout.find((out) => out.scriptPubKey.type === "fee");
expect(fee).toBeDefined();

// Elements relays at 0.1 sat/vbyte with truncating division
expect(
btcToSat(BigNumber(fee!.value!)).toNumber(),
).toBeGreaterThanOrEqual(Math.floor(tx.discountvsize! / 10));
});

test("LN/BTC with zeroConf toggle automatically claims swap", async ({
page,
}) => {
Expand Down
25 changes: 25 additions & 0 deletions e2e/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,13 @@ export const getBitcoinAddress = (): Promise<string> =>
export const getLiquidAddress = (): Promise<string> =>
execCommand("elements-cli-sim-client getnewaddress");

export const getLiquidUnconfidentialAddress = async (): Promise<string> => {
const info = await execCommand(
`elements-cli-sim-client getaddressinfo ${await getLiquidAddress()}`,
);
return (JSON.parse(info) as { unconfidential: string }).unconfidential;
};

export const bitcoinSendToAddress = (
address: string,
amount: string,
Expand Down Expand Up @@ -123,6 +130,12 @@ type DecodedTransaction = {
vout: number;
txinwitness?: string[];
}[];
// Elements only; blinded outputs carry no `value`
vout: {
value?: number;
scriptPubKey: { type: string; address?: string };
}[];
discountvsize?: number;
};

type Unspent = {
Expand Down Expand Up @@ -481,6 +494,18 @@ export const getCurrentSwapId = (page: Page): string => {
return url.pathname.split("/").pop() ?? "";
};

// Esplora indexes by script, so this also resolves the unconfidential form of
// a wallet address; `value` is absent for blinded outputs
export const getAddressUtxos = async (
asset: AssetType,
address: string,
): Promise<{ txid: string; vout: number; value?: number }[]> =>
(
await axios.get<{ txid: string; vout: number; value?: number }[]>(
`${config.assets![asset].blockExplorerApis![0].normal}/address/${address}/utxo`,
)
).data;

export const waitForUTXOs = async (
asset: AssetType,
address: string,
Expand Down
32 changes: 32 additions & 0 deletions packages/boltz-swaps/integration/regtest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,15 @@ export const getBitcoinAddress = (): Promise<string> =>
export const getLiquidAddress = (): Promise<string> =>
execInScripts("elements-cli-sim-client getnewaddress");

export const getLiquidUnconfidentialAddress = async (): Promise<string> => {
const address = await getLiquidAddress();
const info = await execInScripts(
`elements-cli-sim-client getaddressinfo ${address}`,
);
const { unconfidential } = JSON.parse(info) as { unconfidential: string };
return unconfidential;
};

export const bitcoinSendToAddress = (
address: string,
coins: string,
Expand Down Expand Up @@ -144,6 +153,29 @@ export const waitForTxConfirmed = async (
}
};

type EsploraVout = {
scriptpubkey_type: string;
scriptpubkey_address?: string;
value?: number;
};

export type EsploraTransaction = {
txid: string;
fee: number;
vout: EsploraVout[];
};

export const getEsploraTransaction = async (
asset: string,
txid: string,
): Promise<EsploraTransaction> => {
const res = await fetch(`${ESPLORA_API[asset]}/tx/${txid}`);
if (!res.ok) {
throw new Error(`could not fetch ${asset} tx ${txid}: ${res.status}`);
}
return (await res.json()) as EsploraTransaction;
};

export const waitForAddressUtxos = async (
asset: string,
address: string,
Expand Down
88 changes: 88 additions & 0 deletions packages/boltz-swaps/integration/reverseSwap.regtest.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,15 @@ import { createBoltzClient, getPairs } from "boltz-swaps";
import { SwapStatus, isFailureStatus, isFinalStatus } from "boltz-swaps/status";
import { SwapType } from "boltz-swaps/types";

import { liquidUnconfidentialClaimExtra } from "../src/utxo/claim.ts";
import {
BOLTZ_API_URL,
generateBitcoinBlock,
generateLiquidBlock,
getBitcoinAddress,
getEsploraTransaction,
getLiquidAddress,
getLiquidUnconfidentialAddress,
payInvoiceInBackground,
setBackendSignersDisabled,
sleep,
Expand Down Expand Up @@ -145,6 +148,91 @@ describe("reverse swap integration (regtest)", () => {
await runReverseSwap({ to: "L-BTC" });
}, 120_000);

describe("LN -> L-BTC: claim to an unconfidential address", () => {
const setUpClaim = async () => {
const claimKeys = makeKeys();
const preimage = crypto.getRandomValues(new Uint8Array(32));
const claimAddress = await getLiquidUnconfidentialAddress();
expect(claimAddress.startsWith("ert1")).toBe(true);

const pair = await reversePair("L-BTC");
const created = await boltz.swap.reverse.create({
from: "BTC",
to: "L-BTC",
invoiceAmount: 100_000,
preimageHash: hex.encode(sha256(preimage)),
pairHash: pair.hash,
claimPublicKey: hex.encode(claimKeys.publicKey),
claimAddress,
});

payInvoiceInBackground(created.invoice);
await waitUntilClaimable(created.id, "L-BTC", 90_000);

return {
claimKeys,
claimAddress,
created,
claimFee: pair.fees.minerFees.claim,
execute: (receiveAmount: number) =>
boltz.swap.reverse.execute({
createdSwap: created,
to: "L-BTC",
preimage: hex.encode(preimage),
receiveAmount,
claimAddress,
claimKeys,
}),
};
};

test("is accepted by Elements and pays the surcharge out of the claim output", async () => {
const { created, claimAddress, claimFee, execute } =
await setUpClaim();
const receiveAmount = created.onchainAmount - claimFee;

const result = await execute(receiveAmount);
expect(result.claimTransactionId).toMatch(/^[0-9a-f]{64}$/);

await generateLiquidBlock();
await waitForTxConfirmed("L-BTC", result.claimTransactionId);

const utxos = await waitForAddressUtxos("L-BTC", claimAddress);
const claimed = utxos.find(
(utxo) => utxo.txid === result.claimTransactionId,
);
expect(claimed).toBeDefined();
expect(claimed!.value).toBe(
receiveAmount - liquidUnconfidentialClaimExtra,
);
expect(result.receiveAmount).toBe(BigInt(claimed!.value));

const tx = await getEsploraTransaction(
"L-BTC",
result.claimTransactionId,
);
expect(tx.vout).toHaveLength(3);
expect(
tx.vout.filter((out) => out.scriptpubkey_type === "op_return"),
).toHaveLength(1);
expect(tx.fee).toBe(claimFee + liquidUnconfidentialClaimExtra - 1);
}, 120_000);

// Shrinking the budget by the surcharge cancels it out, reproducing the
// pre-fix fee. Guards the test above against passing for free.
test("would be rejected without the surcharge", async () => {
const { created, claimFee, execute } = await setUpClaim();
const receiveAmount =
created.onchainAmount -
claimFee +
liquidUnconfidentialClaimExtra;

await expect(execute(receiveAmount)).rejects.toThrow(
/min relay fee not met/i,
);
}, 120_000);
});

test("LN -> BTC: uncooperative reverse claim when the server refuses to co-sign", async () => {
try {
await runReverseSwap({
Expand Down
Loading