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
4 changes: 2 additions & 2 deletions src/app/screens/ConfirmPayment/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { useNavigationState } from "~/app/hooks/useNavigationState";
import { USER_REJECTED_ERROR } from "~/common/constants";
import api from "~/common/lib/api";
import msg from "~/common/lib/msg";
import { getPaymentRequestAmountSats } from "~/common/utils/paymentRequest";

function ConfirmPayment() {
const {
Expand All @@ -37,8 +38,7 @@ function ConfirmPayment() {
const paymentRequest = navState.args?.paymentRequest as string;
const invoice = lightningPayReq.decode(paymentRequest);

const amountSat =
invoice.satoshis || Number(invoice.millisatoshis) / 1000 || 0;
const amountSat = getPaymentRequestAmountSats(invoice) ?? 0;

const navigate = useNavigate();
const auth = useAccount();
Expand Down
4 changes: 2 additions & 2 deletions src/app/screens/ConfirmPaymentAsync/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { useNavigationState } from "~/app/hooks/useNavigationState";
import { USER_REJECTED_ERROR } from "~/common/constants";
import api from "~/common/lib/api";
import msg from "~/common/lib/msg";
import { getPaymentRequestAmountSats } from "~/common/utils/paymentRequest";

function ConfirmPaymentAsync() {
const {
Expand All @@ -32,8 +33,7 @@ function ConfirmPaymentAsync() {
const navState = useNavigationState();
const paymentRequest = navState.args?.paymentRequest as string;
const invoice = lightningPayReq.decode(paymentRequest);
const amountSat =
invoice.satoshis || Number(invoice.millisatoshis) / 1000 || 0;
const amountSat = getPaymentRequestAmountSats(invoice) ?? 0;
const navigate = useNavigate();

const [fiatAmount, setFiatAmount] = useState("");
Expand Down
36 changes: 36 additions & 0 deletions src/common/utils/__tests__/paymentRequest.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import lightningPayReq from "bolt11-signet";
import { createPaymentRequest } from "~/fixtures/paymentRequests";

import { getPaymentRequestAmountSats } from "../paymentRequest";

function decode(millisatoshis?: number) {
return lightningPayReq.decode(createPaymentRequest(millisatoshis));
}

describe("getPaymentRequestAmountSats", () => {
test("returns the amount for whole-satoshi invoices", () => {
expect(getPaymentRequestAmountSats(decode(1_000_000))).toBe(1000);
});

// an invoice for a non-whole number of sats leaves `satoshis` unset; reading it
// directly reported a real amount as 0 and bypassed the allowance budget check
test("returns the amount for sub-satoshi precision invoices", () => {
expect(getPaymentRequestAmountSats(decode(1_000_500))).toBe(1001);
expect(getPaymentRequestAmountSats(decode(50_000_001))).toBe(50001);
});

test("rounds up so a payment is never under-reported", () => {
expect(getPaymentRequestAmountSats(decode(999))).toBe(1);
expect(getPaymentRequestAmountSats(decode(1))).toBe(1);
});

test("returns null for amountless invoices", () => {
expect(getPaymentRequestAmountSats(decode())).toBeNull();
});

// an explicit zero amount cannot be paid without the user supplying one, so
// it must not be treated as a 0 sat payment that fits any budget
test("returns null for invoices with an explicit zero amount", () => {
expect(getPaymentRequestAmountSats(decode(0))).toBeNull();
});
});
19 changes: 18 additions & 1 deletion src/common/utils/paymentRequest.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,21 @@
import lightningPayReq from "bolt11-signet";
import lightningPayReq, { PaymentRequestObject } from "bolt11-signet";

// BOLT11 amounts are millisatoshi-precision, but `satoshis` is only set when the
// invoice amount is a whole number of sats — an invoice for 1000.5 sat decodes to
// `satoshis: undefined, millisatoshis: "1000500"`. Reading `satoshis` alone (or
// `satoshis || 0`) therefore reads a real amount as 0, so read millisatoshis,
// which is always set alongside it. Rounds up so we never under-report what is
// being spent. Returns null for amountless invoices (no amount, or an explicit
// zero amount), which cannot be checked against a budget.
export function getPaymentRequestAmountSats(
paymentRequestDetails: PaymentRequestObject
): number | null {
const millisatoshis = Number(paymentRequestDetails.millisatoshis);
if (!millisatoshis) {
return null;
}
return Math.ceil(millisatoshis / 1000);
}

export function getPaymentRequestDescription(paymentRequest: string): string {
const decodedPaymentRequest = lightningPayReq.decode(paymentRequest);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import utils from "~/common/lib/utils";
import db from "~/extension/background-script/db";
import { allowanceFixture } from "~/fixtures/allowances";
import { createPaymentRequest } from "~/fixtures/paymentRequests";
import type { DbAllowance, Message, Sender } from "~/types";

import sendPayment from "../../ln/sendPayment";
import { sendPaymentOrPrompt } from "../sendPaymentOrPrompt";

jest.mock("~/common/lib/utils", () => ({
__esModule: true,
default: { openPrompt: jest.fn(() => Promise.resolve({ data: {} })) },
}));

jest.mock("../../ln/sendPayment", () => ({
__esModule: true,
default: jest.fn(() => Promise.resolve({ data: {} })),
}));

const mockAllowances: DbAllowance[] = allowanceFixture;

// the allowance fixture for getalby.com has a remaining budget of 500 sats
const sender: Sender = { origin: "https://getalby.com" };

function message(millisatoshis?: number): Message {
return {
application: "LBE",
prompt: true,
action: "sendPaymentOrPrompt",
origin: { internal: true },
args: { paymentRequest: createPaymentRequest(millisatoshis) },
};
}

describe("sendPaymentOrPrompt", () => {
beforeAll(async () => {
await db.allowances.bulkAdd(mockAllowances);
await db.allowances.add({
...mockAllowances[0],
id: 3,
host: "nostr-only.example",
enabledFor: ["nostr"],
});
// enabled for webln with budget to spare, only the `enabled` flag denies it
await db.allowances.add({
...mockAllowances[0],
id: 4,
host: "disabled.example",
enabled: false,
});
});

afterEach(() => {
jest.clearAllMocks();
});

test("pays without a prompt when the amount is within the budget", async () => {
await sendPaymentOrPrompt(message(100_000), sender);

expect(sendPayment).toHaveBeenCalled();
expect(utils.openPrompt).not.toHaveBeenCalled();
});

test("prompts when the amount exceeds the budget", async () => {
await sendPaymentOrPrompt(message(600_000), sender);

expect(utils.openPrompt).toHaveBeenCalled();
expect(sendPayment).not.toHaveBeenCalled();
});

// a sub-satoshi amount leaves `satoshis` unset on the decoded invoice. Reading
// it directly made this look like a 0 sat payment, so it cleared the budget
// check and was paid without any confirmation.
test("prompts for a sub-satoshi amount that exceeds the budget", async () => {
await sendPaymentOrPrompt(message(50_000_001), sender);

expect(utils.openPrompt).toHaveBeenCalled();
expect(sendPayment).not.toHaveBeenCalled();
});

test("pays without a prompt for a sub-satoshi amount within the budget", async () => {
await sendPaymentOrPrompt(message(100_500), sender);

expect(sendPayment).toHaveBeenCalled();
expect(utils.openPrompt).not.toHaveBeenCalled();
});

test("prompts for amountless invoices", async () => {
await sendPaymentOrPrompt(message(), sender);

expect(utils.openPrompt).toHaveBeenCalled();
expect(sendPayment).not.toHaveBeenCalled();
});

test("prompts when the allowance is disabled", async () => {
await sendPaymentOrPrompt(message(100_000), {
origin: "https://disabled.example",
});

expect(utils.openPrompt).toHaveBeenCalled();
expect(sendPayment).not.toHaveBeenCalled();
});

test("prompts when the allowance is not enabled for webln", async () => {
await sendPaymentOrPrompt(message(100_000), {
origin: "https://nostr-only.example",
});

expect(utils.openPrompt).toHaveBeenCalled();
expect(sendPayment).not.toHaveBeenCalled();
});

test("prompts when the host has no allowance", async () => {
await sendPaymentOrPrompt(message(50_000_001), {
origin: "https://example.com",
});

expect(utils.openPrompt).toHaveBeenCalled();
expect(sendPayment).not.toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import lightningPayReq from "bolt11-signet";
import utils from "~/common/lib/utils";
import { getHostFromSender } from "~/common/utils/helpers";
import { getPaymentRequestAmountSats } from "~/common/utils/paymentRequest";
import { Message, Sender } from "~/types";

import db from "../../db";
Expand All @@ -18,7 +19,11 @@ const sendPaymentOrPrompt = async (message: Message, sender: Sender) => {
}

const paymentRequestDetails = lightningPayReq.decode(paymentRequest);
if (await checkAllowance(host, paymentRequestDetails.satoshis || 0)) {
const amountInSats = getPaymentRequestAmountSats(paymentRequestDetails);

// amountless invoices carry no amount to check against the budget, so they
// always require explicit confirmation
if (amountInSats !== null && (await checkAllowance(host, amountInSats))) {
return sendPaymentWithAllowance(message);
} else {
return payWithPrompt(message);
Expand All @@ -31,7 +36,14 @@ async function checkAllowance(host: string, amount: number) {
.equalsIgnoreCase(host)
.first();

return allowance && allowance.remainingBudget > amount; // check that the budget is higher than the amount. amount can be 0
const enabledFor = new Set(allowance?.enabledFor);

return (
allowance &&
allowance.enabled &&
enabledFor.has("webln") &&
allowance.remainingBudget > amount // check that the budget is higher than the amount
);
}

async function sendPaymentWithAllowance(message: Message) {
Expand Down
2 changes: 1 addition & 1 deletion src/extension/background-script/connectors/eclair.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ class Eclair implements Connector {
preimage: paymentPreimage,
paymentHash,
route: {
total_amt: Math.floor(recipientAmount / 1000),
total_amt: Math.ceil(recipientAmount / 1000),
total_fees: Math.floor(status.feesPaid / 1000),
},
},
Expand Down
8 changes: 6 additions & 2 deletions src/extension/background-script/connectors/galoy.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import axios, { AxiosRequestConfig } from "axios";
import lightningPayReq from "bolt11-signet";
import { ACCOUNT_CURRENCIES, CURRENCIES } from "~/common/constants";
import { getPaymentRequestDescription } from "~/common/utils/paymentRequest";
import {
getPaymentRequestAmountSats,
getPaymentRequestDescription,
} from "~/common/utils/paymentRequest";
import { getCurrencyRateWithCache } from "~/extension/background-script/actions/cache/getCurrencyRate";
import { Account } from "~/types";
import Connector, {
Expand Down Expand Up @@ -378,7 +381,8 @@ class Galoy implements Connector {
};

const paymentRequestDetails = lightningPayReq.decode(args.paymentRequest);
const amountInSats = paymentRequestDetails.satoshis || 0;
const amountInSats =
getPaymentRequestAmountSats(paymentRequestDetails) ?? 0;
const paymentHash = paymentRequestDetails.tagsObject.payment_hash || "";

return this.request(query).then(({ data, errors }) => {
Expand Down
4 changes: 3 additions & 1 deletion src/extension/background-script/connectors/lnbits.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import lightningPayReq from "bolt11-signet";
import Hex from "crypto-js/enc-hex";
import sha256 from "crypto-js/sha256";
import { getPaymentRequestAmountSats } from "~/common/utils/paymentRequest";
import HashKeySigner from "~/common/utils/signer";
import { Account } from "~/types";

Expand Down Expand Up @@ -175,7 +176,8 @@ class LnBits implements Connector {

sendPayment(args: SendPaymentArgs): Promise<SendPaymentResponse> {
const paymentRequestDetails = lightningPayReq.decode(args.paymentRequest);
const amountInSats = paymentRequestDetails.satoshis || 0;
const amountInSats =
getPaymentRequestAmountSats(paymentRequestDetails) ?? 0;
return this.request("POST", "/api/v1/payments", this.config.adminkey, {
bolt11: args.paymentRequest,
out: true,
Expand Down
16 changes: 12 additions & 4 deletions src/extension/background-script/connectors/lnc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -289,8 +289,12 @@ class Lnc implements Connector {
preimage: utils.base64ToHex(data.paymentPreimage.toString()),
paymentHash: utils.base64ToHex(data.paymentHash.toString()),
route: {
total_amt: parseInt(data.paymentRoute?.totalAmtMsat ?? "0") / 1000,
total_fees: parseInt(data.paymentRoute?.totalFeesMsat ?? "0") / 1000,
total_amt: Math.ceil(
parseInt(data.paymentRoute?.totalAmtMsat ?? "0") / 1000
),
total_fees: Math.ceil(
parseInt(data.paymentRoute?.totalFeesMsat ?? "0") / 1000
),
},
},
};
Expand Down Expand Up @@ -334,8 +338,12 @@ class Lnc implements Connector {
preimage: utils.base64ToHex(data.paymentPreimage.toString()),
paymentHash: utils.base64ToHex(data.paymentHash.toString()),
route: {
total_amt: parseInt(data.paymentRoute?.totalAmtMsat ?? "0") / 1000,
total_fees: parseInt(data.paymentRoute?.totalFeesMsat ?? "0") / 1000,
total_amt: Math.ceil(
parseInt(data.paymentRoute?.totalAmtMsat ?? "0") / 1000
),
total_fees: Math.ceil(
parseInt(data.paymentRoute?.totalFeesMsat ?? "0") / 1000
),
},
},
};
Expand Down
4 changes: 3 additions & 1 deletion src/extension/background-script/connectors/lndhub.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import Base64 from "crypto-js/enc-base64";
import Hex from "crypto-js/enc-hex";
import hmacSHA256 from "crypto-js/hmac-sha256";
import sha256 from "crypto-js/sha256";
import { getPaymentRequestAmountSats } from "~/common/utils/paymentRequest";
import HashKeySigner from "~/common/utils/signer";
import { Account } from "~/types";

Expand Down Expand Up @@ -257,7 +258,8 @@ export default class LndHub implements Connector {
// lnbits needs to fix this and return proper route information with a total amount and fees
if (!data.payment_route) {
const paymentRequestDetails = lightningPayReq.decode(args.paymentRequest);
const amountInSats = paymentRequestDetails.satoshis || 0;
const amountInSats =
getPaymentRequestAmountSats(paymentRequestDetails) ?? 0;
data.payment_route = { total_amt: amountInSats, total_fees: 0 };
}
return {
Expand Down
3 changes: 2 additions & 1 deletion src/extension/background-script/connectors/nwc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import Base64 from "crypto-js/enc-base64";
import Hex from "crypto-js/enc-hex";
import UTF8 from "crypto-js/enc-utf8";
import SHA256 from "crypto-js/sha256";
import { getPaymentRequestAmountSats } from "~/common/utils/paymentRequest";
import { Account } from "~/types";
import Connector, {
CheckPaymentArgs,
Expand Down Expand Up @@ -158,7 +159,7 @@ class NWCConnector implements Connector {
paymentHash,
route: {
// TODO: how to get amount paid for zero-amount invoices?
total_amt: Math.floor(parseInt(invoice.millisatoshis || "0") / 1000),
total_amt: getPaymentRequestAmountSats(invoice) ?? 0,
// TODO: How to get fees?
total_fees: 0,
},
Expand Down
Loading
Loading