Files
indee-demo/backend/src/payment/providers/services/btcpay.service.ts
Dorian 11d289d793 feat: add comment support for Lightning payments in BTCPay and Strike services
- Enhanced the sendPaymentWithAddress method in BTCPayService and StrikeService to accept an optional comment parameter.
- Updated resolveLightningAddress to include the comment in the callback URL if supported by the LNURL-pay endpoint.
- Modified PaymentService to construct a descriptive comment for Lightning invoices, improving clarity for users.

These changes enhance the payment experience by allowing users to include contextual information with their transactions.
2026-02-14 13:02:42 +00:00

634 lines
21 KiB
TypeScript

import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import { LightningService } from './lightning.service';
import { Payment } from '../../entities/payment.entity';
import { LightningPaymentDTO } from '../../dto/response/lightning-payment.dto';
import Invoice from '../dto/strike/invoice';
import InvoiceQuote from '../dto/strike/invoice-quote';
import axios from 'axios';
import type {
BTCPayInvoiceResponse,
BTCPayPaymentMethod,
BTCPayLightningPayResponse,
BTCPayInternalLnInvoice,
BTCPayCreateLnInvoiceRequest,
} from '../dto/btcpay/btcpay-invoice';
/**
* BTCPay Server Greenfield API client.
*
* Replaces StrikeService as the Lightning payment provider.
* Handles invoice creation (receiving payments), Lightning payouts
* (paying creator Lightning addresses), and BTC rate fetching.
*/
@Injectable()
export class BTCPayService implements LightningService {
private readonly baseUrl: string;
private readonly storeId: string;
private readonly apiKey: string;
private readonly routeHintsEnabled: boolean;
constructor() {
this.baseUrl = (process.env.BTCPAY_URL || process.env.BTCPAY_SERVER_URL || '').replace(/\/+$/, '');
this.storeId = process.env.BTCPAY_STORE_ID || '';
this.apiKey = process.env.BTCPAY_API_KEY || '';
// Create a separate internal Lightning invoice with privateRouteHints.
// Only needed when BTCPay's own store-level route hints are NOT enabled.
// Since BTCPay can include route hints natively (Lightning settings →
// "Include hop hints"), this defaults to false to avoid duplicating
// invoices. Set BTCPAY_ROUTE_HINTS=true only if you cannot enable
// route hints in BTCPay's UI and still need them.
const envHints = process.env.BTCPAY_ROUTE_HINTS;
this.routeHintsEnabled = envHints === 'true';
if (!this.baseUrl || !this.storeId || !this.apiKey) {
Logger.warn(
'BTCPay Server environment variables not fully configured (BTCPAY_URL, BTCPAY_STORE_ID, BTCPAY_API_KEY)',
'BTCPayService',
);
}
if (this.routeHintsEnabled) {
Logger.log('Private route hints enabled for Lightning invoices', 'BTCPayService');
}
}
// ── Helpers ──────────────────────────────────────────────────────────────
private get headers() {
return {
Authorization: `token ${this.apiKey}`,
'Content-Type': 'application/json',
};
}
private storeUrl(path: string): string {
return `${this.baseUrl}/api/v1/stores/${this.storeId}${path}`;
}
// ── Invoice Creation (receiving payments from users) ─────────────────────
/**
* Create a BTCPay invoice denominated in sats, paid via Lightning.
* The amount parameter is in satoshis. We convert to BTC for the
* API call since not all BTCPay versions support "SATS" as a currency.
*
* Returns data shaped like Strike's Invoice DTO for drop-in
* compatibility with the existing RentsService.
*/
async issueInvoice(
amountSats: number,
description = 'Invoice for order',
correlationId?: string,
): Promise<Invoice> {
try {
// Convert sats to BTC (1 BTC = 100,000,000 sats)
const amountBtc = (amountSats / 100_000_000).toFixed(8);
const body = {
amount: amountBtc,
currency: 'BTC',
metadata: {
orderId: correlationId || undefined,
itemDesc: description,
amountSats, // Store original sats amount for reference
},
checkout: {
paymentMethods: ['BTC-LN'],
expirationMinutes: 15,
monitoringMinutes: 60,
speedPolicy: 'HighSpeed',
},
};
const { data } = await axios.post<BTCPayInvoiceResponse>(
this.storeUrl('/invoices'),
body,
{ headers: this.headers },
);
// Map to the Invoice DTO the rest of the codebase expects
return {
invoiceId: data.id,
amount: { amount: data.amount, currency: data.currency },
description,
created: new Date(data.createdTime * 1000),
state: this.mapInvoiceState(data.status),
correlationId,
};
} catch (error) {
Logger.error('BTCPay invoice creation failed: ' + error.message, 'BTCPayService');
throw new BadRequestException(error.message);
}
}
/**
* Fetch the Lightning BOLT11 for an existing invoice.
*
* When route hints are enabled (default), this creates an internal
* Lightning invoice with `privateRouteHints: true` so that payers
* can find a route even when our node only has private channels.
* The store invoice is kept for webhook-based payment tracking.
* On subsequent polls, if the internal invoice is paid we mark the
* store invoice as complete, which triggers the normal webhook flow.
*
* Returns data shaped like Strike's InvoiceQuote DTO.
*/
async issueQuote(invoiceId: string): Promise<InvoiceQuote> {
try {
const invoice = await this.getRawInvoice(invoiceId);
if (!this.routeHintsEnabled) {
return this.issueQuoteFromPaymentMethods(invoiceId, invoice);
}
// ── Route-hints path: use the internal Lightning node API ───────
const internalLnId = invoice.metadata?.internalLnInvoiceId as
| string
| undefined;
let bolt11: string;
let expirationDate: Date;
let paid = invoice.status === 'Settled';
if (internalLnId) {
// We already created an internal LN invoice — re-fetch it
const lnInvoice = await this.getInternalLnInvoice(internalLnId);
bolt11 = lnInvoice.BOLT11;
expirationDate = new Date(lnInvoice.expiresAt * 1000);
if (lnInvoice.status === 'Paid') {
paid = true;
// Detect payment on the internal invoice and settle the store invoice
if (invoice.status !== 'Settled') {
Logger.log(
`Internal LN invoice ${internalLnId} paid — marking store invoice ${invoiceId} as complete`,
'BTCPayService',
);
await this.markInvoiceComplete(invoiceId);
}
}
} else {
// First call — create a new internal LN invoice with route hints
const amountSats =
(invoice.metadata?.amountSats as number) ||
Math.round(parseFloat(invoice.amount) * 100_000_000);
// Match expiry to the store invoice so both expire together
const expirySeconds = Math.max(
60,
Math.floor((invoice.expirationTime * 1000 - Date.now()) / 1000),
);
try {
const lnInvoice = await this.createInternalLnInvoice(
amountSats * 1000, // BTCPay internal API expects millisats
(invoice.metadata?.itemDesc as string) || 'Invoice',
expirySeconds,
);
bolt11 = lnInvoice.BOLT11;
expirationDate = new Date(lnInvoice.expiresAt * 1000);
// Persist the link so subsequent polls re-use this invoice
await this.updateInvoiceMetadata(invoiceId, {
...invoice.metadata,
internalLnInvoiceId: lnInvoice.id,
});
Logger.log(
`Created internal LN invoice ${lnInvoice.id} with route hints for store invoice ${invoiceId}`,
'BTCPayService',
);
} catch (err) {
// If the internal Lightning API is unavailable, fall back gracefully
Logger.warn(
`Failed to create internal LN invoice with route hints: ${err.message}. Falling back to store payment method BOLT11.`,
'BTCPayService',
);
return this.issueQuoteFromPaymentMethods(invoiceId, invoice);
}
}
const expirationInSec = Math.max(
0,
Math.floor((expirationDate.getTime() - Date.now()) / 1000),
);
return {
quoteId: invoiceId,
description: (invoice.metadata?.itemDesc as string) || 'Invoice',
lnInvoice: bolt11,
onchainAddress: '',
expiration: expirationDate,
expirationInSec,
paid,
targetAmount: {
amount: invoice.amount,
currency: invoice.currency,
},
sourceAmount: {
amount: invoice.amount,
currency: 'BTC',
},
conversionRate: {
amount: '1',
sourceCurrency: 'BTC',
targetCurrency: invoice.currency,
},
};
} catch (error) {
Logger.error(
'BTCPay quote retrieval failed: ' + error.message,
'BTCPayService',
);
throw new BadRequestException(error.message);
}
}
/**
* Original quote logic: fetch the BOLT11 from the store invoice's
* payment methods. Used as fallback when route hints are disabled
* or the internal Lightning API is unavailable.
*/
private async issueQuoteFromPaymentMethods(
invoiceId: string,
invoice: BTCPayInvoiceResponse,
): Promise<InvoiceQuote> {
const { data: paymentMethods } = await axios.get<BTCPayPaymentMethod[]>(
this.storeUrl(`/invoices/${invoiceId}/payment-methods`),
{ headers: this.headers },
);
// BTCPay's payment method IDs vary between versions (BTC-LN, BTC_LN,
// BTC-LightningNetwork, etc.). Match any Lightning-related method.
const lightning = paymentMethods.find((pm) => {
const id = (pm.paymentMethod || pm.paymentMethodId || '').toUpperCase();
return (
id.includes('LN') ||
id.includes('LIGHTNING') ||
id === 'BTC-LNURL'
);
});
if (!lightning) {
const availableMethods = paymentMethods.map(
(pm) => pm.paymentMethod || pm.paymentMethodId || 'unknown',
);
Logger.warn(
`No Lightning payment method found. Available methods: ${JSON.stringify(availableMethods)}`,
'BTCPayService',
);
throw new Error(
'No Lightning payment method found for this invoice. ' +
'Ensure the BTCPay store has a Lightning node configured.',
);
}
const expirationDate = new Date(invoice.expirationTime * 1000);
const expirationInSec = Math.max(
0,
Math.floor((expirationDate.getTime() - Date.now()) / 1000),
);
return {
quoteId: invoiceId,
description: (invoice.metadata?.itemDesc as string) || 'Invoice',
lnInvoice: lightning.destination,
onchainAddress: '',
expiration: expirationDate,
expirationInSec,
paid: invoice.status === 'Settled',
targetAmount: {
amount: invoice.amount,
currency: invoice.currency,
},
sourceAmount: {
amount: lightning.amount,
currency: 'BTC',
},
conversionRate: {
amount: lightning.rate,
sourceCurrency: 'BTC',
targetCurrency: invoice.currency,
},
};
}
/**
* Get the status of an existing invoice.
* Returns data shaped like Strike's Invoice DTO.
*/
async getInvoice(invoiceId: string): Promise<Invoice> {
try {
const raw = await this.getRawInvoice(invoiceId);
return {
invoiceId: raw.id,
amount: { amount: raw.amount, currency: raw.currency },
description: (raw.metadata?.itemDesc as string) || '',
created: new Date(raw.createdTime * 1000),
state: this.mapInvoiceState(raw.status),
correlationId: raw.metadata?.orderId as string,
};
} catch (error) {
Logger.error('BTCPay getInvoice failed: ' + error.message, 'BTCPayService');
throw new BadRequestException(error.message);
}
}
// ── Outgoing Lightning Payments (creator payouts) ───────────────────────
/**
* Pay a creator's Lightning address by resolving LNURL-pay,
* fetching a BOLT11, then paying it via BTCPay's Lightning node.
*/
async sendPaymentWithAddress(
address: string,
payment: Payment,
comment?: string,
): Promise<LightningPaymentDTO> {
try {
const sats = Math.floor(payment.milisatsAmount / 1000);
// Resolve the Lightning address to a BOLT11 invoice
const bolt11 = await this.resolveLightningAddress(address, sats, comment);
// Pay the BOLT11 via BTCPay's internal Lightning node
const payUrl = this.storeUrl('/lightning/BTC/invoices/pay');
Logger.log(
`Paying ${sats} sats to ${address} via ${payUrl}`,
'BTCPayService',
);
const { data } = await axios.post<BTCPayLightningPayResponse>(
payUrl,
{ BOLT11: bolt11 },
{ headers: this.headers },
);
Logger.log(
`BTCPay pay response: ${JSON.stringify(data)}`,
'BTCPayService',
);
// BTCPay Greenfield API returns different shapes depending on version.
// Older versions return { result, errorDetail, paymentHash }.
// Newer versions may return the payment hash/preimage at top level
// or an empty 200 on success.
const result = data?.result;
const paymentHash =
data?.paymentHash ?? (data as any)?.payment_hash ?? 'btcpay-payment';
if (result && result !== 'Ok') {
throw new Error(
`Lightning payment failed: ${result}${data?.errorDetail || 'unknown error'}`,
);
}
return {
id: paymentHash,
status: 'COMPLETED',
};
} catch (error) {
const detail =
error.response?.data
? JSON.stringify(error.response.data)
: error.message;
Logger.error(
`BTCPay sendPayment failed: ${detail}`,
'BTCPayService',
);
throw new Error(detail);
}
}
/**
* Validate a Lightning address by attempting to resolve it.
*/
async validateAddress(address: string): Promise<boolean> {
try {
const [username, domain] = address.split('@');
if (!username || !domain) return false;
const url = `https://${domain}/.well-known/lnurlp/${username}`;
const { status } = await axios.get(url, { timeout: 10_000 });
return status === 200;
} catch {
return false;
}
}
// ── Rate Fetching ───────────────────────────────────────────────────────
/**
* Get the current USD value of 1 satoshi.
* Uses CoinGecko as a reliable public rate source.
*/
async getSatoshiRate(): Promise<number> {
try {
// Try BTCPay's built-in rate provider first
const { data } = await axios.get(
`${this.baseUrl}/api/v1/stores/${this.storeId}/rates`,
{ headers: this.headers, timeout: 5_000 },
);
const btcUsd = data?.find?.(
(r: { currencyPair: string }) => r.currencyPair === 'BTC_USD',
);
if (btcUsd?.rate) {
return Number(btcUsd.rate) * 0.000_000_01; // Convert BTC price to sat price
}
throw new Error('Rate not found in BTCPay response');
} catch {
// Fallback to CoinGecko
try {
const { data } = await axios.get(
'https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies=usd',
{ timeout: 10_000 },
);
const btcPrice = data?.bitcoin?.usd;
if (!btcPrice) throw new Error('CoinGecko returned no BTC price');
return btcPrice * 0.000_000_01;
} catch (fallbackError) {
Logger.error('Rate fetch failed from all sources: ' + fallbackError.message, 'BTCPayService');
throw new BadRequestException('Could not get satoshi rate');
}
}
}
// ── Internal Lightning Node API (route hints) ─────────────────────────
/**
* Create a Lightning invoice directly on the node with private route
* hints enabled. This allows payers to discover routes to our node
* even when it only has private (unannounced) channels.
*
* @param amountMsat Amount in **millisatoshis**
* @param description Invoice description / memo
* @param expiry Seconds until expiry
*/
private async createInternalLnInvoice(
amountMsat: number,
description: string,
expiry: number,
): Promise<BTCPayInternalLnInvoice> {
const body: BTCPayCreateLnInvoiceRequest = {
amount: amountMsat.toString(),
description,
expiry,
privateRouteHints: true,
};
const { data } = await axios.post<BTCPayInternalLnInvoice>(
this.storeUrl('/lightning/BTC/invoices'),
body,
{ headers: this.headers },
);
return data;
}
/**
* Fetch an existing internal Lightning invoice by its ID.
*/
private async getInternalLnInvoice(
invoiceId: string,
): Promise<BTCPayInternalLnInvoice> {
const { data } = await axios.get<BTCPayInternalLnInvoice>(
this.storeUrl(`/lightning/BTC/invoices/${invoiceId}`),
{ headers: this.headers },
);
return data;
}
/**
* Mark a store invoice as complete (settled).
* This triggers the `InvoiceSettled` webhook so the normal payment
* processing pipeline (rents, subscriptions) runs automatically.
*/
private async markInvoiceComplete(invoiceId: string): Promise<void> {
try {
await axios.post(
this.storeUrl(`/invoices/${invoiceId}/status`),
{ status: 'Complete' },
{ headers: this.headers },
);
Logger.log(
`Store invoice ${invoiceId} marked as complete`,
'BTCPayService',
);
} catch (err) {
Logger.error(
`Failed to mark store invoice ${invoiceId} as complete: ${err.message}`,
'BTCPayService',
);
throw err;
}
}
/**
* Update the metadata on a store invoice (e.g. to persist the
* internal Lightning invoice ID for subsequent polls).
*/
private async updateInvoiceMetadata(
invoiceId: string,
metadata: Record<string, unknown>,
): Promise<void> {
await axios.put(
this.storeUrl(`/invoices/${invoiceId}`),
{ metadata },
{ headers: this.headers },
);
}
// ── Private Helpers ─────────────────────────────────────────────────────
private async getRawInvoice(invoiceId: string): Promise<BTCPayInvoiceResponse> {
const { data } = await axios.get<BTCPayInvoiceResponse>(
this.storeUrl(`/invoices/${invoiceId}`),
{ headers: this.headers },
);
return data;
}
/**
* Map BTCPay invoice status to Strike-compatible state string.
*/
private mapInvoiceState(
status: BTCPayInvoiceResponse['status'],
): Invoice['state'] {
switch (status) {
case 'Settled':
return 'PAID';
case 'Processing':
return 'PENDING';
case 'Expired':
case 'Invalid':
return 'CANCELLED';
case 'New':
default:
return 'UNPAID';
}
}
/**
* Resolve a Lightning address (user@domain.com) to a BOLT11 invoice
* via the LNURL-pay protocol.
*/
private async resolveLightningAddress(
address: string,
amountSats: number,
comment?: string,
): Promise<string> {
const [username, domain] = address.split('@');
if (!username || !domain) {
throw new Error(`Invalid Lightning address: ${address}`);
}
// Step 1: Fetch the LNURL-pay metadata
const metadataUrl = `https://${domain}/.well-known/lnurlp/${username}`;
const { data: lnurlData } = await axios.get(metadataUrl, { timeout: 10_000 });
if (lnurlData.status === 'ERROR') {
throw new Error(`LNURL error: ${lnurlData.reason}`);
}
const amountMillisats = amountSats * 1000;
const minSendable = lnurlData.minSendable || 1000;
const maxSendable = lnurlData.maxSendable || 1_000_000_000_000;
if (amountMillisats < minSendable || amountMillisats > maxSendable) {
throw new Error(
`Amount ${amountSats} sats is outside the allowed range (${minSendable / 1000}-${maxSendable / 1000} sats)`,
);
}
// Step 2: Request the BOLT11 invoice
const callbackUrl = new URL(lnurlData.callback);
callbackUrl.searchParams.set('amount', amountMillisats.toString());
// Include a descriptive comment if the endpoint supports it.
// LNURL-pay endpoints advertise commentAllowed (max char length).
const commentAllowed = lnurlData.commentAllowed || 0;
if (comment && commentAllowed > 0) {
callbackUrl.searchParams.set(
'comment',
comment.slice(0, commentAllowed),
);
}
const { data: invoiceData } = await axios.get(callbackUrl.toString(), { timeout: 10_000 });
if (invoiceData.status === 'ERROR') {
throw new Error(`LNURL callback error: ${invoiceData.reason}`);
}
if (!invoiceData.pr) {
throw new Error('No payment request (BOLT11) returned from LNURL callback');
}
return invoiceData.pr;
}
}