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 { 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( 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 { 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 { const { data: paymentMethods } = await axios.get( 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 { 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 { 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( 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 { 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 { 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 { const body: BTCPayCreateLnInvoiceRequest = { amount: amountMsat.toString(), description, expiry, privateRouteHints: true, }; const { data } = await axios.post( 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 { const { data } = await axios.get( 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 { 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, ): Promise { await axios.put( this.storeUrl(`/invoices/${invoiceId}`), { metadata }, { headers: this.headers }, ); } // ── Private Helpers ───────────────────────────────────────────────────── private async getRawInvoice(invoiceId: string): Promise { const { data } = await axios.get( 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 { 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; } }