Implement backend API and database services in Docker setup
- Added a new `api` service for the NestJS backend, including health checks and dependencies on PostgreSQL, Redis, and MinIO. - Introduced PostgreSQL and Redis services with health checks and configurations for data persistence. - Added MinIO for S3-compatible object storage and a one-shot service to initialize required buckets. - Updated the Nginx configuration to proxy requests to the new backend API and MinIO storage. - Enhanced the Dockerfile to support the new API environment variables and configurations. - Updated the `package.json` and `package-lock.json` to include new dependencies for QR code generation and other utilities. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
351
backend/src/payment/providers/services/btcpay.service.ts
Normal file
351
backend/src/payment/providers/services/btcpay.service.ts
Normal file
@@ -0,0 +1,351 @@
|
||||
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,
|
||||
} 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;
|
||||
|
||||
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 || '';
|
||||
|
||||
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',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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 USD, paid via Lightning.
|
||||
* Returns data shaped like Strike's Invoice DTO for drop-in compatibility
|
||||
* with the existing RentsService.
|
||||
*/
|
||||
async issueInvoice(
|
||||
amount: number,
|
||||
description = 'Invoice for order',
|
||||
correlationId?: string,
|
||||
): Promise<Invoice> {
|
||||
try {
|
||||
const body = {
|
||||
amount: amount.toString(),
|
||||
currency: 'USD',
|
||||
metadata: {
|
||||
orderId: correlationId || undefined,
|
||||
itemDesc: description,
|
||||
},
|
||||
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.
|
||||
* Returns data shaped like Strike's InvoiceQuote DTO.
|
||||
*/
|
||||
async issueQuote(invoiceId: string): Promise<InvoiceQuote> {
|
||||
try {
|
||||
// Get the invoice to know the amount
|
||||
const invoice = await this.getRawInvoice(invoiceId);
|
||||
|
||||
// Get payment methods to retrieve the BOLT11
|
||||
const { data: paymentMethods } = await axios.get<BTCPayPaymentMethod[]>(
|
||||
this.storeUrl(`/invoices/${invoiceId}/payment-methods`),
|
||||
{ headers: this.headers },
|
||||
);
|
||||
|
||||
const lightning = paymentMethods.find(
|
||||
(pm) => pm.paymentMethod === 'BTC-LN' || pm.paymentMethod === 'BTC-LNURL',
|
||||
);
|
||||
|
||||
if (!lightning) {
|
||||
throw new Error('No Lightning payment method found for this invoice');
|
||||
}
|
||||
|
||||
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,
|
||||
targetAmount: {
|
||||
amount: invoice.amount,
|
||||
currency: invoice.currency,
|
||||
},
|
||||
sourceAmount: {
|
||||
amount: lightning.amount,
|
||||
currency: 'BTC',
|
||||
},
|
||||
conversionRate: {
|
||||
amount: lightning.rate,
|
||||
sourceCurrency: 'BTC',
|
||||
targetCurrency: invoice.currency,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
Logger.error('BTCPay quote retrieval failed: ' + error.message, 'BTCPayService');
|
||||
throw new BadRequestException(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
): 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);
|
||||
|
||||
// Pay the BOLT11 via BTCPay's internal Lightning node
|
||||
const { data } = await axios.post<BTCPayLightningPayResponse>(
|
||||
this.storeUrl('/lightning/BTC/invoices/pay'),
|
||||
{ BOLT11: bolt11 },
|
||||
{ headers: this.headers },
|
||||
);
|
||||
|
||||
if (data.result !== 'Ok') {
|
||||
throw new Error(
|
||||
`Lightning payment failed: ${data.result} — ${data.errorDetail || 'unknown error'}`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
id: data.paymentHash || 'btcpay-payment',
|
||||
status: 'COMPLETED',
|
||||
};
|
||||
} catch (error) {
|
||||
Logger.error('BTCPay sendPayment failed: ' + error.message, 'BTCPayService');
|
||||
throw new Error(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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,
|
||||
): 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());
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user