From 1d5c473041dd9bfb985be3c9c8ba321aff42b429 Mon Sep 17 00:00:00 2001 From: ABHAY PANDEY Date: Mon, 9 Nov 2026 08:28:34 +0530 Subject: [PATCH 1/9] feat(peering): add paid peering billing ledger - add invoice purpose, term snapshots and the peering_payments table - add the peering payment repository and invoice period update - create and settle peering invoices outside the admission/user flow - skip the user balance credit for peering invoices - validate paid terms and add repository coverage --- .changeset/paid-peering-ledger.md | 5 + .../20261109_120000_add_peering_billing.js | 130 +++++++ src/@types/invoice.ts | 15 + src/@types/operator-notifications.ts | 8 + src/@types/peering.ts | 63 ++++ src/@types/repositories.ts | 16 + src/@types/services.ts | 14 +- src/constants/base.ts | 2 + src/repositories/invoice-repository.ts | 27 +- src/services/payments-service.ts | 269 ++++++++++++++- src/utils/peering-billing.ts | 136 ++++++++ src/utils/transform.ts | 30 ++ test/unit/services/payments-service.spec.ts | 317 +++++++++++++++++- test/unit/utils/peering-billing.spec.ts | 76 +++++ 14 files changed, 1093 insertions(+), 15 deletions(-) create mode 100644 .changeset/paid-peering-ledger.md create mode 100644 migrations/20261109_120000_add_peering_billing.js create mode 100644 src/utils/peering-billing.ts create mode 100644 test/unit/utils/peering-billing.spec.ts diff --git a/.changeset/paid-peering-ledger.md b/.changeset/paid-peering-ledger.md new file mode 100644 index 000000000..2c1566eb9 --- /dev/null +++ b/.changeset/paid-peering-ledger.md @@ -0,0 +1,5 @@ +--- +"nostream": minor +--- + +feat(peering): add the paid peering billing ledger diff --git a/migrations/20261109_120000_add_peering_billing.js b/migrations/20261109_120000_add_peering_billing.js new file mode 100644 index 000000000..be52cad4c --- /dev/null +++ b/migrations/20261109_120000_add_peering_billing.js @@ -0,0 +1,130 @@ +exports.up = async function (knex) { + await knex.schema.alterTable('invoices', (table) => { + table.enum('purpose', ['admission', 'subscription', 'peering']).nullable() + table.bigint('term_amount_msats').unsigned().nullable() + table.integer('term_period_seconds').nullable() + table.index(['purpose', 'status'], 'idx_invoices_purpose_status') + }) + + // Existing rows predate the purpose column. A plan means a subscription; no plan means the + // admission-fee flow. Peering invoices always set purpose explicitly. + await knex.raw(` + UPDATE invoices + SET purpose = CASE WHEN plan_id IS NULL THEN 'admission' ELSE 'subscription' END + WHERE purpose IS NULL + `) + + await knex.raw('ALTER TABLE invoices ALTER COLUMN purpose SET DEFAULT \'admission\'') + await knex.raw('ALTER TABLE invoices ALTER COLUMN purpose SET NOT NULL') + + // confirm_invoice() credits the payer's user balance. A peering invoice is paid + // by another relay, not by a user, so skip the balance credit for that purpose. + await knex.raw(` + CREATE OR REPLACE FUNCTION confirm_invoice(invoice_id TEXT, amount_received BIGINT, confirmation_date TIMESTAMP WITHOUT TIME ZONE) +RETURNS INTEGER +LANGUAGE plpgsql +AS $$ +DECLARE + payee BYTEA; + confirmed_date TIMESTAMP WITHOUT TIME ZONE; + invoice_unit TEXT; + invoice_purpose TEXT; +BEGIN + PERFORM ASSERT_SERIALIZED(); + + SELECT "pubkey", "confirmed_at", "unit", "purpose" + INTO payee, confirmed_date, invoice_unit, invoice_purpose + FROM "invoices" + WHERE id = invoice_id; + + IF confirmed_date IS NULL THEN + UPDATE invoices + SET + "confirmed_at" = confirmation_date, + "amount_paid" = amount_received, + "updated_at" = now_utc() + WHERE id = invoice_id; + + IF invoice_purpose <> 'peering' THEN + IF invoice_unit = 'sats' THEN + UPDATE users SET balance = balance + amount_received * 1000 WHERE "pubkey" = payee; + ELSIF invoice_unit = 'msats' THEN + UPDATE users SET balance = balance + amount_received WHERE "pubkey" = payee; + ELSIF invoice_unit = 'btc' THEN + UPDATE users SET balance = balance + amount_received * 100000000 * 1000 WHERE "pubkey" = payee; + END IF; + END IF; + END IF; + RETURN 0; +END; +$$;`) + + await knex.schema.createTable('peering_payments', (table) => { + // A peer can both buy from and sell to this relay, so direction is part of the key. + table.binary('peer_pubkey').notNullable() + table.enum('direction', ['sell', 'buy']).notNullable().defaultTo('sell') + table + .enum('status', ['unpaid', 'invoiced', 'active', 'renewal_pending', 'lapsed']) + .notNullable() + .defaultTo('unpaid') + table.text('current_invoice_id').nullable() + table.text('renewal_invoice_id').nullable() + table.timestamp('period_start', { useTz: true }).nullable() + table.timestamp('period_end', { useTz: true }).nullable() + table.bigint('term_amount_msats').unsigned().nullable() + table.integer('term_period_seconds').nullable() + table.text('last_settled_invoice_id').nullable() + table.timestamp('last_settled_at', { useTz: true }).nullable() + table.timestamp('last_checked_at', { useTz: true }).nullable() + table.text('last_error').nullable() + table.timestamp('created_at', { useTz: true }).notNullable().defaultTo(knex.fn.now()) + table.timestamp('updated_at', { useTz: true }).notNullable().defaultTo(knex.fn.now()) + + table.primary(['peer_pubkey', 'direction']) + table.index(['status', 'period_end'], 'idx_peering_payments_status_period_end') + }) +} + +exports.down = async function (knex) { + await knex.schema.dropTable('peering_payments') + + // Restore the previous confirm_invoice() before the purpose column disappears. + await knex.raw(` + CREATE OR REPLACE FUNCTION confirm_invoice(invoice_id TEXT, amount_received BIGINT, confirmation_date TIMESTAMP WITHOUT TIME ZONE) +RETURNS INTEGER +LANGUAGE plpgsql +AS $$ +DECLARE + payee BYTEA; + confirmed_date TIMESTAMP WITHOUT TIME ZONE; + invoice_unit TEXT; +BEGIN + PERFORM ASSERT_SERIALIZED(); + + SELECT "pubkey", "confirmed_at", "unit" INTO payee, confirmed_date, invoice_unit FROM "invoices" WHERE id = invoice_id; + IF confirmed_date IS NULL THEN + UPDATE invoices + SET + "confirmed_at" = confirmation_date, + "amount_paid" = amount_received, + "updated_at" = now_utc() + WHERE id = invoice_id; + IF invoice_unit = 'sats' THEN + UPDATE users SET balance = balance + amount_received * 1000 WHERE "pubkey" = payee; + ELSIF invoice_unit = 'msats' THEN + UPDATE users SET balance = balance + amount_received WHERE "pubkey" = payee; + ELSIF invoice_unit = 'btc' THEN + UPDATE users SET balance = balance + amount_received * 100000000 * 1000 WHERE "pubkey" = payee; + END IF; + END IF; + RETURN 0; +END; +$$;`) + + await knex.schema.alterTable('invoices', (table) => { + table.dropIndex(['purpose', 'status'], 'idx_invoices_purpose_status') + table.dropColumn('term_amount_msats') + table.dropColumn('term_period_seconds') + table.dropColumn('purpose') + }) +} diff --git a/src/@types/invoice.ts b/src/@types/invoice.ts index 6ca842785..f6cd05133 100644 --- a/src/@types/invoice.ts +++ b/src/@types/invoice.ts @@ -12,6 +12,12 @@ export enum InvoiceStatus { EXPIRED = 'expired', } +export enum InvoicePurpose { + ADMISSION = 'admission', + SUBSCRIPTION = 'subscription', + PEERING = 'peering', +} + export interface Invoice { id: string pubkey: Pubkey @@ -26,10 +32,16 @@ export interface Invoice { updatedAt: Date createdAt: Date verifyURL?: string + /** What this invoice pays for. Older rows are admission or subscription. */ + purpose?: InvoicePurpose | null /** Subscription plan this invoice pays for, when it is not an admission fee. */ planId?: string | null periodStart?: Date | null periodEnd?: Date | null + /** Peering term snapshot used to issue this invoice. */ + termAmountMsats?: bigint | null + /** Peering period length in seconds for this invoice. */ + termPeriodSeconds?: number | null } export interface LnurlInvoice extends Invoice { @@ -50,7 +62,10 @@ export interface DBInvoice { updated_at: Date created_at: Date verify_url: string + purpose?: InvoicePurpose | null plan_id?: string | null period_start?: Date | null period_end?: Date | null + term_amount_msats?: string | null + term_period_seconds?: number | null } diff --git a/src/@types/operator-notifications.ts b/src/@types/operator-notifications.ts index 880dfe4f2..31395b476 100644 --- a/src/@types/operator-notifications.ts +++ b/src/@types/operator-notifications.ts @@ -2,6 +2,10 @@ export enum OperatorNotificationEventType { ADMISSION_INVOICE_CREATED = 'admission.invoice.created', ADMISSION_INVOICE_PAID = 'admission.invoice.paid', ADMISSION_INVOICE_FAILED = 'admission.invoice.failed', + PEERING_INVOICE_CREATED = 'peering.invoice.created', + PEERING_INVOICE_PAID = 'peering.invoice.paid', + PEERING_INVOICE_FAILED = 'peering.invoice.failed', + PEERING_ACCESS_LAPSED = 'peering.access.lapsed', SETTINGS_CHANGED = 'settings.changed', RELAY_RESTARTED = 'relay.restarted', PROBE_FAILED = 'probe.failed', @@ -26,6 +30,10 @@ export interface OperatorNotificationEventToggles { 'admission.invoice.created'?: boolean 'admission.invoice.paid'?: boolean 'admission.invoice.failed'?: boolean + 'peering.invoice.created'?: boolean + 'peering.invoice.paid'?: boolean + 'peering.invoice.failed'?: boolean + 'peering.access.lapsed'?: boolean 'settings.changed'?: boolean 'relay.restarted'?: boolean 'probe.failed'?: boolean diff --git a/src/@types/peering.ts b/src/@types/peering.ts index fecb40cf2..f257df599 100644 --- a/src/@types/peering.ts +++ b/src/@types/peering.ts @@ -1,3 +1,4 @@ +import { Pubkey } from './base' import { PeeringScope, PeeringTerm } from './settings' /** One scope advertised by a peer in its NIP-11 `peering` section. */ @@ -14,6 +15,68 @@ export interface PeeringAdvertisement { export type PeeringSyncStatus = 'idle' | 'connecting' | 'reconciling' | 'live' | 'error' | 'stopped' +export enum PeeringPaymentDirection { + SELL = 'sell', + BUY = 'buy', +} + +export enum PeeringPaymentStatus { + UNPAID = 'unpaid', + INVOICED = 'invoiced', + ACTIVE = 'active', + RENEWAL_PENDING = 'renewal_pending', + LAPSED = 'lapsed', +} + +/** Billing state for one peer relationship in one direction. */ +export interface PeeringPayment { + peerPubkey: Pubkey + direction: PeeringPaymentDirection + status: PeeringPaymentStatus + currentInvoiceId?: string | null + renewalInvoiceId?: string | null + periodStart?: Date | null + periodEnd?: Date | null + termAmountMsats?: bigint | null + termPeriodSeconds?: number | null + lastSettledInvoiceId?: string | null + lastSettledAt?: Date | null + lastCheckedAt?: Date | null + lastError?: string | null + createdAt: Date + updatedAt: Date +} + +export interface DBPeeringPayment { + peer_pubkey: Buffer + direction: PeeringPaymentDirection + status: PeeringPaymentStatus + current_invoice_id: string | null + renewal_invoice_id: string | null + period_start: Date | null + period_end: Date | null + term_amount_msats: string | null + term_period_seconds: number | null + last_settled_invoice_id: string | null + last_settled_at: Date | null + last_checked_at: Date | null + last_error: string | null + created_at: Date + updated_at: Date +} + +export interface PeeringAccess { + allowed: boolean + status: PeeringPaymentStatus | 'free' | 'unconfigured' + periodEnd?: Date | null + reason?: string +} + +export interface PeeringTermSnapshot { + amountMsats: bigint + periodSeconds: number +} + /** * Per-peer sync state. Written by the peering-sync worker and read by the admin * console; `lagSeconds` is how old the newest event we hold in a current scope diff --git a/src/@types/repositories.ts b/src/@types/repositories.ts index 3da242738..15d117fa0 100644 --- a/src/@types/repositories.ts +++ b/src/@types/repositories.ts @@ -12,6 +12,7 @@ import { NotificationDeliveryStatus, OperatorNotificationChannelType, } from './operator-notifications' +import { PeeringPayment } from './peering' import { Report } from './report' import { EventKindsRange } from './settings' import { SubscriptionFilter } from './subscription' @@ -72,9 +73,24 @@ export interface IInvoiceRepository { upsert(invoice: Partial, client?: DatabaseClient): Promise updateStatus(invoice: Pick, client?: DatabaseClient): Promise confirmInvoice(invoiceId: string, amountReceived: bigint, confirmedAt: Date, client?: DatabaseClient): Promise + updatePeriod(invoiceId: string, periodStart: Date, periodEnd: Date, client?: DatabaseClient): Promise findPendingInvoices(offset?: number, limit?: number, client?: DatabaseClient): Promise } +export interface IPeeringPaymentRepository { + findByPeer(pubkey: Pubkey, client?: DatabaseClient): Promise + /** Inserts an unpaid row when one does not exist, so the following lock always finds it. */ + ensureRow(pubkey: Pubkey, client: DatabaseClient): Promise + /** SELECT ... FOR UPDATE; must run inside a transaction. */ + lockByPeer(pubkey: Pubkey, client: DatabaseClient): Promise + save(payment: PeeringPayment, client?: DatabaseClient): Promise + list(): Promise + /** Active or renewal-pending relationships whose period ends by `before`. */ + findDueForRenewal(before: Date, limit: number, offset: number): Promise + /** Active or renewal-pending relationships whose period has ended by `now`. */ + findPeriodEnded(now: Date, limit: number, offset: number): Promise +} + export interface IUserSubscriptionRepository { findByPubkey(pubkey: Pubkey, client?: DatabaseClient): Promise upsert(subscription: Omit, client?: DatabaseClient): Promise diff --git a/src/@types/services.ts b/src/@types/services.ts index 21a04c177..5520ee798 100644 --- a/src/@types/services.ts +++ b/src/@types/services.ts @@ -1,4 +1,4 @@ -import { Pubkey } from './base' +import { DatabaseTransaction, Pubkey } from './base' import { Invoice } from './invoice' import { NotificationOutboxPayload } from './notification-outbox' @@ -37,6 +37,18 @@ export interface IPaymentsService { sendInvoiceUpdateNotification(invoice: Invoice): Promise /** Tells the subscriber, with a kind 402 event, that their renewal invoice can be paid. */ sendRenewalNotification(invoice: Invoice): Promise + /** Creates a peering invoice outside the admission/user flow. */ + createPeeringInvoice( + pubkey: Pubkey, + amountMsats: bigint, + description: string, + termPeriodSeconds: number, + client?: DatabaseTransaction, + ): Promise + /** Publishes a peering invoice as a signed, p-tagged kind 402 event. */ + sendPeeringInvoiceNotification(invoice: Invoice): Promise + /** Publishes only when no unexpired kind 402 event for this invoice exists. */ + ensurePeeringInvoiceNotification(invoice: Invoice): Promise getPendingInvoices(offset?: number): Promise } diff --git a/src/constants/base.ts b/src/constants/base.ts index 02e1706fe..d82f1fc0e 100644 --- a/src/constants/base.ts +++ b/src/constants/base.ts @@ -94,6 +94,8 @@ export enum EventTags { Deduplication = 'd', Expiration = 'expiration', Invoice = 'bolt11', + /** NIP-12 topic tag, also used to mark peering invoice notifications. */ + Tag = 't', // NIP-03: target event kind on an OpenTimestamps attestation Kind = 'k', // NIP-25: Reactions diff --git a/src/repositories/invoice-repository.ts b/src/repositories/invoice-repository.ts index 36734916e..8116a8d94 100644 --- a/src/repositories/invoice-repository.ts +++ b/src/repositories/invoice-repository.ts @@ -1,4 +1,4 @@ -import { always, applySpec, head, ifElse, is, map, omit, pipe, prop, propSatisfies, toString } from 'ramda' +import { always, applySpec, head, ifElse, is, isNil, map, omit, pipe, prop, propSatisfies, toString } from 'ramda' import { DBInvoice, Invoice, InvoiceStatus } from '../@types/invoice' import { fromDBInvoice, toBuffer } from '../utils/transform' @@ -29,6 +29,21 @@ export class InvoiceRepository implements IInvoiceRepository { } } + public async updatePeriod( + invoiceId: string, + periodStart: Date, + periodEnd: Date, + client: DatabaseClient = this.dbClient, + ): Promise { + await client('invoices') + .update({ + period_start: periodStart, + period_end: periodEnd, + updated_at: new Date(), + }) + .where('id', invoiceId) + } + public async findById(id: string, client: DatabaseClient = this.dbClient): Promise { const [dbInvoice] = await client('invoices').where('id', id).select() @@ -92,9 +107,16 @@ export class InvoiceRepository implements IInvoiceRepository { updated_at: always(new Date()), created_at: prop('createdAt'), verify_url: prop('verifyURL'), + purpose: prop('purpose'), plan_id: prop('planId'), period_start: prop('periodStart'), period_end: prop('periodEnd'), + term_amount_msats: ifElse( + propSatisfies((value) => isNil(value), 'termAmountMsats'), + always(null), + pipe(prop('termAmountMsats') as () => bigint, toString), + ), + term_period_seconds: prop('termPeriodSeconds'), })(invoice) logger('row: %o', row) @@ -113,9 +135,12 @@ export class InvoiceRepository implements IInvoiceRepository { 'expires_at', 'created_at', 'verify_url', + 'purpose', 'plan_id', 'period_start', 'period_end', + 'term_amount_msats', + 'term_period_seconds', ])(row), ) diff --git a/src/services/payments-service.ts b/src/services/payments-service.ts index 41cfb112e..461b0c07c 100644 --- a/src/services/payments-service.ts +++ b/src/services/payments-service.ts @@ -1,25 +1,28 @@ import { andThen, otherwise, pipe } from 'ramda' import { broadcastEvent, getPublicKey, getRelayPrivateKey, identifyEvent, signEvent } from '../utils/event' -import { DatabaseClient, Pubkey } from '../@types/base' +import { DatabaseClient, DatabaseTransaction, Pubkey, Tag } from '../@types/base' import { FeeSchedule, Settings } from '../@types/settings' import { IEventRepository, IInvoiceRepository, INotificationOutboxRepository, + IPeeringPaymentRepository, IUserRepository, IUserSubscriptionRepository, } from '../@types/repositories' import { OperatorNotificationEventType } from '../@types/operator-notifications' -import { Invoice, InvoiceStatus, InvoiceUnit } from '../@types/invoice' +import { Invoice, InvoicePurpose, InvoiceStatus, InvoiceUnit } from '../@types/invoice' import { Event, ExpiringEvent, UnidentifiedEvent } from '../@types/event' import { EventExpirationTimeMetadataKey, EventKinds, EventTags } from '../constants/base' import { createLogger } from '../factories/logger-factory' import { IPaymentsProcessor } from '../@types/clients' import { IPaymentsService } from '../@types/services' +import { PeeringPaymentStatus } from '../@types/peering' import { Transaction } from '../database/transaction' -import { applyPayment, areSubscriptionsEnabled, findSubscriptionPlan } from '../utils/subscription' +import { applyPayment, findSubscriptionPlan } from '../utils/subscription' import { resolveInvoiceExpiry } from '../utils/invoice' +import { PEERING_INVOICE_TAG, peeringPeriodEnd } from '../utils/peering-billing' const logger = createLogger('payments-service') @@ -36,6 +39,7 @@ export class PaymentsService implements IPaymentsService { private readonly settings: () => Settings, private readonly notificationOutboxRepository: INotificationOutboxRepository, private readonly userSubscriptionRepository?: IUserSubscriptionRepository, + private readonly peeringPaymentRepository?: IPeeringPaymentRepository, ) {} public async getPendingInvoices(offset = 0): Promise { @@ -96,6 +100,7 @@ export class PaymentsService implements IPaymentsService { updatedAt: date, createdAt: date, verifyURL: invoiceResponse.verifyURL, + purpose: planId ? InvoicePurpose.SUBSCRIPTION : InvoicePurpose.ADMISSION, planId, }, transaction.transaction, @@ -126,6 +131,7 @@ export class PaymentsService implements IPaymentsService { updatedAt: date, createdAt: invoiceResponse.createdAt, verifyURL: invoiceResponse.verifyURL, + purpose: planId ? InvoicePurpose.SUBSCRIPTION : InvoicePurpose.ADMISSION, planId, } } catch (error) { @@ -136,6 +142,89 @@ export class PaymentsService implements IPaymentsService { } } + /** + * Creates a peering invoice without touching the user table or the admission + * notifications. The caller may already hold a transaction (the billing + * service locks the peer row before asking the processor), in which case this + * method joins it instead of opening a nested one. + */ + public async createPeeringInvoice( + pubkey: Pubkey, + amountMsats: bigint, + description: string, + termPeriodSeconds: number, + client?: DatabaseTransaction, + ): Promise { + logger('create peering invoice for %s for %s msats', pubkey, amountMsats.toString()) + + const transaction = client ? undefined : new Transaction(this.dbClient) + if (transaction) { + await transaction.begin() + } + const db = client ?? transaction!.transaction + + try { + const invoiceResponse = await this.paymentsProcessor.createInvoice({ + amount: amountMsats, + description, + requestId: pubkey, + }) + + const date = new Date() + const expiresAt = resolveInvoiceExpiry( + invoiceResponse.expiresAt, + date, + this.settings()?.payments?.invoiceExpirySeconds, + ) + + const invoice: Invoice = { + id: invoiceResponse.id, + pubkey, + bolt11: invoiceResponse.bolt11, + amountRequested: invoiceResponse.amountRequested, + description: invoiceResponse.description, + unit: invoiceResponse.unit, + status: invoiceResponse.status, + expiresAt, + updatedAt: date, + createdAt: date, + verifyURL: invoiceResponse.verifyURL, + purpose: InvoicePurpose.PEERING, + planId: null, + termAmountMsats: amountMsats, + termPeriodSeconds, + } + + await this.invoiceRepository.upsert(invoice, db) + + await this.notificationOutboxRepository.enqueue( + OperatorNotificationEventType.PEERING_INVOICE_CREATED, + { + invoiceId: invoice.id, + pubkey, + amountRequested: invoice.amountRequested.toString(), + unit: invoice.unit, + termAmountMsats: amountMsats.toString(), + termPeriodSeconds, + }, + db, + ) + + if (transaction) { + await transaction.commit() + } + + return invoice + } catch (error) { + if (transaction) { + await transaction.rollback() + } + logger.error('Unable to create peering invoice:', error) + + throw error + } + } + public async updateInvoice(invoice: Partial): Promise { logger('update invoice %s: %o', invoice.id, invoice) try { @@ -179,12 +268,11 @@ export class PaymentsService implements IPaymentsService { await transaction.begin() const currentSettings = this.settings() - const subscriptionsEnabled = areSubscriptionsEnabled(currentSettings) - // The callbacks only pass what their processor reports, so the plan, the unit and whether the - // invoice was already confirmed come from the stored invoice, read before it is confirmed. - const stored = subscriptionsEnabled - ? await this.invoiceRepository.findById(invoice.id, transaction.transaction) - : undefined + // The callbacks only pass what their processor reports, so the purpose, plan, unit and + // whether the invoice was already confirmed come from the stored invoice. This read must + // happen before confirmation and must not be gated on subscriptions: peering invoices + // need it even when subscription plans are off. + const stored = await this.invoiceRepository.findById(invoice.id, transaction.transaction) await this.invoiceRepository.confirmInvoice( invoice.id, @@ -202,6 +290,41 @@ export class PaymentsService implements IPaymentsService { amountPaidMsat *= 1000n * 100000000n } + if (stored?.purpose === InvoicePurpose.PEERING) { + // The stored row is read inside this transaction. Once an invoice is confirmed, + // a redelivered older callback must not buy another period even if the peer has + // since settled a newer invoice. + if (stored.confirmedAt) { + await transaction.commit() + return + } + + const result = await this.settlePeeringInvoice(invoice, stored, amountPaidMsat, transaction.transaction) + + if (result.duplicate) { + await transaction.commit() + return + } + + await this.notificationOutboxRepository.enqueue( + result.activated + ? OperatorNotificationEventType.PEERING_INVOICE_PAID + : OperatorNotificationEventType.PEERING_INVOICE_FAILED, + { + invoiceId: invoice.id, + pubkey: invoice.pubkey, + amountPaid: invoice.amountPaid.toString(), + unit, + confirmedAt: invoice.confirmedAt.toISOString(), + reason: result.reason, + }, + transaction.transaction, + ) + + await transaction.commit() + return + } + const isApplicableFee = (feeSchedule: FeeSchedule) => feeSchedule.enabled && !feeSchedule.whitelists?.pubkeys?.includes(invoice.pubkey) const admissionFeeSchedules = currentSettings.payments?.feeSchedules?.admission ?? [] @@ -252,6 +375,126 @@ export class PaymentsService implements IPaymentsService { } } + private async settlePeeringInvoice( + invoice: Invoice, + stored: Invoice, + amountPaidMsat: bigint, + client: DatabaseTransaction, + ): Promise<{ activated: boolean; duplicate?: boolean; reason?: string }> { + if (!this.peeringPaymentRepository) { + throw new Error('Peering payment repository is not configured') + } + + const termAmountMsats = stored.termAmountMsats ?? 0n + const termPeriodSeconds = stored.termPeriodSeconds ?? 0 + + if (termAmountMsats <= 0n || termPeriodSeconds <= 0) { + throw new Error(`Peering invoice ${invoice.id} has no term snapshot`) + } + + const payment = await this.peeringPaymentRepository.lockByPeer(invoice.pubkey, client) + if (!payment) { + throw new Error(`Peering payment row is missing for ${invoice.pubkey}`) + } + + if (payment.lastSettledInvoiceId === invoice.id) { + // A duplicate callback or a worker poll after the webhook: the period was already granted. + return { activated: true, duplicate: true } + } + + const confirmedAt = invoice.confirmedAt as Date + + if (amountPaidMsat < termAmountMsats) { + logger( + 'peering invoice %s paid %s msats, below term %s msats', + invoice.id, + amountPaidMsat.toString(), + termAmountMsats.toString(), + ) + + await this.peeringPaymentRepository.save( + { + ...payment, + lastCheckedAt: confirmedAt, + lastError: `underpaid: received ${amountPaidMsat.toString()} of ${termAmountMsats.toString()} msats`, + }, + client, + ) + + return { activated: false, reason: 'underpaid' } + } + + // Paying early extends from the current period end; paying after a lapse starts now. + const stillRunning = payment.periodEnd instanceof Date && payment.periodEnd.getTime() > confirmedAt.getTime() + const periodStart = stillRunning ? (payment.periodEnd as Date) : confirmedAt + const periodEnd = peeringPeriodEnd(periodStart, termPeriodSeconds) + + await this.peeringPaymentRepository.save( + { + ...payment, + status: PeeringPaymentStatus.ACTIVE, + currentInvoiceId: null, + renewalInvoiceId: null, + periodStart, + periodEnd, + lastSettledInvoiceId: invoice.id, + lastSettledAt: confirmedAt, + lastCheckedAt: confirmedAt, + lastError: null, + }, + client, + ) + + // The invoice is the ledger entry for the period it bought; the peer row is the live window. + await this.invoiceRepository.updatePeriod(invoice.id, periodStart, periodEnd, client) + + return { activated: true } + } + + /** + * A peer may have missed the original broadcast. Republish only when the + * expiring kind 402 event is not already stored, so a re-request does not + * create one event per poll. + */ + public async ensurePeeringInvoiceNotification(invoice: Invoice): Promise { + const existing = await this.eventRepository.findByFilters( + [ + { + kinds: [EventKinds.INVOICE_UPDATE], + '#i': [invoice.id], + limit: 1, + }, + ], + { excludeDeleted: true }, + ) + + if (existing.length > 0) { + return + } + + await this.sendPeeringInvoiceNotification(invoice) + } + + /** Publishes the peering invoice as a signed, p-tagged kind 402 event. */ + public async sendPeeringInvoiceNotification(invoice: Invoice): Promise { + logger('peering invoice notification %s: %o', invoice.id, invoice) + + const periodSeconds = invoice.termPeriodSeconds ?? 0 + const days = periodSeconds > 0 ? Math.max(1, Math.round(periodSeconds / 86_400)) : 0 + const content = `Paid peering invoice: ${invoice.amountRequested.toString()} ${invoice.unit}${ + days ? ` for ${days} day(s)` : '' + }` + + const expiration = invoice.expiresAt ?? new Date(Date.now() + 24 * 60 * 60 * 1000) + + const extraTags: Tag[] = [ + [EventTags.Tag, PEERING_INVOICE_TAG], + [EventTags.Index, invoice.id], + ] + + await this.publishInvoiceNotification(invoice, content, expiration, extraTags) + } + public async sendInvoiceUpdateNotification(invoice: Invoice): Promise { logger('invoice updated notification %s: %o', invoice.id, invoice) @@ -290,7 +533,12 @@ export class PaymentsService implements IPaymentsService { await this.publishInvoiceNotification(invoice, `Subscription renewal due: ${amount.toString()} ${unit}`, expiration) } - private async publishInvoiceNotification(invoice: Invoice, content: string, expiration: Date): Promise { + private async publishInvoiceNotification( + invoice: Invoice, + content: string, + expiration: Date, + extraTags: Tag[] = [], + ): Promise { const { info: { relay_url: relayUrl }, } = this.settings() @@ -307,6 +555,7 @@ export class PaymentsService implements IPaymentsService { content, tags: [ [EventTags.Pubkey, invoice.pubkey], + ...extraTags, [EventTags.Invoice, invoice.bolt11], [EventTags.Expiration, Math.floor(expiration.getTime() / 1000).toString()], ], diff --git a/src/utils/peering-billing.ts b/src/utils/peering-billing.ts new file mode 100644 index 000000000..63bb589e9 --- /dev/null +++ b/src/utils/peering-billing.ts @@ -0,0 +1,136 @@ +import { PeeringTermSnapshot } from '../@types/peering' +import { Settings } from '../@types/settings' +import { SubscriptionFilter } from '../@types/subscription' +import { EventKinds } from '../constants/base' + +/** Topic tag carried by every peering invoice notification. */ +export const PEERING_INVOICE_TAG = 'peering' + +/** How long before a paid period ends the relay offers its renewal. */ +export const PEERING_RENEWAL_LEAD_SECONDS = 3 * 24 * 60 * 60 + +/** A peer that just let an invoice expire cannot immediately mint another one. */ +export const PEERING_REISSUE_COOLDOWN_SECONDS = 15 + +/** Do not ask the processor about the same unpaid peer more often than this. */ +export const PEERING_RECHECK_COOLDOWN_SECONDS = 30 + +const UNIT_MULTIPLIER: Record = { + msats: 1n, + sats: 1000n, + btc: 100_000_000_000n, +} + +export class InvalidPeeringTermError extends Error { + public constructor(message: string) { + super(message) + this.name = 'InvalidPeeringTermError' + } +} + +export class PeeringInvoiceCooldownError extends Error { + public constructor(public readonly retryAfterSeconds: number) { + super(`peering invoice reissue is cooling down for ${retryAfterSeconds}s`) + this.name = 'PeeringInvoiceCooldownError' + } +} + +const toMsats = (amount: number, unit: string): bigint | undefined => { + const multiplier = UNIT_MULTIPLIER[unit.trim().toLowerCase()] + if (!multiplier) { + return undefined + } + + return BigInt(amount) * multiplier +} + +/** + * The paid term this relay sells. An empty list, or only zero-amount terms, means + * settlement-free peering and returns undefined. A configured paid term that cannot + * be charged safely throws instead of silently granting free access. + */ +export const resolvePaidPeeringTerm = (settings: Settings): PeeringTermSnapshot | undefined => { + const terms = settings.peering?.terms ?? [] + + for (const term of terms) { + if (typeof term !== 'object' || term === null) { + throw new InvalidPeeringTermError('peering term must be an object') + } + + if (typeof term.amount !== 'number' || !Number.isSafeInteger(term.amount)) { + throw new InvalidPeeringTermError('peering term amount must be a safe integer') + } + + // Zero is the explicit settlement-free term. Every other malformed amount must + // fail closed: returning undefined would grant free peering by accident. + if (term.amount === 0) { + continue + } + + if (term.amount < 0) { + throw new InvalidPeeringTermError('peering term amount must not be negative') + } + + const amountMsats = toMsats(term.amount, term.unit) + if (amountMsats === undefined) { + throw new InvalidPeeringTermError(`unsupported peering term unit "${term.unit}"; use msats, sats or btc`) + } + + if (amountMsats <= 0n) { + throw new InvalidPeeringTermError('peering term amount must be positive') + } + + // The processor adapters take msats but several truncate to whole sats. A + // sub-satoshi term would silently undercharge, so refuse it at the boundary. + if (amountMsats % 1000n !== 0n) { + throw new InvalidPeeringTermError('peering term amount must be a whole number of satoshis') + } + + if (!(typeof term.period === 'number' && Number.isSafeInteger(term.period) && term.period > 0)) { + throw new InvalidPeeringTermError('peering term period must be a positive number of seconds') + } + + return { + amountMsats, + periodSeconds: term.period, + } + } + + return undefined +} + +export const isPeeringInvoiceFilter = (filter: SubscriptionFilter, peerPubkey: string): boolean => { + const kinds = filter.kinds + const pubkeys = filter['#p'] + const topics = filter['#t'] + + return ( + Array.isArray(kinds) && + kinds.length === 1 && + kinds[0] === EventKinds.INVOICE_UPDATE && + Array.isArray(pubkeys) && + pubkeys.includes(peerPubkey) && + Array.isArray(topics) && + topics.includes(PEERING_INVOICE_TAG) + ) +} + +export const isPeeringInvoiceRequest = (filters: SubscriptionFilter[], peerPubkey: string): boolean => + filters.length > 0 && filters.every((filter) => isPeeringInvoiceFilter(filter, peerPubkey)) + +export const peeringPeriodEnd = (start: Date, periodSeconds: number): Date => + new Date(start.getTime() + periodSeconds * 1000) + +export const peeringInvoiceDescription = ( + settings: Settings, + peerPubkey: string, + term: PeeringTermSnapshot, +): string => { + const days = Math.max(1, Math.round(term.periodSeconds / 86_400)) + return `${settings.info.name} paid peering for ${peerPubkey.slice(0, 12)}…: ${term.amountMsats.toString()} msats for ${days} day(s)` +} + +export const isPeeringAccessActive = (status: string, periodEnd: Date | null | undefined, now: Date): boolean => + (status === 'active' || status === 'renewal_pending') && + periodEnd instanceof Date && + periodEnd.getTime() > now.getTime() diff --git a/src/utils/transform.ts b/src/utils/transform.ts index a9021037d..2f083a927 100644 --- a/src/utils/transform.ts +++ b/src/utils/transform.ts @@ -16,6 +16,7 @@ import { } from 'ramda' import { Invoice, InvoiceStatus, InvoiceUnit } from '../@types/invoice' +import { DBPeeringPayment, PeeringPayment } from '../@types/peering' import { User } from '../@types/user' const BECH32_ALPHABET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l' @@ -132,9 +133,38 @@ export const fromDBInvoice = applySpec({ updatedAt: prop('updated_at'), createdAt: prop('created_at'), verifyURL: prop('verify_url'), + purpose: prop('purpose'), planId: prop('plan_id'), periodStart: prop('period_start'), periodEnd: prop('period_end'), + termAmountMsats: ifElse( + propSatisfies(isNil, 'term_amount_msats'), + always(undefined), + pipe(prop('term_amount_msats') as () => string, toBigInt), + ), + termPeriodSeconds: prop('term_period_seconds'), +}) + +export const fromDBPeeringPayment = applySpec({ + peerPubkey: pipe(prop('peer_pubkey') as () => Buffer, fromBuffer), + direction: prop('direction'), + status: prop('status'), + currentInvoiceId: prop('current_invoice_id'), + renewalInvoiceId: prop('renewal_invoice_id'), + periodStart: prop('period_start'), + periodEnd: prop('period_end'), + termAmountMsats: ifElse( + propSatisfies(isNil, 'term_amount_msats'), + always(undefined), + pipe(prop('term_amount_msats') as () => string, toBigInt), + ), + termPeriodSeconds: prop('term_period_seconds'), + lastSettledInvoiceId: prop('last_settled_invoice_id'), + lastSettledAt: prop('last_settled_at'), + lastCheckedAt: prop('last_checked_at'), + lastError: prop('last_error'), + createdAt: prop('created_at'), + updatedAt: prop('updated_at'), }) export const fromDBUser = applySpec({ diff --git a/test/unit/services/payments-service.spec.ts b/test/unit/services/payments-service.spec.ts index 2fe8ab6dd..f462df961 100644 --- a/test/unit/services/payments-service.spec.ts +++ b/test/unit/services/payments-service.spec.ts @@ -7,7 +7,7 @@ chai.use(sinonChai) chai.use(chaiAsPromised) import * as eventUtils from '../../../src/utils/event' -import { Invoice, InvoiceStatus, InvoiceUnit } from '../../../src/@types/invoice' +import { Invoice, InvoicePurpose, InvoiceStatus, InvoiceUnit } from '../../../src/@types/invoice' import { OperatorNotificationEventType } from '../../../src/@types/operator-notifications' import { PaymentsService } from '../../../src/services/payments-service' @@ -63,10 +63,12 @@ describe('PaymentsService', () => { } invoiceRepository = { + findById: sandbox.stub().resolves(undefined), findPendingInvoices: sandbox.stub(), upsert: sandbox.stub().resolves(), updateStatus: sandbox.stub(), confirmInvoice: sandbox.stub().resolves(), + updatePeriod: sandbox.stub().resolves(), } eventRepository = { @@ -640,12 +642,12 @@ describe('PaymentsService', () => { expect(userRepository.admitUser).to.have.been.calledOnce }) - it('does not read the invoice or subscriptions while no plan is configured', async () => { + it('reads the invoice for its purpose even when no plan is configured', async () => { settings.returns(plansSettings([])) await service.confirmInvoice(makeCompletedInvoice({ amountPaid: 2000n })) - expect(invoiceRepository.findById).not.to.have.been.called + expect(invoiceRepository.findById).to.have.been.calledOnce expect(userSubscriptionRepository.findByPubkey).not.to.have.been.called expect(userRepository.admitUser).to.have.been.calledOnce }) @@ -785,4 +787,313 @@ describe('PaymentsService', () => { expect(eventRepository.create).not.to.have.been.called }) }) + + describe('peering billing', () => { + let peeringPaymentRepository: any + let peeringService: PaymentsService + + const peeringRow = (overrides: Record = {}) => ({ + peerPubkey: 'pubkey1234', + direction: 'sell', + status: 'unpaid', + currentInvoiceId: null, + renewalInvoiceId: null, + periodStart: null, + periodEnd: null, + termAmountMsats: 21000n, + termPeriodSeconds: 2592000, + lastSettledInvoiceId: null, + lastSettledAt: null, + lastCheckedAt: null, + lastError: null, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + }) + + const makeCompletedInvoice = (overrides: Partial = {}): Invoice => + stubInvoice({ + status: InvoiceStatus.COMPLETED, + confirmedAt: new Date('2026-11-09T12:00:00.000Z'), + amountPaid: 21000n, + unit: InvoiceUnit.MSATS, + purpose: InvoicePurpose.PEERING, + termAmountMsats: 21000n, + termPeriodSeconds: 2592000, + ...overrides, + }) + + beforeEach(() => { + eventRepository.findByFilters = sandbox.stub().resolves([]) + peeringPaymentRepository = { + lockByPeer: sandbox.stub().resolves(peeringRow()), + save: sandbox.stub().callsFake(async (value: unknown) => value), + } + + peeringService = new PaymentsService( + dbClient, + paymentsProcessor, + userRepository, + invoiceRepository, + eventRepository, + settings, + notificationOutboxRepository, + undefined, + peeringPaymentRepository, + ) + }) + + it('creates a peering invoice without upserting a user', async () => { + settings.returns({ payments: { invoiceExpirySeconds: 3600 } }) + paymentsProcessor.createInvoice.resolves({ + id: 'peering-inv', + bolt11: 'lnbc...', + amountRequested: 21000n, + description: 'peering', + unit: InvoiceUnit.MSATS, + status: InvoiceStatus.PENDING, + expiresAt: new Date('2099-01-01'), + createdAt: new Date(), + }) + + const result = await peeringService.createPeeringInvoice('peer', 21000n, 'peering', 2592000) + + expect(userRepository.upsert).not.to.have.been.called + expect(paymentsProcessor.createInvoice).to.have.been.calledWith({ + amount: 21000n, + description: 'peering', + requestId: 'peer', + }) + expect(invoiceRepository.upsert.firstCall.args[0]).to.include({ + purpose: InvoicePurpose.PEERING, + termAmountMsats: 21000n, + termPeriodSeconds: 2592000, + planId: null, + }) + expect(notificationOutboxRepository.enqueue).to.have.been.calledWith( + OperatorNotificationEventType.PEERING_INVOICE_CREATED, + Sinon.match({ invoiceId: 'peering-inv' }), + mockTrx, + ) + expect(result.id).to.equal('peering-inv') + }) + + it('applies a paid period and clears the open invoice pointers', async () => { + settings.returns({ payments: { enabled: true, feeSchedules: { admission: [] } } }) + invoiceRepository.findById.resolves( + stubInvoice({ + purpose: InvoicePurpose.PEERING, + termAmountMsats: 21000n, + termPeriodSeconds: 2592000, + unit: InvoiceUnit.MSATS, + }), + ) + peeringPaymentRepository.lockByPeer.resolves(peeringRow({ currentInvoiceId: 'invoice-id' })) + + await peeringService.confirmInvoice(makeCompletedInvoice()) + + expect(peeringPaymentRepository.save).to.have.been.calledWith( + Sinon.match({ + status: 'active', + currentInvoiceId: null, + renewalInvoiceId: null, + lastSettledInvoiceId: 'invoice-id', + }), + mockTrx, + ) + expect(invoiceRepository.updatePeriod).to.have.been.calledWith( + 'invoice-id', + Sinon.match.instanceOf(Date), + Sinon.match.instanceOf(Date), + mockTrx, + ) + expect(userRepository.admitUser).not.to.have.been.called + expect(notificationOutboxRepository.enqueue).to.have.been.calledWith( + OperatorNotificationEventType.PEERING_INVOICE_PAID, + Sinon.match({ invoiceId: 'invoice-id' }), + mockTrx, + ) + expect(mockTrx.commit).to.have.been.calledOnce + }) + + it('does not grant a second period for a duplicate callback', async () => { + settings.returns({ payments: { enabled: true, feeSchedules: { admission: [] } } }) + invoiceRepository.findById.resolves( + stubInvoice({ + purpose: InvoicePurpose.PEERING, + termAmountMsats: 21000n, + termPeriodSeconds: 2592000, + }), + ) + peeringPaymentRepository.lockByPeer.resolves(peeringRow({ lastSettledInvoiceId: 'invoice-id' })) + + await peeringService.confirmInvoice(makeCompletedInvoice()) + + expect(peeringPaymentRepository.save).not.to.have.been.called + expect(invoiceRepository.updatePeriod).not.to.have.been.called + expect(notificationOutboxRepository.enqueue).not.to.have.been.called + expect(mockTrx.commit).to.have.been.calledOnce + }) + + it('records an underpayment without activating access', async () => { + settings.returns({ payments: { enabled: true, feeSchedules: { admission: [] } } }) + invoiceRepository.findById.resolves( + stubInvoice({ + purpose: InvoicePurpose.PEERING, + termAmountMsats: 21000n, + termPeriodSeconds: 2592000, + }), + ) + + await peeringService.confirmInvoice(makeCompletedInvoice({ amountPaid: 1000n })) + + expect(peeringPaymentRepository.save).to.have.been.calledWith( + Sinon.match({ lastError: Sinon.match('underpaid') }), + mockTrx, + ) + expect(invoiceRepository.updatePeriod).not.to.have.been.called + expect(notificationOutboxRepository.enqueue).to.have.been.calledWith( + OperatorNotificationEventType.PEERING_INVOICE_FAILED, + Sinon.match({ reason: 'underpaid' }), + mockTrx, + ) + }) + + it('publishes peering invoice events with the peering and invoice-id tags', async () => { + settings.returns({ info: { relay_url: 'wss://relay.example.com' } }) + const invoice = stubInvoice({ + id: 'peering-inv', + purpose: InvoicePurpose.PEERING, + termPeriodSeconds: 2592000, + }) + + await peeringService.sendPeeringInvoiceNotification(invoice) + + const [unsignedEvent] = (eventUtils.identifyEvent as Sinon.SinonStub).firstCall.args + expect(unsignedEvent.tags).to.deep.include.members([ + ['p', invoice.pubkey], + ['t', 'peering'], + ['bolt11', invoice.bolt11], + ['i', invoice.id], + ]) + expect(unsignedEvent.content).to.include('Paid peering invoice') + }) + + it('does not republish an invoice that already has a stored notification', async () => { + settings.returns({ info: { relay_url: 'wss://relay.example.com' } }) + const invoice = stubInvoice({ id: 'peering-inv', purpose: InvoicePurpose.PEERING }) + eventRepository.findByFilters.resolves([{ event_id: Buffer.alloc(32) }]) + + await peeringService.ensurePeeringInvoiceNotification(invoice) + + expect(eventUtils.identifyEvent).not.to.have.been.called + expect(eventRepository.findByFilters).to.have.been.calledWith( + [Sinon.match({ kinds: [402], '#i': ['peering-inv'], limit: 1 })], + { excludeDeleted: true }, + ) + }) + + it('publishes when no stored notification exists', async () => { + settings.returns({ info: { relay_url: 'wss://relay.example.com' } }) + const invoice = stubInvoice({ id: 'peering-inv', purpose: InvoicePurpose.PEERING }) + + await peeringService.ensurePeeringInvoiceNotification(invoice) + + expect(eventUtils.identifyEvent).to.have.been.calledOnce + }) + + it('does not grant another period when an older confirmed invoice callback is redelivered', async () => { + settings.returns({ payments: { enabled: true, feeSchedules: { admission: [] } } }) + invoiceRepository.findById.resolves( + stubInvoice({ + id: 'older-invoice', + purpose: InvoicePurpose.PEERING, + termAmountMsats: 21000n, + termPeriodSeconds: 2592000, + confirmedAt: new Date('2026-10-01T00:00:00.000Z'), + }), + ) + peeringPaymentRepository.lockByPeer.resolves(peeringRow({ lastSettledInvoiceId: 'newer-invoice' })) + + await peeringService.confirmInvoice(makeCompletedInvoice({ id: 'older-invoice' })) + + expect(peeringPaymentRepository.lockByPeer).not.to.have.been.called + expect(peeringPaymentRepository.save).not.to.have.been.called + expect(invoiceRepository.updatePeriod).not.to.have.been.called + expect(notificationOutboxRepository.enqueue).not.to.have.been.called + expect(mockTrx.commit).to.have.been.calledOnce + }) + + it('extends a running period from its existing end', async () => { + const existingPeriodEnd = new Date('2026-11-20T00:00:00.000Z') + const confirmedAt = new Date('2026-11-09T12:00:00.000Z') + settings.returns({ payments: { enabled: true, feeSchedules: { admission: [] } } }) + invoiceRepository.findById.resolves( + stubInvoice({ + purpose: InvoicePurpose.PEERING, + termAmountMsats: 21000n, + termPeriodSeconds: 2592000, + }), + ) + peeringPaymentRepository.lockByPeer.resolves( + peeringRow({ + status: 'active', + currentInvoiceId: 'invoice-id', + periodEnd: existingPeriodEnd, + }), + ) + + await peeringService.confirmInvoice(makeCompletedInvoice({ confirmedAt })) + + const [, periodStart, periodEnd] = invoiceRepository.updatePeriod.firstCall.args + expect(periodStart).to.equal(existingPeriodEnd) + expect((periodEnd as Date).getTime()).to.equal(existingPeriodEnd.getTime() + 2592000 * 1000) + }) + + it('rolls back when saving the peer billing row fails', async () => { + settings.returns({ payments: { enabled: true, feeSchedules: { admission: [] } } }) + invoiceRepository.findById.resolves( + stubInvoice({ + purpose: InvoicePurpose.PEERING, + termAmountMsats: 21000n, + termPeriodSeconds: 2592000, + }), + ) + peeringPaymentRepository.save.rejects(new Error('peer save failed')) + + await expect(peeringService.confirmInvoice(makeCompletedInvoice())).to.be.rejectedWith('peer save failed') + + expect(mockTrx.rollback).to.have.been.calledOnce + expect(invoiceRepository.updatePeriod).not.to.have.been.called + }) + + it('rolls back when updating the invoice period fails', async () => { + settings.returns({ payments: { enabled: true, feeSchedules: { admission: [] } } }) + invoiceRepository.findById.resolves( + stubInvoice({ + purpose: InvoicePurpose.PEERING, + termAmountMsats: 21000n, + termPeriodSeconds: 2592000, + }), + ) + invoiceRepository.updatePeriod.rejects(new Error('period update failed')) + + await expect(peeringService.confirmInvoice(makeCompletedInvoice())).to.be.rejectedWith('period update failed') + + expect(mockTrx.rollback).to.have.been.calledOnce + }) + + it('rolls back a peering invoice when the processor fails', async () => { + settings.returns({ payments: { invoiceExpirySeconds: 3600 } }) + paymentsProcessor.createInvoice.rejects(new Error('processor failed')) + + await expect(peeringService.createPeeringInvoice('peer', 21000n, 'peering', 2592000)).to.be.rejectedWith( + 'processor failed', + ) + + expect(mockTrx.rollback).to.have.been.calledOnce + expect(mockTrx.commit).not.to.have.been.called + }) + }) + }) diff --git a/test/unit/utils/peering-billing.spec.ts b/test/unit/utils/peering-billing.spec.ts new file mode 100644 index 000000000..d7bfe189a --- /dev/null +++ b/test/unit/utils/peering-billing.spec.ts @@ -0,0 +1,76 @@ +import { expect } from 'chai' + +import { Settings } from '../../../src/@types/settings' +import { + InvalidPeeringTermError, + isPeeringInvoiceRequest, + resolvePaidPeeringTerm, +} from '../../../src/utils/peering-billing' + +const withTerms = (terms: unknown[]): Settings => + ({ + peering: { + enabled: true, + terms, + }, + }) as Settings + +describe('resolvePaidPeeringTerm', () => { + it('returns undefined when there are no terms or only zero-amount terms', () => { + expect(resolvePaidPeeringTerm(withTerms([]))).to.be.undefined + expect(resolvePaidPeeringTerm(withTerms([{ amount: 0, unit: 'msats', period: 2592000 }]))).to.be.undefined + }) + + it('normalizes a valid msats term', () => { + expect(resolvePaidPeeringTerm(withTerms([{ amount: 21000, unit: 'msats', period: 2592000 }]))).to.deep.equal({ + amountMsats: 21000n, + periodSeconds: 2592000, + }) + }) + + it('normalizes sats and btc terms', () => { + expect(resolvePaidPeeringTerm(withTerms([{ amount: 21, unit: 'sats', period: 60 }]))).to.deep.equal({ + amountMsats: 21000n, + periodSeconds: 60, + }) + expect(resolvePaidPeeringTerm(withTerms([{ amount: 1, unit: 'btc', period: 60 }]))).to.deep.equal({ + amountMsats: 100_000_000_000n, + periodSeconds: 60, + }) + }) + + it('fails closed for malformed amounts instead of granting free peering', () => { + for (const amount of ['21000', 21.5, -1, Number.NaN, Number.POSITIVE_INFINITY]) { + expect(() => resolvePaidPeeringTerm(withTerms([{ amount, unit: 'msats', period: 60 }]))).to.throw( + InvalidPeeringTermError, + ) + } + }) + + it('rejects an unsupported unit', () => { + expect(() => resolvePaidPeeringTerm(withTerms([{ amount: 1, unit: 'usd', period: 60 }]))).to.throw( + InvalidPeeringTermError, + ) + }) + + it('rejects a sub-satoshi msat term', () => { + expect(() => resolvePaidPeeringTerm(withTerms([{ amount: 1500, unit: 'msats', period: 60 }]))).to.throw( + InvalidPeeringTermError, + ) + }) + + it('rejects an invalid period', () => { + expect(() => resolvePaidPeeringTerm(withTerms([{ amount: 1000, unit: 'msats', period: 0 }]))).to.throw( + InvalidPeeringTermError, + ) + }) + + it('exempts only requests where every filter is an invoice filter', () => { + const peer = 'a'.repeat(64) + const invoice = { kinds: [402], '#p': [peer], '#t': ['peering'] } + + expect(isPeeringInvoiceRequest([invoice], peer)).to.be.true + expect(isPeeringInvoiceRequest([invoice, { kinds: [1] }], peer)).to.be.false + expect(isPeeringInvoiceRequest([], peer)).to.be.false + }) +}) From e45cd65f604eef6af978d6a1dc44303a01243a7b Mon Sep 17 00:00:00 2001 From: ABHAY PANDEY Date: Tue, 10 Nov 2026 00:47:49 +0530 Subject: [PATCH 2/9] fix(peering): add missing peering payment repository to ledger PR The ledger PR introduced the peering_payments table and the payments-service settlement path, but the concrete repository implementation and factory wiring were only added in the delivery PR. This meant PR1 could not actually settle a peering invoice in production. - add PeeringPaymentRepository implementing IPeeringPaymentRepository - wire the repository into createPaymentsService - add unit tests covering mapping, ensureRow, lockByPeer, save and lifecycle queries --- src/factories/payments-service-factory.ts | 2 + .../peering-payment-repository.ts | 120 ++++++++++++++++++ .../peering-payment-repository.spec.ts | 116 +++++++++++++++++ 3 files changed, 238 insertions(+) create mode 100644 src/repositories/peering-payment-repository.ts create mode 100644 test/unit/repositories/peering-payment-repository.spec.ts diff --git a/src/factories/payments-service-factory.ts b/src/factories/payments-service-factory.ts index 07e3bcf59..b9ef8e580 100644 --- a/src/factories/payments-service-factory.ts +++ b/src/factories/payments-service-factory.ts @@ -4,6 +4,7 @@ import { createPaymentsProcessor } from './payments-processor-factory' import { createSettings } from './settings-factory' import { InvoiceRepository } from '../repositories/invoice-repository' import { PaymentsService } from '../services/payments-service' +import { PeeringPaymentRepository } from '../repositories/peering-payment-repository' import { UserSubscriptionRepository } from '../repositories/user-subscription-repository' import { UserRepository } from '../repositories/user-repository' import { NotificationOutboxRepository } from '../repositories/notification-outbox-repository' @@ -26,5 +27,6 @@ export const createPaymentsService = () => { createSettings, notificationOutboxRepository, new UserSubscriptionRepository(dbClient), + new PeeringPaymentRepository(dbClient), ) } diff --git a/src/repositories/peering-payment-repository.ts b/src/repositories/peering-payment-repository.ts new file mode 100644 index 000000000..708cf6342 --- /dev/null +++ b/src/repositories/peering-payment-repository.ts @@ -0,0 +1,120 @@ +import { DatabaseClient, Pubkey } from '../@types/base' +import { DBPeeringPayment, PeeringPayment, PeeringPaymentDirection, PeeringPaymentStatus } from '../@types/peering' +import { IPeeringPaymentRepository } from '../@types/repositories' +import { createLogger } from '../factories/logger-factory' +import { fromDBPeeringPayment, toBuffer } from '../utils/transform' + +const logger = createLogger('peering-payment-repository') + +const toRow = (payment: PeeringPayment): Omit => ({ + peer_pubkey: toBuffer(payment.peerPubkey), + direction: payment.direction, + status: payment.status, + current_invoice_id: payment.currentInvoiceId ?? null, + renewal_invoice_id: payment.renewalInvoiceId ?? null, + period_start: payment.periodStart ?? null, + period_end: payment.periodEnd ?? null, + term_amount_msats: + typeof payment.termAmountMsats === 'bigint' && payment.termAmountMsats >= 0n + ? payment.termAmountMsats.toString() + : null, + term_period_seconds: payment.termPeriodSeconds ?? null, + last_settled_invoice_id: payment.lastSettledInvoiceId ?? null, + last_settled_at: payment.lastSettledAt ?? null, + last_checked_at: payment.lastCheckedAt ?? null, + last_error: payment.lastError ?? null, +}) + +export class PeeringPaymentRepository implements IPeeringPaymentRepository { + public constructor(private readonly dbClient: DatabaseClient) {} + + public async findByPeer(pubkey: Pubkey, client: DatabaseClient = this.dbClient): Promise { + const row = await client('peering_payments') + .where({ + peer_pubkey: toBuffer(pubkey), + direction: PeeringPaymentDirection.SELL, + }) + .first() + + return row ? fromDBPeeringPayment(row) : undefined + } + + public async ensureRow(pubkey: Pubkey, client: DatabaseClient): Promise { + await client('peering_payments') + .insert({ + peer_pubkey: toBuffer(pubkey), + direction: PeeringPaymentDirection.SELL, + status: PeeringPaymentStatus.UNPAID, + }) + .onConflict(['peer_pubkey', 'direction']) + .ignore() + } + + public async lockByPeer(pubkey: Pubkey, client: DatabaseClient): Promise { + const row = await client('peering_payments') + .where({ + peer_pubkey: toBuffer(pubkey), + direction: PeeringPaymentDirection.SELL, + }) + .forUpdate() + .first() + + return row ? fromDBPeeringPayment(row) : undefined + } + + public async save(payment: PeeringPayment, client: DatabaseClient = this.dbClient): Promise { + const row = toRow(payment) + const now = new Date() + + await client('peering_payments') + .update({ + ...row, + updated_at: now, + }) + .where({ + peer_pubkey: row.peer_pubkey, + direction: row.direction, + }) + + return { + ...payment, + updatedAt: now, + } + } + + public async list(): Promise { + const rows = await this.dbClient('peering_payments') + .where('direction', PeeringPaymentDirection.SELL) + .orderBy('peer_pubkey', 'asc') + + return rows.map(fromDBPeeringPayment) + } + + public async findDueForRenewal(before: Date, limit: number, offset: number): Promise { + const rows = await this.dbClient('peering_payments') + .where('direction', PeeringPaymentDirection.SELL) + .whereIn('status', [PeeringPaymentStatus.ACTIVE, PeeringPaymentStatus.RENEWAL_PENDING]) + .whereNotNull('period_end') + .where('period_end', '<=', before) + .orderBy('period_end', 'asc') + .offset(offset) + .limit(limit) + + return rows.map(fromDBPeeringPayment) + } + + public async findPeriodEnded(now: Date, limit: number, offset: number): Promise { + const rows = await this.dbClient('peering_payments') + .where('direction', PeeringPaymentDirection.SELL) + .whereIn('status', [PeeringPaymentStatus.ACTIVE, PeeringPaymentStatus.RENEWAL_PENDING]) + .whereNotNull('period_end') + .where('period_end', '<=', now) + .orderBy('period_end', 'asc') + .offset(offset) + .limit(limit) + + logger('found %d peering period end(s)', rows.length) + + return rows.map(fromDBPeeringPayment) + } +} diff --git a/test/unit/repositories/peering-payment-repository.spec.ts b/test/unit/repositories/peering-payment-repository.spec.ts new file mode 100644 index 000000000..7c13d3a8d --- /dev/null +++ b/test/unit/repositories/peering-payment-repository.spec.ts @@ -0,0 +1,116 @@ +import { expect } from 'chai' + +import { PeeringPaymentDirection, PeeringPaymentStatus } from '../../../src/@types/peering' +import { PeeringPaymentRepository } from '../../../src/repositories/peering-payment-repository' +import { createRecordingKnex } from './recording-knex' + +describe('PeeringPaymentRepository', () => { + const pubkey = 'a'.repeat(64) + const now = new Date('2026-11-09T12:00:00.000Z') + + const row = { + peer_pubkey: Buffer.from(pubkey, 'hex'), + direction: PeeringPaymentDirection.SELL, + status: PeeringPaymentStatus.ACTIVE, + current_invoice_id: 'inv-1', + renewal_invoice_id: null, + period_start: now, + period_end: new Date(now.getTime() + 86_400_000), + term_amount_msats: '21000', + term_period_seconds: 86_400, + last_settled_invoice_id: 'inv-0', + last_settled_at: now, + last_checked_at: now, + last_error: null, + created_at: now, + updated_at: now, + } + + it('maps a stored row to the domain type', async () => { + const recording = createRecordingKnex() + const repository = new PeeringPaymentRepository(recording.db) + recording.respondWith([row]) + + const result = await repository.findByPeer(pubkey) + + expect(result).to.include({ + peerPubkey: pubkey, + direction: PeeringPaymentDirection.SELL, + status: PeeringPaymentStatus.ACTIVE, + currentInvoiceId: 'inv-1', + termPeriodSeconds: 86_400, + }) + expect(result?.termAmountMsats).to.equal(21000n) + expect(recording.queries[0].sql).to.include('"peering_payments"') + expect(recording.queries[0].sql).to.include('"peer_pubkey"') + }) + + it('creates a missing row without overwriting an existing one', async () => { + const recording = createRecordingKnex() + const repository = new PeeringPaymentRepository(recording.db) + recording.respondWith(0) + + await repository.ensureRow(pubkey, recording.db) + + const sql = recording.queries[0].sql + expect(sql).to.include('insert into "peering_payments"') + expect(sql).to.include('on conflict') + expect(sql).to.include('do nothing') + }) + + it('locks the peer row for update', async () => { + const recording = createRecordingKnex() + const repository = new PeeringPaymentRepository(recording.db) + recording.respondWith([row]) + + await repository.lockByPeer(pubkey, recording.db) + + expect(recording.queries[0].sql).to.include('for update') + }) + + it('saves the billing state', async () => { + const recording = createRecordingKnex() + const repository = new PeeringPaymentRepository(recording.db) + recording.respondWith(1) + + const saved = await repository.save( + { + peerPubkey: pubkey, + direction: PeeringPaymentDirection.SELL, + status: PeeringPaymentStatus.LAPSED, + currentInvoiceId: null, + renewalInvoiceId: null, + periodStart: now, + periodEnd: now, + termAmountMsats: 21000n, + termPeriodSeconds: 86_400, + lastSettledInvoiceId: null, + lastSettledAt: null, + lastCheckedAt: now, + lastError: null, + createdAt: now, + updatedAt: now, + }, + recording.db, + ) + + const sql = recording.queries[0].sql + expect(sql).to.include('update "peering_payments"') + expect(sql).to.include('"status"') + expect(saved.status).to.equal(PeeringPaymentStatus.LAPSED) + }) + + it('finds periods due for renewal and periods that ended', async () => { + const recording = createRecordingKnex() + const repository = new PeeringPaymentRepository(recording.db) + recording.respondWith([row], [row]) + + await repository.findDueForRenewal(now, 10, 0) + await repository.findPeriodEnded(now, 10, 0) + + expect(recording.queries[0].sql).to.include('"status" in') + expect(recording.queries[0].sql).to.include('"period_end" <=') + expect(recording.queries[1].sql).to.include('"status" in') + expect(recording.queries[1].sql).to.include('"period_end" <=') + }) +}) From 2d3671cb8a7029749768999ccd8afa739ccc9532 Mon Sep 17 00:00:00 2001 From: ABHAY PANDEY Date: Sat, 14 Nov 2026 08:15:45 +0530 Subject: [PATCH 3/9] fix(peering): make failed settlements retryable and preserve paid amounts A completed peering invoice whose settlement transaction rolled back stayed completed with confirmed_at null. The poller only read pending invoices and LNbits answered 409 for completed ones, so a paid peer could be stranded without a period. - add findUnconfirmedPeeringInvoices() and retry those in the maintenance worker - let a completed, unconfirmed invoice bypass the LNbits 409 - preserve the processor's amountPaid instead of overwriting it with the requested amount, so underpayments are not masked and overpayments stay on the invoice receipt - validate amount/period/range at the createPeeringInvoice boundary - reject terms above Number.MAX_SAFE_INTEGER sats before processor conversion - record the refund policy and add regression tests for retries, overpayment, duplicate callbacks and creation-boundary abuse --- src/@types/repositories.ts | 6 + src/@types/services.ts | 2 + src/app/maintenance-worker.ts | 53 ++++++-- .../callbacks/lnbits-callback-controller.ts | 8 +- .../callbacks/nodeless-callback-controller.ts | 8 +- .../callbacks/opennode-callback-controller.ts | 7 +- .../callbacks/zebedee-callback-controller.ts | 10 +- src/repositories/invoice-repository.ts | 22 +++- src/services/payments-service.ts | 35 +++++- src/utils/peering-billing.ts | 7 ++ test/unit/app/maintenance-worker.spec.ts | 88 +++++++++++++- .../lnbits-callback-controller.spec.ts | 22 ++++ .../repositories/invoice-repository.spec.ts | 50 +++++++- test/unit/services/payments-service.spec.ts | 113 +++++++++++++++++- test/unit/utils/peering-billing.spec.ts | 8 ++ 15 files changed, 417 insertions(+), 22 deletions(-) diff --git a/src/@types/repositories.ts b/src/@types/repositories.ts index 15d117fa0..461d25dc9 100644 --- a/src/@types/repositories.ts +++ b/src/@types/repositories.ts @@ -75,6 +75,12 @@ export interface IInvoiceRepository { confirmInvoice(invoiceId: string, amountReceived: bigint, confirmedAt: Date, client?: DatabaseClient): Promise updatePeriod(invoiceId: string, periodStart: Date, periodEnd: Date, client?: DatabaseClient): Promise findPendingInvoices(offset?: number, limit?: number, client?: DatabaseClient): Promise + /** + * Completed peering invoices whose settlement never committed, oldest first. + * `confirmed_at` stays null when the settlement transaction rolls back, so the + * maintenance worker can retry them instead of stranding a paid peer. + */ + findUnconfirmedPeeringInvoices(limit?: number, client?: DatabaseClient): Promise } export interface IPeeringPaymentRepository { diff --git a/src/@types/services.ts b/src/@types/services.ts index 5520ee798..d1ae6a90b 100644 --- a/src/@types/services.ts +++ b/src/@types/services.ts @@ -50,6 +50,8 @@ export interface IPaymentsService { /** Publishes only when no unexpired kind 402 event for this invoice exists. */ ensurePeeringInvoiceNotification(invoice: Invoice): Promise getPendingInvoices(offset?: number): Promise + /** Completed peering invoices whose settlement did not commit, for retry. */ + getUnconfirmedPeeringInvoices(limit?: number): Promise } export interface ISubscriptionService { diff --git a/src/app/maintenance-worker.ts b/src/app/maintenance-worker.ts index b3d9e1f42..dc4c68535 100644 --- a/src/app/maintenance-worker.ts +++ b/src/app/maintenance-worker.ts @@ -187,12 +187,38 @@ export class MaintenanceWorker implements IRunnable { return } - const invoices = await this.paymentsService.getPendingInvoices(this.pendingInvoiceOffset) - logger('found %d pending invoices from offset %d', invoices.length, this.pendingInvoiceOffset) + // A paid peering invoice is marked completed before settlement runs. If that + // settlement rolled back, confirmed_at is still null and the invoice is no + // longer in the pending page. Pick those up first so a paid peer is never + // stranded between "completed" and "settled". + const retryablePeering = await this.paymentsService.getUnconfirmedPeeringInvoices( + PENDING_INVOICE_PAGE_SIZE, + ) + const pending = await this.paymentsService.getPendingInvoices(this.pendingInvoiceOffset) + logger( + 'found %d pending invoice(s) from offset %d and %d unconfirmed peering invoice(s)', + pending.length, + this.pendingInvoiceOffset, + retryablePeering.length, + ) // A short page means we reached the end, so start over next pass. this.pendingInvoiceOffset = - invoices.length < PENDING_INVOICE_PAGE_SIZE ? 0 : this.pendingInvoiceOffset + PENDING_INVOICE_PAGE_SIZE + pending.length < PENDING_INVOICE_PAGE_SIZE ? 0 : this.pendingInvoiceOffset + PENDING_INVOICE_PAGE_SIZE + + // A retryable peering invoice is completed, not pending, so the two lists do + // not normally overlap. Deduplicate anyway so a future query change cannot + // settle the same invoice twice in one pass. + const seen = new Set() + const invoices = [...retryablePeering, ...pending].filter((invoice) => { + if (seen.has(invoice.id)) { + return false + } + + seen.add(invoice.id) + return true + }) + const delay = () => delayMs(100 + Math.floor(Math.random() * 10)) let successful = 0 @@ -211,16 +237,25 @@ export class MaintenanceWorker implements IRunnable { await this.paymentsService.updateInvoiceStatus({ id, status }) - if ( - invoice.status !== updatedInvoice.status && - updatedInvoice.status == InvoiceStatus.COMPLETED && - updatedInvoice.confirmedAt - ) { + const needsConfirmation = + updatedInvoice.status === InvoiceStatus.COMPLETED && + updatedInvoice.confirmedAt && + (invoice.status !== updatedInvoice.status || invoice.confirmedAt == null) + + if (needsConfirmation) { logger('confirming invoice %s & notifying %s', invoice.id, invoice.pubkey) + // The processor may report a real paid amount for a peering invoice + // (underpayment or overpayment). Preserve it; fall back to the + // requested amount only when the processor does not report one. + const amountPaid = + typeof updatedInvoice.amountPaid === 'bigint' + ? updatedInvoice.amountPaid + : invoice.amountRequested + const update = pipe( mergeDeepLeft(updatedInvoice), - mergeDeepLeft({ amountPaid: invoice.amountRequested }), + mergeDeepLeft({ amountPaid }), )(invoice) await Promise.all([ diff --git a/src/controllers/callbacks/lnbits-callback-controller.ts b/src/controllers/callbacks/lnbits-callback-controller.ts index e8b93fca8..8fda6eb8b 100644 --- a/src/controllers/callbacks/lnbits-callback-controller.ts +++ b/src/controllers/callbacks/lnbits-callback-controller.ts @@ -94,12 +94,16 @@ export class LNbitsCallbackController implements IController { return } - if (storedInvoice.status === InvoiceStatus.COMPLETED) { + // A completed invoice without a confirmation is a settlement that rolled + // back. Let the redelivery retry it instead of answering 409 forever. + if (storedInvoice.status === InvoiceStatus.COMPLETED && storedInvoice.confirmedAt) { response.status(409).setHeader('content-type', 'text/plain; charset=utf8').send('Invoice is already marked paid') return } - invoice.amountPaid = invoice.amountRequested + if (typeof invoice.amountPaid !== 'bigint') { + invoice.amountPaid = invoice.amountRequested + } try { await this.paymentsService.confirmInvoice(invoice as Invoice) diff --git a/src/controllers/callbacks/nodeless-callback-controller.ts b/src/controllers/callbacks/nodeless-callback-controller.ts index 7aca3a923..73d7e06df 100644 --- a/src/controllers/callbacks/nodeless-callback-controller.ts +++ b/src/controllers/callbacks/nodeless-callback-controller.ts @@ -91,8 +91,12 @@ export class NodelessCallbackController implements IController { return } - invoice.amountPaid = invoice.amountRequested - updatedInvoice.amountPaid = invoice.amountRequested + if (typeof invoice.amountPaid !== 'bigint') { + invoice.amountPaid = invoice.amountRequested + } + if (typeof updatedInvoice.amountPaid !== 'bigint') { + updatedInvoice.amountPaid = invoice.amountPaid + } try { await this.paymentsService.confirmInvoice(invoice) diff --git a/src/controllers/callbacks/opennode-callback-controller.ts b/src/controllers/callbacks/opennode-callback-controller.ts index cc349c95d..71bbb2062 100644 --- a/src/controllers/callbacks/opennode-callback-controller.ts +++ b/src/controllers/callbacks/opennode-callback-controller.ts @@ -78,6 +78,9 @@ export class OpenNodeCallbackController implements IController { const statusMap: Record = { expired: InvoiceStatus.EXPIRED, + // A refund only expires the invoice. Removing the paid peering period is a + // lifecycle policy and is deliberately not automatic here; the peer row is + // left untouched so an operator can decide whether to revoke access. refunded: InvoiceStatus.EXPIRED, unpaid: InvoiceStatus.PENDING, processing: InvoiceStatus.PENDING, @@ -112,7 +115,9 @@ export class OpenNodeCallbackController implements IController { if (!updatedInvoice.confirmedAt) { updatedInvoice.confirmedAt = new Date() } - updatedInvoice.amountPaid = updatedInvoice.amountRequested + if (typeof updatedInvoice.amountPaid !== 'bigint') { + updatedInvoice.amountPaid = updatedInvoice.amountRequested + } try { await this.paymentsService.confirmInvoice({ diff --git a/src/controllers/callbacks/zebedee-callback-controller.ts b/src/controllers/callbacks/zebedee-callback-controller.ts index e4c2b792a..003325dcd 100644 --- a/src/controllers/callbacks/zebedee-callback-controller.ts +++ b/src/controllers/callbacks/zebedee-callback-controller.ts @@ -56,9 +56,13 @@ export class ZebedeeCallbackController implements IController { return } - invoice.amountPaid = invoice.amountRequested + if (typeof invoice.amountPaid !== 'bigint') { + invoice.amountPaid = invoice.amountRequested + } invoice.status = updatedInvoice.status - updatedInvoice.amountPaid = invoice.amountRequested + if (typeof updatedInvoice.amountPaid !== 'bigint') { + updatedInvoice.amountPaid = invoice.amountPaid + } try { await this.paymentsService.confirmInvoice({ @@ -66,7 +70,7 @@ export class ZebedeeCallbackController implements IController { pubkey: invoice.pubkey, status: invoice.status, confirmedAt: invoice.confirmedAt, - amountPaid: invoice.amountRequested, + amountPaid: invoice.amountPaid, }) await this.paymentsService.sendInvoiceUpdateNotification(updatedInvoice) } catch (error) { diff --git a/src/repositories/invoice-repository.ts b/src/repositories/invoice-repository.ts index 8116a8d94..8883a5af0 100644 --- a/src/repositories/invoice-repository.ts +++ b/src/repositories/invoice-repository.ts @@ -1,6 +1,6 @@ import { always, applySpec, head, ifElse, is, isNil, map, omit, pipe, prop, propSatisfies, toString } from 'ramda' -import { DBInvoice, Invoice, InvoiceStatus } from '../@types/invoice' +import { DBInvoice, Invoice, InvoicePurpose, InvoiceStatus } from '../@types/invoice' import { fromDBInvoice, toBuffer } from '../utils/transform' import { createLogger } from '../factories/logger-factory' import { DatabaseClient } from '../@types/base' @@ -68,6 +68,26 @@ export class InvoiceRepository implements IInvoiceRepository { return dbInvoices.map(fromDBInvoice) } + public async findUnconfirmedPeeringInvoices( + limit = 10, + client: DatabaseClient = this.dbClient, + ): Promise { + // The status is committed before settlement runs, so a rolled-back + // settlement leaves a completed invoice with no confirmed_at. The worker + // must be able to find those and try the settlement again. + const dbInvoices = await client('invoices') + .where({ + purpose: InvoicePurpose.PEERING, + status: InvoiceStatus.COMPLETED, + }) + .whereNull('confirmed_at') + .orderBy('created_at', 'asc') + .limit(limit) + .select() + + return dbInvoices.map(fromDBInvoice) + } + public updateStatus(invoice: Invoice, client: DatabaseClient = this.dbClient): Promise { logger('updating invoice status: %o', invoice) diff --git a/src/services/payments-service.ts b/src/services/payments-service.ts index 461b0c07c..ea98b4907 100644 --- a/src/services/payments-service.ts +++ b/src/services/payments-service.ts @@ -22,7 +22,11 @@ import { PeeringPaymentStatus } from '../@types/peering' import { Transaction } from '../database/transaction' import { applyPayment, findSubscriptionPlan } from '../utils/subscription' import { resolveInvoiceExpiry } from '../utils/invoice' -import { PEERING_INVOICE_TAG, peeringPeriodEnd } from '../utils/peering-billing' +import { + InvalidPeeringTermError, + PEERING_INVOICE_TAG, + peeringPeriodEnd, +} from '../utils/peering-billing' const logger = createLogger('payments-service') @@ -53,6 +57,17 @@ export class PaymentsService implements IPaymentsService { } } + public async getUnconfirmedPeeringInvoices(limit = PENDING_INVOICE_PAGE_SIZE): Promise { + logger('get unconfirmed peering invoices') + try { + return await this.invoiceRepository.findUnconfirmedPeeringInvoices(limit) + } catch (error) { + logger.error('Unable to get unconfirmed peering invoices.', error) + + throw error + } + } + public async getInvoiceFromPaymentsProcessor(invoice: Invoice | string): Promise> { try { return await this.paymentsProcessor.getInvoice( @@ -157,6 +172,22 @@ export class PaymentsService implements IPaymentsService { ): Promise { logger('create peering invoice for %s for %s msats', pubkey, amountMsats.toString()) + // The boundary repeats the term validator's checks: a caller that skips + // resolvePaidPeeringTerm must not persist a term settlement will reject. + if (amountMsats <= 0n || amountMsats % 1000n !== 0n) { + throw new InvalidPeeringTermError( + 'peering invoice amount must be a positive whole number of satoshis', + ) + } + + if (amountMsats / 1000n > BigInt(Number.MAX_SAFE_INTEGER)) { + throw new InvalidPeeringTermError('peering invoice amount is too large for the payment processors') + } + + if (!Number.isSafeInteger(termPeriodSeconds) || termPeriodSeconds <= 0) { + throw new InvalidPeeringTermError('peering invoice period must be a positive number of seconds') + } + const transaction = client ? undefined : new Transaction(this.dbClient) if (transaction) { await transaction.begin() @@ -424,6 +455,8 @@ export class PaymentsService implements IPaymentsService { return { activated: false, reason: 'underpaid' } } + // Overpayments are not credited to the peer row. The invoice's amount_paid + // is the receipt; an operator can refund the surplus out of band. // Paying early extends from the current period end; paying after a lapse starts now. const stillRunning = payment.periodEnd instanceof Date && payment.periodEnd.getTime() > confirmedAt.getTime() const periodStart = stillRunning ? (payment.periodEnd as Date) : confirmedAt diff --git a/src/utils/peering-billing.ts b/src/utils/peering-billing.ts index 63bb589e9..9ce024d5b 100644 --- a/src/utils/peering-billing.ts +++ b/src/utils/peering-billing.ts @@ -86,6 +86,13 @@ export const resolvePaidPeeringTerm = (settings: Settings): PeeringTermSnapshot throw new InvalidPeeringTermError('peering term amount must be a whole number of satoshis') } + // Processor adapters convert msats to sats with Number(). Reject a term that + // would cross Number.MAX_SAFE_INTEGER sats, or the invoice would be created + // for a rounded amount that no longer matches the stored term. + if (amountMsats / 1000n > BigInt(Number.MAX_SAFE_INTEGER)) { + throw new InvalidPeeringTermError('peering term amount is too large for the payment processors') + } + if (!(typeof term.period === 'number' && Number.isSafeInteger(term.period) && term.period > 0)) { throw new InvalidPeeringTermError('peering term period must be a positive number of seconds') } diff --git a/test/unit/app/maintenance-worker.spec.ts b/test/unit/app/maintenance-worker.spec.ts index dc3defe1c..bc6a1f4c7 100644 --- a/test/unit/app/maintenance-worker.spec.ts +++ b/test/unit/app/maintenance-worker.spec.ts @@ -5,11 +5,12 @@ import chaiAsPromised from 'chai-as-promised' import Sinon from 'sinon' import sinonChai from 'sinon-chai' -import { InvoiceStatus, InvoiceUnit } from '../../../src/@types/invoice' +import { InvoicePurpose, InvoiceStatus, InvoiceUnit } from '../../../src/@types/invoice' import { Nip05Verification } from '../../../src/@types/nip05' import { IMaintenanceService, INotificationOutboxService, IPaymentsService } from '../../../src/@types/services' import { Settings } from '../../../src/@types/settings' import { applyReverificationOutcome, MaintenanceWorker } from '../../../src/app/maintenance-worker' +import { PENDING_INVOICE_PAGE_SIZE } from '../../../src/services/payments-service' import * as metricsTelemetry from '../../../src/telemetry/metrics' import * as misc from '../../../src/utils/misc' import * as nip05Utils from '../../../src/utils/nip05' @@ -84,6 +85,7 @@ describe('MaintenanceWorker', () => { paymentsService = { getPendingInvoices: sandbox.stub().resolves([]), + getUnconfirmedPeeringInvoices: sandbox.stub().resolves([]), getInvoiceFromPaymentsProcessor: sandbox.stub(), updateInvoiceStatus: sandbox.stub().resolves(), confirmInvoice: sandbox.stub().resolves(), @@ -489,6 +491,90 @@ describe('MaintenanceWorker', () => { expect(confirmArg.amountPaid).to.equal(pendingInvoice.amountRequested) }) + it('confirms a completed peering invoice that was never settled', async () => { + const confirmedAt = new Date() + const completedUnconfirmed = { + ...pendingInvoice, + status: InvoiceStatus.COMPLETED, + confirmedAt: null, + purpose: InvoicePurpose.PEERING, + } + settingsState.payments = { enabled: true } as any + paymentsService.getUnconfirmedPeeringInvoices.resolves([completedUnconfirmed]) + paymentsService.getPendingInvoices.resolves([]) + paymentsService.getInvoiceFromPaymentsProcessor.resolves({ + id: 'inv-1', + status: InvoiceStatus.COMPLETED, + confirmedAt, + }) + + await (worker as any).onSchedule() + + expect(paymentsService.getUnconfirmedPeeringInvoices).to.have.been.calledOnceWithExactly( + PENDING_INVOICE_PAGE_SIZE, + ) + expect(paymentsService.updateInvoiceStatus).to.have.been.calledOnceWithExactly({ + id: 'inv-1', + status: InvoiceStatus.COMPLETED, + }) + expect(paymentsService.confirmInvoice).to.have.been.calledOnce + + const [confirmArg] = paymentsService.confirmInvoice.firstCall.args + expect(confirmArg).to.include({ + id: 'inv-1', + status: InvoiceStatus.COMPLETED, + purpose: InvoicePurpose.PEERING, + }) + }) + + it('uses the paid amount reported by the processor', async () => { + const confirmedAt = new Date() + const completedUnconfirmed = { + ...pendingInvoice, + status: InvoiceStatus.COMPLETED, + confirmedAt: null, + purpose: InvoicePurpose.PEERING, + amountRequested: 1000n, + } + settingsState.payments = { enabled: true } as any + paymentsService.getUnconfirmedPeeringInvoices.resolves([completedUnconfirmed]) + paymentsService.getInvoiceFromPaymentsProcessor.resolves({ + id: 'inv-1', + status: InvoiceStatus.COMPLETED, + confirmedAt, + amountPaid: 1500n, + }) + + await (worker as any).onSchedule() + + const [confirmArg] = paymentsService.confirmInvoice.firstCall.args + expect(confirmArg.amountPaid).to.equal(1500n) + }) + + it('retries a failed peering settlement on the next pass', async () => { + const confirmedAt = new Date() + const completedUnconfirmed = { + ...pendingInvoice, + status: InvoiceStatus.COMPLETED, + confirmedAt: null, + purpose: InvoicePurpose.PEERING, + } + settingsState.payments = { enabled: true } as any + paymentsService.getUnconfirmedPeeringInvoices.resolves([completedUnconfirmed]) + paymentsService.getInvoiceFromPaymentsProcessor.resolves({ + id: 'inv-1', + status: InvoiceStatus.COMPLETED, + confirmedAt, + }) + paymentsService.confirmInvoice.onFirstCall().rejects(new Error('settlement failed')) + paymentsService.confirmInvoice.onSecondCall().resolves() + + await (worker as any).onSchedule() + await (worker as any).onSchedule() + + expect(paymentsService.confirmInvoice).to.have.been.calledTwice + }) + it('continues processing remaining invoices when one throws', async () => { const secondInvoice = { ...pendingInvoice, id: 'inv-2' } settingsState.payments = { enabled: true } as any diff --git a/test/unit/controllers/callbacks/lnbits-callback-controller.spec.ts b/test/unit/controllers/callbacks/lnbits-callback-controller.spec.ts index 62e0d4014..ca6e3b2c5 100644 --- a/test/unit/controllers/callbacks/lnbits-callback-controller.spec.ts +++ b/test/unit/controllers/callbacks/lnbits-callback-controller.spec.ts @@ -260,6 +260,28 @@ describe('LNbitsCallbackController', () => { expect(res.send).to.have.been.calledWith('Invoice is already marked paid') }) + it('retries a completed invoice that has no confirmation yet', async () => { + const invoiceRepository = { + findById: sinon.stub().resolves(makeInvoice({ status: InvoiceStatus.COMPLETED, confirmedAt: null })), + } + const paymentsService = { + getInvoiceFromPaymentsProcessor: sinon + .stub() + .resolves(makeInvoice({ status: InvoiceStatus.COMPLETED, confirmedAt: new Date() })), + updateInvoice: sinon.stub().resolves(), + confirmInvoice: sinon.stub().resolves(), + sendInvoiceUpdateNotification: sinon.stub().resolves(), + } + const { controller } = makeController({ paymentsService, invoiceRepository }) + const res = makeRes() + + await controller.handleRequest(makeReq(), res) + + expect(res.status).to.have.been.calledWith(200) + expect(res.send).to.have.been.calledWith('OK') + expect(paymentsService.confirmInvoice).to.have.been.calledOnce + }) + it('confirms and notifies when invoice transitions to completed', async () => { const paymentsService = { getInvoiceFromPaymentsProcessor: sinon.stub().resolves(makeInvoice({ status: InvoiceStatus.COMPLETED })), diff --git a/test/unit/repositories/invoice-repository.spec.ts b/test/unit/repositories/invoice-repository.spec.ts index 281abd08c..7e01fbbc4 100644 --- a/test/unit/repositories/invoice-repository.spec.ts +++ b/test/unit/repositories/invoice-repository.spec.ts @@ -5,7 +5,7 @@ import sinonChai from 'sinon-chai' import chaiAsPromised from 'chai-as-promised' import { DatabaseClient } from '../../../src/@types/base' -import { Invoice, InvoiceStatus, InvoiceUnit } from '../../../src/@types/invoice' +import { Invoice, InvoicePurpose, InvoiceStatus, InvoiceUnit } from '../../../src/@types/invoice' import { IInvoiceRepository } from '../../../src/@types/repositories' import { InvoiceRepository } from '../../../src/repositories/invoice-repository' @@ -218,6 +218,54 @@ describe('InvoiceRepository', () => { }) }) + describe('.findUnconfirmedPeeringInvoices', () => { + function makeClient(results: any[]) { + const selectStub = sandbox.stub().resolves(results) + const limitStub = sandbox.stub().returns({ select: selectStub }) + const orderByStub = sandbox.stub().returns({ limit: limitStub }) + const whereNullStub = sandbox.stub().returns({ orderBy: orderByStub }) + const whereStub = sandbox.stub().returns({ whereNull: whereNullStub }) + return { + client: sandbox.stub().returns({ where: whereStub }) as unknown as DatabaseClient, + whereStub, + whereNullStub, + orderByStub, + limitStub, + } + } + + it('returns completed peering invoices with no confirmation', async () => { + const peeringRow = { + ...dbInvoiceRow, + purpose: InvoicePurpose.PEERING, + status: InvoiceStatus.COMPLETED, + confirmed_at: null, + } + const { client } = makeClient([peeringRow]) + + const result = await repository.findUnconfirmedPeeringInvoices(10, client) + + expect(result).to.have.length(1) + expect(result[0].id).to.equal('test-invoice-id') + expect(result[0].purpose).to.equal(InvoicePurpose.PEERING) + expect(result[0].confirmedAt).to.equal(null) + }) + + it('filters by peering purpose, completed status and a null confirmation', async () => { + const { client, whereStub, whereNullStub, orderByStub, limitStub } = makeClient([]) + + await repository.findUnconfirmedPeeringInvoices(25, client) + + expect(whereStub).to.have.been.calledWith({ + purpose: InvoicePurpose.PEERING, + status: InvoiceStatus.COMPLETED, + }) + expect(whereNullStub).to.have.been.calledWith('confirmed_at') + expect(orderByStub).to.have.been.calledWith('created_at', 'asc') + expect(limitStub).to.have.been.calledWith(25) + }) + }) + describe('.updateStatus', () => { it('returns an object with then, catch, and toString', () => { const result = repository.updateStatus(testInvoice) diff --git a/test/unit/services/payments-service.spec.ts b/test/unit/services/payments-service.spec.ts index f462df961..433122643 100644 --- a/test/unit/services/payments-service.spec.ts +++ b/test/unit/services/payments-service.spec.ts @@ -9,7 +9,7 @@ chai.use(chaiAsPromised) import * as eventUtils from '../../../src/utils/event' import { Invoice, InvoicePurpose, InvoiceStatus, InvoiceUnit } from '../../../src/@types/invoice' import { OperatorNotificationEventType } from '../../../src/@types/operator-notifications' -import { PaymentsService } from '../../../src/services/payments-service' +import { PENDING_INVOICE_PAGE_SIZE, PaymentsService } from '../../../src/services/payments-service' const { expect } = chai @@ -65,6 +65,7 @@ describe('PaymentsService', () => { invoiceRepository = { findById: sandbox.stub().resolves(undefined), findPendingInvoices: sandbox.stub(), + findUnconfirmedPeeringInvoices: sandbox.stub().resolves([]), upsert: sandbox.stub().resolves(), updateStatus: sandbox.stub(), confirmInvoice: sandbox.stub().resolves(), @@ -140,6 +141,26 @@ describe('PaymentsService', () => { }) + describe('getUnconfirmedPeeringInvoices', () => { + it('returns completed peering invoices that never settled', async () => { + const invoices = [stubInvoice({ confirmedAt: null })] + invoiceRepository.findUnconfirmedPeeringInvoices.resolves(invoices) + + const result = await service.getUnconfirmedPeeringInvoices() + + expect(result).to.deep.equal(invoices) + expect(invoiceRepository.findUnconfirmedPeeringInvoices).to.have.been.calledOnceWithExactly( + PENDING_INVOICE_PAGE_SIZE, + ) + }) + + it('re-throws repository errors', async () => { + invoiceRepository.findUnconfirmedPeeringInvoices.rejects(new Error('db error')) + + await expect(service.getUnconfirmedPeeringInvoices()).to.be.rejectedWith('db error') + }) + }) + describe('getInvoiceFromPaymentsProcessor', () => { it('passes a string invoice ID directly to the payments processor', async () => { const partial = { id: 'inv', status: InvoiceStatus.PENDING } @@ -878,6 +899,41 @@ describe('PaymentsService', () => { expect(result.id).to.equal('peering-inv') }) + it('rejects a zero-amount peering invoice before calling the processor', async () => { + await expect(peeringService.createPeeringInvoice('peer', 0n, 'peering', 2592000)).to.be.rejectedWith( + 'positive whole number of satoshis', + ) + expect(paymentsProcessor.createInvoice).not.to.have.been.called + }) + + it('rejects a sub-satoshi peering invoice before calling the processor', async () => { + await expect(peeringService.createPeeringInvoice('peer', 1500n, 'peering', 2592000)).to.be.rejectedWith( + 'positive whole number of satoshis', + ) + expect(paymentsProcessor.createInvoice).not.to.have.been.called + }) + + it('rejects a peering invoice too large for the processor conversion', async () => { + await expect( + peeringService.createPeeringInvoice('peer', 10n ** 19n, 'peering', 2592000), + ).to.be.rejectedWith('too large for the payment processors') + expect(paymentsProcessor.createInvoice).not.to.have.been.called + }) + + it('rejects a negative peering invoice before calling the processor', async () => { + await expect(peeringService.createPeeringInvoice('peer', -1000n, 'peering', 2592000)).to.be.rejectedWith( + 'positive whole number of satoshis', + ) + expect(paymentsProcessor.createInvoice).not.to.have.been.called + }) + + it('rejects a peering invoice with no period before calling the processor', async () => { + await expect(peeringService.createPeeringInvoice('peer', 21000n, 'peering', 0)).to.be.rejectedWith( + 'positive number of seconds', + ) + expect(paymentsProcessor.createInvoice).not.to.have.been.called + }) + it('applies a paid period and clears the open invoice pointers', async () => { settings.returns({ payments: { enabled: true, feeSchedules: { admission: [] } } }) invoiceRepository.findById.resolves( @@ -916,6 +972,39 @@ describe('PaymentsService', () => { expect(mockTrx.commit).to.have.been.calledOnce }) + it('grants exactly one term for an overpayment and keeps the full amount on the receipt', async () => { + settings.returns({ payments: { enabled: true, feeSchedules: { admission: [] } } }) + invoiceRepository.findById.resolves( + stubInvoice({ + purpose: InvoicePurpose.PEERING, + termAmountMsats: 21000n, + termPeriodSeconds: 2592000, + unit: InvoiceUnit.MSATS, + }), + ) + peeringPaymentRepository.lockByPeer.resolves(peeringRow({ currentInvoiceId: 'invoice-id' })) + + await peeringService.confirmInvoice(makeCompletedInvoice({ amountPaid: 42000n })) + + expect(invoiceRepository.confirmInvoice).to.have.been.calledWith( + 'invoice-id', + 42000n, + Sinon.match.instanceOf(Date), + mockTrx, + ) + expect(peeringPaymentRepository.save).to.have.been.calledWith( + Sinon.match({ + status: 'active', + lastSettledInvoiceId: 'invoice-id', + }), + mockTrx, + ) + expect(invoiceRepository.updatePeriod).to.have.been.calledOnce + + const [, periodStart, periodEnd] = invoiceRepository.updatePeriod.firstCall.args + expect((periodEnd as Date).getTime() - (periodStart as Date).getTime()).to.equal(2592000 * 1000) + }) + it('does not grant a second period for a duplicate callback', async () => { settings.returns({ payments: { enabled: true, feeSchedules: { admission: [] } } }) invoiceRepository.findById.resolves( @@ -935,6 +1024,28 @@ describe('PaymentsService', () => { expect(mockTrx.commit).to.have.been.calledOnce }) + it('settles the same invoice only once across two callbacks', async () => { + settings.returns({ payments: { enabled: true, feeSchedules: { admission: [] } } }) + let stored = stubInvoice({ + purpose: InvoicePurpose.PEERING, + termAmountMsats: 21000n, + termPeriodSeconds: 2592000, + unit: InvoiceUnit.MSATS, + }) + invoiceRepository.findById.callsFake(async () => stored) + invoiceRepository.confirmInvoice.callsFake(async () => { + stored = { ...stored, confirmedAt: new Date() } + }) + peeringPaymentRepository.lockByPeer.resolves(peeringRow()) + + await peeringService.confirmInvoice(makeCompletedInvoice()) + await peeringService.confirmInvoice(makeCompletedInvoice()) + + expect(peeringPaymentRepository.lockByPeer).to.have.been.calledOnce + expect(peeringPaymentRepository.save).to.have.been.calledOnce + expect(invoiceRepository.updatePeriod).to.have.been.calledOnce + }) + it('records an underpayment without activating access', async () => { settings.returns({ payments: { enabled: true, feeSchedules: { admission: [] } } }) invoiceRepository.findById.resolves( diff --git a/test/unit/utils/peering-billing.spec.ts b/test/unit/utils/peering-billing.spec.ts index d7bfe189a..233e7d300 100644 --- a/test/unit/utils/peering-billing.spec.ts +++ b/test/unit/utils/peering-billing.spec.ts @@ -59,6 +59,14 @@ describe('resolvePaidPeeringTerm', () => { ) }) + it('rejects an amount whose satoshi value exceeds Number.MAX_SAFE_INTEGER', () => { + // 100,000,000 BTC = 10^16 sats, above Number.MAX_SAFE_INTEGER. The processor + // adapters convert msats to sats with Number(), so this must fail closed. + expect(() => + resolvePaidPeeringTerm(withTerms([{ amount: 100_000_000, unit: 'btc', period: 60 }])), + ).to.throw(InvalidPeeringTermError) + }) + it('rejects an invalid period', () => { expect(() => resolvePaidPeeringTerm(withTerms([{ amount: 1000, unit: 'msats', period: 0 }]))).to.throw( InvalidPeeringTermError, From ebf01e61d7607ed6a3b49df21cbf0cecb7230922 Mon Sep 17 00:00:00 2001 From: ABHAY PANDEY Date: Sat, 14 Nov 2026 09:27:05 +0530 Subject: [PATCH 4/9] fix(peering): harden retry scan, expiry fallback and notification switches - advance a retry cursor so a page of permanently failing peering invoices cannot starve later ones - restrict the 404/expired fallback to pending invoices, so a completed peering settlement is never marked expired - start the paid period at settlement time instead of the invoice receipt timestamp (LNbits uses the creation time there) - add the four peering notification switches to the patch schema, default settings and admin console - add regression tests for the retry cursor, 404 fallback, settlement-time period start and peering switches --- resources/admin/assets/notifications.js | 4 + resources/admin/dashboard.html | 24 ++++++ resources/default-settings.yaml | 4 + src/@types/repositories.ts | 2 +- src/@types/services.ts | 2 +- src/app/maintenance-worker.ts | 20 ++++- src/repositories/invoice-repository.ts | 5 +- src/schemas/admin-notifications-schema.ts | 4 + src/services/payments-service.ts | 24 +++--- test/unit/app/maintenance-worker.spec.ts | 74 +++++++++++++++++++ .../patch-notifications-controller.spec.ts | 22 ++++++ .../repositories/invoice-repository.spec.ts | 11 ++- test/unit/services/payments-service.spec.ts | 42 ++++++++++- 13 files changed, 217 insertions(+), 21 deletions(-) diff --git a/resources/admin/assets/notifications.js b/resources/admin/assets/notifications.js index 8d60629a9..bf0ed61ab 100644 --- a/resources/admin/assets/notifications.js +++ b/resources/admin/assets/notifications.js @@ -58,6 +58,10 @@ 'admission.invoice.created': true, 'admission.invoice.paid': true, 'admission.invoice.failed': true, + 'peering.invoice.created': true, + 'peering.invoice.paid': true, + 'peering.invoice.failed': true, + 'peering.access.lapsed': true, 'settings.changed': true, 'relay.restarted': false, 'probe.failed': true, diff --git a/resources/admin/dashboard.html b/resources/admin/dashboard.html index 40771553e..61b9cf1d8 100644 --- a/resources/admin/dashboard.html +++ b/resources/admin/dashboard.html @@ -406,6 +406,30 @@

Operator notifications

+
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
diff --git a/resources/default-settings.yaml b/resources/default-settings.yaml index 12dc90b75..1d7ed2c34 100755 --- a/resources/default-settings.yaml +++ b/resources/default-settings.yaml @@ -462,6 +462,10 @@ admin: admission.invoice.created: true admission.invoice.paid: true admission.invoice.failed: true + peering.invoice.created: true + peering.invoice.paid: true + peering.invoice.failed: true + peering.access.lapsed: true settings.changed: true relay.restarted: false probe.failed: true diff --git a/src/@types/repositories.ts b/src/@types/repositories.ts index 461d25dc9..1a03d5086 100644 --- a/src/@types/repositories.ts +++ b/src/@types/repositories.ts @@ -80,7 +80,7 @@ export interface IInvoiceRepository { * `confirmed_at` stays null when the settlement transaction rolls back, so the * maintenance worker can retry them instead of stranding a paid peer. */ - findUnconfirmedPeeringInvoices(limit?: number, client?: DatabaseClient): Promise + findUnconfirmedPeeringInvoices(limit?: number, offset?: number, client?: DatabaseClient): Promise } export interface IPeeringPaymentRepository { diff --git a/src/@types/services.ts b/src/@types/services.ts index d1ae6a90b..de9366223 100644 --- a/src/@types/services.ts +++ b/src/@types/services.ts @@ -51,7 +51,7 @@ export interface IPaymentsService { ensurePeeringInvoiceNotification(invoice: Invoice): Promise getPendingInvoices(offset?: number): Promise /** Completed peering invoices whose settlement did not commit, for retry. */ - getUnconfirmedPeeringInvoices(limit?: number): Promise + getUnconfirmedPeeringInvoices(limit?: number, offset?: number): Promise } export interface ISubscriptionService { diff --git a/src/app/maintenance-worker.ts b/src/app/maintenance-worker.ts index dc4c68535..e066b3a46 100644 --- a/src/app/maintenance-worker.ts +++ b/src/app/maintenance-worker.ts @@ -94,6 +94,11 @@ export class MaintenanceWorker implements IRunnable { * ten invoices that never resolve starve everything behind them. */ private pendingInvoiceOffset = 0 + /** + * Where the next retry scan starts. A page of invoices that keep failing must + * not starve the ones behind them. + */ + private retryPeeringInvoiceOffset = 0 public constructor( private readonly process: NodeJS.Process, @@ -191,20 +196,27 @@ export class MaintenanceWorker implements IRunnable { // settlement rolled back, confirmed_at is still null and the invoice is no // longer in the pending page. Pick those up first so a paid peer is never // stranded between "completed" and "settled". + const retryOffset = this.retryPeeringInvoiceOffset const retryablePeering = await this.paymentsService.getUnconfirmedPeeringInvoices( PENDING_INVOICE_PAGE_SIZE, + retryOffset, ) const pending = await this.paymentsService.getPendingInvoices(this.pendingInvoiceOffset) logger( - 'found %d pending invoice(s) from offset %d and %d unconfirmed peering invoice(s)', + 'found %d pending invoice(s) from offset %d and %d unconfirmed peering invoice(s) from offset %d', pending.length, this.pendingInvoiceOffset, retryablePeering.length, + retryOffset, ) - // A short page means we reached the end, so start over next pass. + // A short page means we reached the end, so start over next pass. A full + // page advances the retry cursor so the same failing invoices cannot starve + // the ones behind them. this.pendingInvoiceOffset = pending.length < PENDING_INVOICE_PAGE_SIZE ? 0 : this.pendingInvoiceOffset + PENDING_INVOICE_PAGE_SIZE + this.retryPeeringInvoiceOffset = + retryablePeering.length < PENDING_INVOICE_PAGE_SIZE ? 0 : retryOffset + PENDING_INVOICE_PAGE_SIZE // A retryable peering invoice is completed, not pending, so the two lists do // not normally overlap. Deduplicate anyway so a future query change cannot @@ -267,7 +279,9 @@ export class MaintenanceWorker implements IRunnable { } successful++ } catch (error) { - if (isNotFoundError(error) && isExpiredInvoice(invoice)) { + // Only a pending invoice has no settled period to protect. A completed + // invoice may be a peering settlement that rolled back; never expire it. + if (invoice.status === InvoiceStatus.PENDING && isNotFoundError(error) && isExpiredInvoice(invoice)) { logger('marking expired invoice %s after payment processor returned 404', invoice.id) await this.paymentsService.updateInvoiceStatus({ id: invoice.id, diff --git a/src/repositories/invoice-repository.ts b/src/repositories/invoice-repository.ts index 8883a5af0..cd7428ab2 100644 --- a/src/repositories/invoice-repository.ts +++ b/src/repositories/invoice-repository.ts @@ -70,11 +70,13 @@ export class InvoiceRepository implements IInvoiceRepository { public async findUnconfirmedPeeringInvoices( limit = 10, + offset = 0, client: DatabaseClient = this.dbClient, ): Promise { // The status is committed before settlement runs, so a rolled-back // settlement leaves a completed invoice with no confirmed_at. The worker - // must be able to find those and try the settlement again. + // must be able to find those and try the settlement again. Offset advances + // the scan past invoices that keep failing so later ones are not starved. const dbInvoices = await client('invoices') .where({ purpose: InvoicePurpose.PEERING, @@ -82,6 +84,7 @@ export class InvoiceRepository implements IInvoiceRepository { }) .whereNull('confirmed_at') .orderBy('created_at', 'asc') + .offset(offset) .limit(limit) .select() diff --git a/src/schemas/admin-notifications-schema.ts b/src/schemas/admin-notifications-schema.ts index bba837296..7f5a0e26c 100644 --- a/src/schemas/admin-notifications-schema.ts +++ b/src/schemas/admin-notifications-schema.ts @@ -21,6 +21,10 @@ export const adminNotificationsPatchBodySchema = z 'admission.invoice.created': z.boolean().optional(), 'admission.invoice.paid': z.boolean().optional(), 'admission.invoice.failed': z.boolean().optional(), + 'peering.invoice.created': z.boolean().optional(), + 'peering.invoice.paid': z.boolean().optional(), + 'peering.invoice.failed': z.boolean().optional(), + 'peering.access.lapsed': z.boolean().optional(), 'settings.changed': z.boolean().optional(), 'relay.restarted': z.boolean().optional(), 'probe.failed': z.boolean().optional(), diff --git a/src/services/payments-service.ts b/src/services/payments-service.ts index 72722d084..cbc9a2190 100644 --- a/src/services/payments-service.ts +++ b/src/services/payments-service.ts @@ -57,10 +57,13 @@ export class PaymentsService implements IPaymentsService { } } - public async getUnconfirmedPeeringInvoices(limit = PENDING_INVOICE_PAGE_SIZE): Promise { - logger('get unconfirmed peering invoices') + public async getUnconfirmedPeeringInvoices( + limit = PENDING_INVOICE_PAGE_SIZE, + offset = 0, + ): Promise { + logger('get unconfirmed peering invoices from offset %d', offset) try { - return await this.invoiceRepository.findUnconfirmedPeeringInvoices(limit) + return await this.invoiceRepository.findUnconfirmedPeeringInvoices(limit, offset) } catch (error) { logger.error('Unable to get unconfirmed peering invoices.', error) @@ -436,7 +439,10 @@ export class PaymentsService implements IPaymentsService { return { activated: true, duplicate: true } } - const confirmedAt = invoice.confirmedAt as Date + // The invoice's confirmedAt is the receipt timestamp. It is not reliable as a + // payment time: LNbits, for example, fills it from the invoice creation time. + // The access window starts when this relay actually grants it. + const settledAt = new Date() if (amountPaidMsat < termAmountMsats) { logger( @@ -449,7 +455,7 @@ export class PaymentsService implements IPaymentsService { await this.peeringPaymentRepository.save( { ...payment, - lastCheckedAt: confirmedAt, + lastCheckedAt: settledAt, lastError: `underpaid: received ${amountPaidMsat.toString()} of ${termAmountMsats.toString()} msats`, }, client, @@ -461,8 +467,8 @@ export class PaymentsService implements IPaymentsService { // Overpayments are not credited to the peer row. The invoice's amount_paid // is the receipt; an operator can refund the surplus out of band. // Paying early extends from the current period end; paying after a lapse starts now. - const stillRunning = payment.periodEnd instanceof Date && payment.periodEnd.getTime() > confirmedAt.getTime() - const periodStart = stillRunning ? (payment.periodEnd as Date) : confirmedAt + const stillRunning = payment.periodEnd instanceof Date && payment.periodEnd.getTime() > settledAt.getTime() + const periodStart = stillRunning ? (payment.periodEnd as Date) : settledAt const periodEnd = peeringPeriodEnd(periodStart, termPeriodSeconds) await this.peeringPaymentRepository.save( @@ -474,8 +480,8 @@ export class PaymentsService implements IPaymentsService { periodStart, periodEnd, lastSettledInvoiceId: invoice.id, - lastSettledAt: confirmedAt, - lastCheckedAt: confirmedAt, + lastSettledAt: settledAt, + lastCheckedAt: settledAt, lastError: null, }, client, diff --git a/test/unit/app/maintenance-worker.spec.ts b/test/unit/app/maintenance-worker.spec.ts index bc6a1f4c7..cbd03e1d6 100644 --- a/test/unit/app/maintenance-worker.spec.ts +++ b/test/unit/app/maintenance-worker.spec.ts @@ -512,6 +512,7 @@ describe('MaintenanceWorker', () => { expect(paymentsService.getUnconfirmedPeeringInvoices).to.have.been.calledOnceWithExactly( PENDING_INVOICE_PAGE_SIZE, + 0, ) expect(paymentsService.updateInvoiceStatus).to.have.been.calledOnceWithExactly({ id: 'inv-1', @@ -575,6 +576,62 @@ describe('MaintenanceWorker', () => { expect(paymentsService.confirmInvoice).to.have.been.calledTwice }) + it('advances the retry cursor past a full page of failing peering invoices', async () => { + settingsState.payments = { enabled: true } as any + const fullPage = Array.from({ length: PENDING_INVOICE_PAGE_SIZE }, (_, i) => ({ + ...pendingInvoice, + id: `peering-${i}`, + status: InvoiceStatus.COMPLETED, + confirmedAt: null, + purpose: InvoicePurpose.PEERING, + })) + paymentsService.getUnconfirmedPeeringInvoices.resolves(fullPage) + paymentsService.getPendingInvoices.resolves([]) + paymentsService.getInvoiceFromPaymentsProcessor.resolves({ + id: 'peering-0', + status: InvoiceStatus.COMPLETED, + confirmedAt: new Date(), + }) + paymentsService.confirmInvoice.rejects(new Error('settlement failed')) + + await (worker as any).onSchedule() + await (worker as any).onSchedule() + + expect(paymentsService.getUnconfirmedPeeringInvoices.getCall(0).args[1]).to.equal(0) + expect(paymentsService.getUnconfirmedPeeringInvoices.getCall(1).args[1]).to.equal( + PENDING_INVOICE_PAGE_SIZE, + ) + }) + + it('resets the retry cursor after a short page', async () => { + settingsState.payments = { enabled: true } as any + const fullPage = Array.from({ length: PENDING_INVOICE_PAGE_SIZE }, (_, i) => ({ + ...pendingInvoice, + id: `peering-${i}`, + status: InvoiceStatus.COMPLETED, + confirmedAt: null, + purpose: InvoicePurpose.PEERING, + })) + paymentsService.getUnconfirmedPeeringInvoices.onFirstCall().resolves(fullPage) + paymentsService.getUnconfirmedPeeringInvoices.onSecondCall().resolves([fullPage[0]]) + paymentsService.getUnconfirmedPeeringInvoices.onThirdCall().resolves([]) + paymentsService.getPendingInvoices.resolves([]) + paymentsService.getInvoiceFromPaymentsProcessor.resolves({ + id: 'peering-0', + status: InvoiceStatus.PENDING, + }) + + await (worker as any).onSchedule() + await (worker as any).onSchedule() + await (worker as any).onSchedule() + + expect(paymentsService.getUnconfirmedPeeringInvoices.getCall(0).args[1]).to.equal(0) + expect(paymentsService.getUnconfirmedPeeringInvoices.getCall(1).args[1]).to.equal( + PENDING_INVOICE_PAGE_SIZE, + ) + expect(paymentsService.getUnconfirmedPeeringInvoices.getCall(2).args[1]).to.equal(0) + }) + it('continues processing remaining invoices when one throws', async () => { const secondInvoice = { ...pendingInvoice, id: 'inv-2' } settingsState.payments = { enabled: true } as any @@ -667,6 +724,23 @@ describe('MaintenanceWorker', () => { }) }) + it('does not expire a completed peering invoice when the processor returns 404', async () => { + const completedUnconfirmed = { + ...pendingInvoice, + status: InvoiceStatus.COMPLETED, + confirmedAt: null, + purpose: InvoicePurpose.PEERING, + expiresAt: new Date(Date.now() - 60000), + } + settingsState.payments = { enabled: true } as any + paymentsService.getUnconfirmedPeeringInvoices.resolves([completedUnconfirmed]) + paymentsService.getInvoiceFromPaymentsProcessor.rejects({ response: { status: 404 } }) + + await (worker as any).onSchedule() + + expect(paymentsService.updateInvoiceStatus).not.to.have.been.called + }) + it('keeps an expired pending invoice pending when the processor lookup fails without 404', async () => { const expiredInvoice = { ...pendingInvoice, diff --git a/test/unit/controllers/admin/patch-notifications-controller.spec.ts b/test/unit/controllers/admin/patch-notifications-controller.spec.ts index 9698bcfae..df34f311a 100644 --- a/test/unit/controllers/admin/patch-notifications-controller.spec.ts +++ b/test/unit/controllers/admin/patch-notifications-controller.spec.ts @@ -130,6 +130,28 @@ describe('PatchAdminNotificationsController', () => { expect(response.send.firstCall.args[0].ok).to.equal(true) }) + it('accepts the peering notification switches', async () => { + const controller = new PatchAdminNotificationsController(outboxRepository as any) + await controller.handleRequest( + { + body: { + events: { + 'peering.invoice.paid': false, + 'peering.access.lapsed': false, + }, + }, + ip: '127.0.0.1', + } as any, + response as any, + ) + + expect(response.status).to.have.been.calledWith(200) + + const saved = (settingsConfig.saveSettings as Sinon.SinonStub).firstCall.args[0] + expect(saved.admin.notifications.events['peering.invoice.paid']).to.equal(false) + expect(saved.admin.notifications.events['peering.access.lapsed']).to.equal(false) + }) + it('still returns 200 when outbox enqueue fails', async () => { outboxRepository.enqueue.rejects(new Error('db unavailable')) const controller = new PatchAdminNotificationsController(outboxRepository as any) diff --git a/test/unit/repositories/invoice-repository.spec.ts b/test/unit/repositories/invoice-repository.spec.ts index 7e01fbbc4..ac92b1420 100644 --- a/test/unit/repositories/invoice-repository.spec.ts +++ b/test/unit/repositories/invoice-repository.spec.ts @@ -222,7 +222,8 @@ describe('InvoiceRepository', () => { function makeClient(results: any[]) { const selectStub = sandbox.stub().resolves(results) const limitStub = sandbox.stub().returns({ select: selectStub }) - const orderByStub = sandbox.stub().returns({ limit: limitStub }) + const offsetStub = sandbox.stub().returns({ limit: limitStub }) + const orderByStub = sandbox.stub().returns({ offset: offsetStub }) const whereNullStub = sandbox.stub().returns({ orderBy: orderByStub }) const whereStub = sandbox.stub().returns({ whereNull: whereNullStub }) return { @@ -230,6 +231,7 @@ describe('InvoiceRepository', () => { whereStub, whereNullStub, orderByStub, + offsetStub, limitStub, } } @@ -243,7 +245,7 @@ describe('InvoiceRepository', () => { } const { client } = makeClient([peeringRow]) - const result = await repository.findUnconfirmedPeeringInvoices(10, client) + const result = await repository.findUnconfirmedPeeringInvoices(10, 0, client) expect(result).to.have.length(1) expect(result[0].id).to.equal('test-invoice-id') @@ -252,9 +254,9 @@ describe('InvoiceRepository', () => { }) it('filters by peering purpose, completed status and a null confirmation', async () => { - const { client, whereStub, whereNullStub, orderByStub, limitStub } = makeClient([]) + const { client, whereStub, whereNullStub, orderByStub, offsetStub, limitStub } = makeClient([]) - await repository.findUnconfirmedPeeringInvoices(25, client) + await repository.findUnconfirmedPeeringInvoices(25, 5, client) expect(whereStub).to.have.been.calledWith({ purpose: InvoicePurpose.PEERING, @@ -262,6 +264,7 @@ describe('InvoiceRepository', () => { }) expect(whereNullStub).to.have.been.calledWith('confirmed_at') expect(orderByStub).to.have.been.calledWith('created_at', 'asc') + expect(offsetStub).to.have.been.calledWith(5) expect(limitStub).to.have.been.calledWith(25) }) }) diff --git a/test/unit/services/payments-service.spec.ts b/test/unit/services/payments-service.spec.ts index 235d533fe..576fc653a 100644 --- a/test/unit/services/payments-service.spec.ts +++ b/test/unit/services/payments-service.spec.ts @@ -151,6 +151,18 @@ describe('PaymentsService', () => { expect(result).to.deep.equal(invoices) expect(invoiceRepository.findUnconfirmedPeeringInvoices).to.have.been.calledOnceWithExactly( PENDING_INVOICE_PAGE_SIZE, + 0, + ) + }) + + it('passes the retry offset through', async () => { + invoiceRepository.findUnconfirmedPeeringInvoices.resolves([stubInvoice()]) + + await service.getUnconfirmedPeeringInvoices(PENDING_INVOICE_PAGE_SIZE, 20) + + expect(invoiceRepository.findUnconfirmedPeeringInvoices).to.have.been.calledOnceWithExactly( + PENDING_INVOICE_PAGE_SIZE, + 20, ) }) @@ -1197,9 +1209,35 @@ describe('PaymentsService', () => { expect(mockTrx.commit).to.have.been.calledOnce }) + it('starts a first period at settlement time, not the receipt timestamp', async () => { + const clock = Sinon.useFakeTimers({ now: new Date('2026-11-14T12:00:00.000Z') }) + const receiptTime = new Date('2026-11-14T11:58:00.000Z') + settings.returns({ payments: { enabled: true, feeSchedules: { admission: [] } } }) + invoiceRepository.findById.resolves( + stubInvoice({ + purpose: InvoicePurpose.PEERING, + termAmountMsats: 21000n, + termPeriodSeconds: 60, + }), + ) + peeringPaymentRepository.lockByPeer.resolves(peeringRow({ periodEnd: null })) + + try { + await peeringService.confirmInvoice(makeCompletedInvoice({ confirmedAt: receiptTime })) + + const [, periodStart, periodEnd] = invoiceRepository.updatePeriod.firstCall.args + expect(periodStart).to.deep.equal(new Date('2026-11-14T12:00:00.000Z')) + expect((periodEnd as Date).getTime()).to.equal( + (periodStart as Date).getTime() + 60 * 1000, + ) + } finally { + clock.restore() + } + }) + it('extends a running period from its existing end', async () => { - const existingPeriodEnd = new Date('2026-11-20T00:00:00.000Z') - const confirmedAt = new Date('2026-11-09T12:00:00.000Z') + const existingPeriodEnd = new Date(Date.now() + 5 * 86_400_000) + const confirmedAt = new Date(Date.now() - 86_400_000) settings.returns({ payments: { enabled: true, feeSchedules: { admission: [] } } }) invoiceRepository.findById.resolves( stubInvoice({ From a6d50fd1519fa53de0aeec353027d8e964f585d5 Mon Sep 17 00:00:00 2001 From: ABHAY PANDEY Date: Sat, 14 Nov 2026 10:36:55 +0530 Subject: [PATCH 5/9] test(peering): cover the paid peering ledger in integration Add a Cucumber feature that drives the real Postgres ledger: - create and settle a peering invoice, assert the peer and invoice periods, and verify the payment does not credit a user balance or admit the user - duplicate callback keeps one period; underpayment records last_error - completed-but-unconfirmed invoice is returned by the retry query and disappears after settlement - cover term parsing, invoice request filters, notification switches, creation boundaries and kind 402 publishing --- .../peering/paid-peering-ledger.feature | 30 ++ .../peering/paid-peering-ledger.feature.ts | 501 ++++++++++++++++++ 2 files changed, 531 insertions(+) create mode 100644 test/integration/features/peering/paid-peering-ledger.feature create mode 100644 test/integration/features/peering/paid-peering-ledger.feature.ts diff --git a/test/integration/features/peering/paid-peering-ledger.feature b/test/integration/features/peering/paid-peering-ledger.feature new file mode 100644 index 000000000..b62534f60 --- /dev/null +++ b/test/integration/features/peering/paid-peering-ledger.feature @@ -0,0 +1,30 @@ +@paid-peering-ledger +Feature: Paid peering billing ledger + + The ledger records a peering invoice, grants exactly one paid period, and keeps + the payment separate from the user admission and balance flow. + + Scenario: A paid peering invoice activates exactly one term + When the peer pays a 21000 msat peering invoice + Then the peer has an active peering period for 60 seconds + And the peering payment did not credit a user balance + When the same peering invoice is paid again + Then the peer period did not change + + Scenario: An underpaid peering invoice is recorded without activating access + When the peer underpays a 21000 msat peering invoice with 1000 msats + Then the underpayment is recorded without activating the peer + + Scenario: A completed but unconfirmed peering invoice is retryable + When a peering invoice is marked completed without a confirmation + Then the unconfirmed peering invoice is returned for retry + When the retryable peering invoice is paid + Then the unconfirmed peering invoice is no longer returned for retry + + Scenario: Terms, invoice requests, notification switches and creation boundaries + When I parse the paid peering terms + Then only all-invoice peering requests are exempt + And the peering notification switches are accepted + And the peering invoice creation boundary rejects invalid terms + When the peering invoice notification is published + Then the invoice notification is published once with peering tags diff --git a/test/integration/features/peering/paid-peering-ledger.feature.ts b/test/integration/features/peering/paid-peering-ledger.feature.ts new file mode 100644 index 000000000..6318e0d88 --- /dev/null +++ b/test/integration/features/peering/paid-peering-ledger.feature.ts @@ -0,0 +1,501 @@ +import { After, Before, Then, When, World } from '@cucumber/cucumber' +import { expect } from 'chai' +import { randomBytes, randomUUID } from 'node:crypto' +import Sinon from 'sinon' + +import { adminNotificationsPatchBodySchema } from '../../../../src/schemas/admin-notifications-schema' +import { getMasterDbClient } from '../../../../src/database/client' +import { InvoiceStatus, InvoiceUnit } from '../../../../src/@types/invoice' +import { InvoiceRepository } from '../../../../src/repositories/invoice-repository' +import { PeeringPaymentRepository } from '../../../../src/repositories/peering-payment-repository' +import { PaymentsService } from '../../../../src/services/payments-service' +import { SettingsStatic } from '../../../../src/utils/settings' +import * as eventUtils from '../../../../src/utils/event' +import { + InvalidPeeringTermError, + isPeeringAccessActive, + isPeeringInvoiceRequest, + peeringInvoiceDescription, + peeringPeriodEnd, + resolvePaidPeeringTerm, +} from '../../../../src/utils/peering-billing' + +const TERM_MSATS = 21000n +const TERM_SECONDS = 60 + +const randomPeer = () => randomBytes(32).toString('hex') + +const rejects = async (promise: Promise): Promise => { + try { + await promise + return false + } catch { + return true + } +} + +const createService = (world: World>): void => { + const db = getMasterDbClient() + const invoiceRepository = new InvoiceRepository(db) + const peeringPaymentRepository = new PeeringPaymentRepository(db) + + const eventRepository = { + findByFilters: async () => world.parameters.peeringEvents.map(() => ({ event_id: Buffer.alloc(32) })), + create: async (event: unknown) => { + world.parameters.peeringEvents.push(event) + world.parameters.peeringEventCreates = (world.parameters.peeringEventCreates ?? 0) + 1 + return 1 + }, + } as any + + const userRepository = { + upsert: async () => { + throw new Error('peering must not upsert a user') + }, + admitUser: async () => { + throw new Error('peering must not admit a user') + }, + getBalanceByPubkey: async () => 0n, + } as any + + const notificationOutboxRepository = { + enqueue: async () => ({}), + } as any + + const paymentsProcessor = { + createInvoice: async (request: any) => { + world.parameters.peeringInvoiceCreates = (world.parameters.peeringInvoiceCreates ?? 0) + 1 + return { + id: `peering-${randomUUID()}`, + pubkey: request.requestId, + bolt11: `lnbc${Math.max(1, Number(request.amount / 1000n))}n1peering`, + amountRequested: request.amount, + description: request.description, + unit: InvoiceUnit.MSATS, + status: InvoiceStatus.PENDING, + expiresAt: new Date(Date.now() + 900_000), + createdAt: new Date(), + } + }, + getInvoice: async () => { + throw new Error('processor lookup is not used by the ledger test') + }, + } as any + + world.parameters.peeringDb = db + world.parameters.peeringInvoiceRepository = invoiceRepository + world.parameters.peeringPaymentRepository = peeringPaymentRepository + world.parameters.peeringService = new PaymentsService( + db, + paymentsProcessor, + userRepository, + invoiceRepository, + eventRepository, + () => SettingsStatic._settings as any, + notificationOutboxRepository, + undefined, + peeringPaymentRepository, + ) +} + +const createInvoice = async ( + world: World>, + peer: string, + amountMsats = TERM_MSATS, + periodSeconds = TERM_SECONDS, +) => { + const service = world.parameters.peeringService as PaymentsService + const repository = world.parameters.peeringPaymentRepository as PeeringPaymentRepository + const db = world.parameters.peeringDb + + const invoice = await service.createPeeringInvoice( + peer, + amountMsats, + 'paid peering integration test', + periodSeconds, + ) + + world.parameters.peeringInvoiceIds.push(invoice.id) + if (!world.parameters.peeringPeerPubkeys.includes(peer)) { + world.parameters.peeringPeerPubkeys.push(peer) + } + + if (!world.parameters.peeringUserPubkeys.includes(peer)) { + await db('users').insert({ pubkey: Buffer.from(peer, 'hex') }).onConflict('pubkey').ignore() + world.parameters.peeringUserPubkeys.push(peer) + } + + await repository.ensureRow(peer, db) + + return invoice +} + +const payInvoice = async ( + world: World>, + invoice: any, + amountPaid: bigint = invoice.amountRequested, +) => { + const service = world.parameters.peeringService as PaymentsService + await service.confirmInvoice({ + ...invoice, + status: InvoiceStatus.COMPLETED, + confirmedAt: new Date(), + amountPaid, + }) +} + +const peerRow = (world: World>, peer: string) => + world.parameters.peeringDb('peering_payments').where('peer_pubkey', Buffer.from(peer, 'hex')).first() + +Before({ tags: '@paid-peering-ledger' }, function (this: World>) { + this.parameters.peeringPreviousSettings = SettingsStatic._settings + this.parameters.peeringPeerPubkeys = [] + this.parameters.peeringInvoiceIds = [] + this.parameters.peeringUserPubkeys = [] + this.parameters.peeringEvents = [] + this.parameters.peeringEventCreates = 0 + this.parameters.peeringInvoiceCreates = 0 + this.parameters.peeringSandbox = Sinon.createSandbox() + this.parameters.peeringSandbox.stub(eventUtils, 'broadcastEvent').resolves() + + const settings = SettingsStatic._settings as any + SettingsStatic._settings = { + ...settings, + peering: { + ...(settings?.peering ?? {}), + enabled: true, + terms: [{ amount: Number(TERM_MSATS), unit: 'msats', period: TERM_SECONDS }], + }, + } as any + + createService(this) +}) + +After({ tags: '@paid-peering-ledger' }, async function (this: World>) { + const db = this.parameters.peeringDb + const peers: string[] = this.parameters.peeringPeerPubkeys ?? [] + const invoices: string[] = this.parameters.peeringInvoiceIds ?? [] + const users: string[] = this.parameters.peeringUserPubkeys ?? [] + + try { + if (db && peers.length > 0) { + await db('peering_payments') + .whereIn( + 'peer_pubkey', + peers.map((peer) => Buffer.from(peer, 'hex')), + ) + .delete() + } + if (db && invoices.length > 0) { + await db('invoices').whereIn('id', invoices).delete() + } + if (db && users.length > 0) { + await db('users') + .whereIn( + 'pubkey', + users.map((peer) => Buffer.from(peer, 'hex')), + ) + .delete() + } + } finally { + this.parameters.peeringSandbox?.restore() + if (this.parameters.peeringPreviousSettings) { + SettingsStatic._settings = this.parameters.peeringPreviousSettings + } + + this.parameters.peeringDb = undefined + this.parameters.peeringInvoiceRepository = undefined + this.parameters.peeringPaymentRepository = undefined + this.parameters.peeringService = undefined + this.parameters.peeringPreviousSettings = undefined + this.parameters.peeringSandbox = undefined + this.parameters.peeringEvents = [] + this.parameters.peeringPeerPubkeys = [] + this.parameters.peeringInvoiceIds = [] + this.parameters.peeringUserPubkeys = [] + this.parameters.peeringInvoice = undefined + this.parameters.peeringPeer = undefined + this.parameters.peeringFirstPeriodEnd = undefined + this.parameters.peeringUnderpaidInvoice = undefined + this.parameters.peeringUnderpaidPeer = undefined + this.parameters.peeringRetryInvoice = undefined + this.parameters.peeringRetryPeer = undefined + this.parameters.peeringNotificationInvoice = undefined + this.parameters.peeringNotificationPeer = undefined + } +}) + +When('the peer pays a {int} msat peering invoice', async function (this: World>, amount: number) { + const peer = randomPeer() + const invoice = await createInvoice(this, peer, BigInt(amount)) + + await payInvoice(this, invoice) + + this.parameters.peeringPeer = peer + this.parameters.peeringInvoice = invoice +}) + +Then( + 'the peer has an active peering period for {int} seconds', + async function (this: World>, seconds: number) { + const peer = this.parameters.peeringPeer as string + const invoice = this.parameters.peeringInvoice as any + const row = await peerRow(this, peer) + + expect(row).to.exist + expect(row.status).to.equal('active') + expect(row.last_settled_invoice_id).to.equal(invoice.id) + expect(row.period_start).to.be.instanceOf(Date) + expect(row.period_end).to.be.instanceOf(Date) + expect(Math.round((row.period_end.getTime() - row.period_start.getTime()) / 1000)).to.equal(seconds) + expect(row.period_end.getTime()).to.be.greaterThan(Date.now()) + + const invoiceRow = await this.parameters.peeringDb('invoices').where('id', invoice.id).first() + expect(invoiceRow.confirmed_at).to.be.instanceOf(Date) + expect(invoiceRow.period_start).to.be.instanceOf(Date) + expect(invoiceRow.period_end).to.be.instanceOf(Date) + + const repository = this.parameters.peeringPaymentRepository as PeeringPaymentRepository + const found = await repository.findByPeer(peer) + expect(found?.status).to.equal('active') + + const listed = await repository.list() + expect(listed.some((payment) => payment.peerPubkey === peer)).to.equal(true) + + const before = new Date(Date.now() + 2 * seconds * 1000) + const due = await repository.findDueForRenewal(before, 10, 0) + expect(due.some((payment) => payment.peerPubkey === peer)).to.equal(true) + + const ended = await repository.findPeriodEnded(before, 10, 0) + expect(ended.some((payment) => payment.peerPubkey === peer)).to.equal(true) + }, +) + +Then('the peering payment did not credit a user balance', async function (this: World>) { + const peer = this.parameters.peeringPeer as string + const user = await this.parameters.peeringDb('users').where('pubkey', Buffer.from(peer, 'hex')).first() + + expect(user).to.exist + expect(String(user.balance)).to.equal('0') + expect(user.is_admitted).to.equal(false) +}) + +When('the same peering invoice is paid again', async function (this: World>) { + const peer = this.parameters.peeringPeer as string + const row = await peerRow(this, peer) + this.parameters.peeringFirstPeriodEnd = row.period_end + + await payInvoice(this, this.parameters.peeringInvoice) +}) + +Then('the peer period did not change', async function (this: World>) { + const peer = this.parameters.peeringPeer as string + const row = await peerRow(this, peer) + + expect(row.period_end.getTime()).to.equal(this.parameters.peeringFirstPeriodEnd.getTime()) + expect(row.last_settled_invoice_id).to.equal(this.parameters.peeringInvoice.id) +}) + +When( + 'the peer underpays a {int} msat peering invoice with {int} msats', + async function (this: World>, amount: number, paid: number) { + const peer = randomPeer() + const invoice = await createInvoice(this, peer, BigInt(amount)) + + await payInvoice(this, invoice, BigInt(paid)) + + this.parameters.peeringUnderpaidPeer = peer + this.parameters.peeringUnderpaidInvoice = invoice + }, +) + +Then( + 'the underpayment is recorded without activating the peer', + async function (this: World>) { + const peer = this.parameters.peeringUnderpaidPeer as string + const invoice = this.parameters.peeringUnderpaidInvoice as any + const row = await peerRow(this, peer) + + expect(row).to.exist + expect(row.status).to.equal('unpaid') + expect(row.period_end).to.equal(null) + expect(row.last_settled_invoice_id).to.equal(null) + expect(row.last_error).to.include('underpaid') + + const invoiceRow = await this.parameters.peeringDb('invoices').where('id', invoice.id).first() + expect(invoiceRow.confirmed_at).to.be.instanceOf(Date) + expect(invoiceRow.period_end).to.equal(null) + }, +) + +When( + 'a peering invoice is marked completed without a confirmation', + async function (this: World>) { + const peer = randomPeer() + const invoice = await createInvoice(this, peer) + + await this.parameters.peeringInvoiceRepository.updateStatus({ + id: invoice.id, + status: InvoiceStatus.COMPLETED, + }) + + this.parameters.peeringRetryPeer = peer + this.parameters.peeringRetryInvoice = invoice + }, +) + +Then('the unconfirmed peering invoice is returned for retry', async function (this: World>) { + const service = this.parameters.peeringService as PaymentsService + const invoice = this.parameters.peeringRetryInvoice as any + const retryable = await service.getUnconfirmedPeeringInvoices(100, 0) + + expect(retryable.some((candidate) => candidate.id === invoice.id)).to.equal(true) +}) + +When('the retryable peering invoice is paid', async function (this: World>) { + await payInvoice(this, this.parameters.peeringRetryInvoice) +}) + +Then( + 'the unconfirmed peering invoice is no longer returned for retry', + async function (this: World>) { + const service = this.parameters.peeringService as PaymentsService + const invoice = this.parameters.peeringRetryInvoice as any + const retryable = await service.getUnconfirmedPeeringInvoices(100, 0) + + expect(retryable.some((candidate) => candidate.id === invoice.id)).to.equal(false) + }, +) + +When('I parse the paid peering terms', function () { + const base = SettingsStatic._settings as any + const withTerms = (terms: unknown[]) => ({ + ...base, + peering: { + ...(base?.peering ?? {}), + terms, + }, + }) + + const valid = [ + { amount: 21000, unit: 'msats', period: 60, amountMsats: 21000n }, + { amount: 21, unit: 'sats', period: 60, amountMsats: 21000n }, + { amount: 1, unit: 'btc', period: 60, amountMsats: 100_000_000_000n }, + ] + + for (const term of valid) { + expect(resolvePaidPeeringTerm(withTerms([term]))).to.deep.equal({ + amountMsats: term.amountMsats, + periodSeconds: term.period, + }) + } + + expect(resolvePaidPeeringTerm(withTerms([{ amount: 0, unit: 'msats', period: 60 }]))).to.be.undefined + + const invalid = [ + { amount: -1, unit: 'msats', period: 60 }, + { amount: 1500, unit: 'msats', period: 60 }, + { amount: 1, unit: 'usd', period: 60 }, + { amount: 100_000_000, unit: 'btc', period: 60 }, + { amount: 21000, unit: 'msats', period: 0 }, + ] + + for (const term of invalid) { + expect(() => resolvePaidPeeringTerm(withTerms([term]))).to.throw(InvalidPeeringTermError) + } + + const start = new Date('2026-01-01T00:00:00.000Z') + expect(peeringPeriodEnd(start, 60).getTime() - start.getTime()).to.equal(60_000) + expect( + peeringInvoiceDescription(base, randomPeer(), { amountMsats: 21000n, periodSeconds: 86_400 }), + ).to.include('21000 msats') + + const now = new Date() + expect(isPeeringAccessActive('active', new Date(now.getTime() + 60_000), now)).to.equal(true) + expect(isPeeringAccessActive('active', new Date(now.getTime() - 60_000), now)).to.equal(false) + expect(isPeeringAccessActive('lapsed', new Date(now.getTime() + 60_000), now)).to.equal(false) + expect(isPeeringAccessActive('renewal_pending', new Date(now.getTime() + 60_000), now)).to.equal(true) +}) + +Then('only all-invoice peering requests are exempt', function () { + const peer = randomPeer() + const invoiceFilter = { kinds: [402], '#p': [peer], '#t': ['peering'] } + + expect(isPeeringInvoiceRequest([invoiceFilter], peer)).to.equal(true) + expect(isPeeringInvoiceRequest([invoiceFilter, { kinds: [1] }], peer)).to.equal(false) + expect(isPeeringInvoiceRequest([], peer)).to.equal(false) + expect(isPeeringInvoiceRequest([{ ...invoiceFilter, '#p': [randomPeer()] }], peer)).to.equal(false) + expect(isPeeringInvoiceRequest([{ kinds: [402], '#t': ['peering'] }], peer)).to.equal(false) + expect(isPeeringInvoiceRequest([{ kinds: [402, 1], '#p': [peer], '#t': ['peering'] }], peer)).to.equal(false) +}) + +Then('the peering notification switches are accepted', function () { + const accepted = adminNotificationsPatchBodySchema.safeParse({ + events: { + 'peering.invoice.created': false, + 'peering.invoice.paid': false, + 'peering.invoice.failed': false, + 'peering.access.lapsed': false, + }, + }) + expect(accepted.success).to.equal(true) + + const rejected = adminNotificationsPatchBodySchema.safeParse({ + events: { 'peering.unknown': false }, + }) + expect(rejected.success).to.equal(false) +}) + +Then( + 'the peering invoice creation boundary rejects invalid terms', + async function (this: World>) { + const service = this.parameters.peeringService as PaymentsService + const peer = randomPeer() + const before = this.parameters.peeringInvoiceCreates + const invalid: [bigint, number][] = [ + [0n, 60], + [1500n, 60], + [-1000n, 60], + [10n ** 19n, 60], + [21000n, 0], + ] + + for (const [amount, period] of invalid) { + expect(await rejects(service.createPeeringInvoice(peer, amount, 'invalid', period))).to.equal(true) + } + + expect(this.parameters.peeringInvoiceCreates).to.equal(before) + }, +) + +When( + 'the peering invoice notification is published', + async function (this: World>) { + const peer = randomPeer() + const invoice = await createInvoice(this, peer) + const service = this.parameters.peeringService as PaymentsService + + await service.ensurePeeringInvoiceNotification(invoice) + await service.ensurePeeringInvoiceNotification(invoice) + + this.parameters.peeringNotificationPeer = peer + this.parameters.peeringNotificationInvoice = invoice + }, +) + +Then( + 'the invoice notification is published once with peering tags', + function (this: World>) { + expect(this.parameters.peeringEventCreates).to.equal(1) + + const event = this.parameters.peeringEvents[0] as any + const tags = event.tags as string[][] + + expect(event.kind).to.equal(402) + expect(tags).to.deep.include(['p', this.parameters.peeringNotificationPeer]) + expect(tags).to.deep.include(['t', 'peering']) + expect(tags).to.deep.include(['i', this.parameters.peeringNotificationInvoice.id]) + expect(tags).to.deep.include(['bolt11', this.parameters.peeringNotificationInvoice.bolt11]) + expect(tags.some(([name]) => name === 'expiration')).to.equal(true) + }, +) From e1d08e1699b7bcc42d41ad8d68bb2858e0dfc88b Mon Sep 17 00:00:00 2001 From: ABHAY PANDEY Date: Mon, 9 Nov 2026 09:43:59 +0530 Subject: [PATCH 6/9] feat(peering): deliver paid peering invoices and gate feeds - issue, reuse and reissue peering invoices on the peer invoice REQ - publish them as signed kind 402 events with peering and invoice-id tags - recheck a pending invoice before denying access - refuse peer REQ/COUNT/NEG-OPEN without an active paid period - validate and document paid peering terms --- .changeset/paid-peering-delivery.md | 11 + resources/default-settings.yaml | 6 +- src/@types/services.ts | 8 + src/factories/message-handler-factory.ts | 13 + .../peering-billing-service-factory.ts | 20 ++ src/handlers/count-message-handler.ts | 25 ++ src/handlers/neg-open-message-handler.ts | 24 ++ src/handlers/subscribe-message-handler.ts | 90 +++++- src/services/peering-billing-service.ts | 295 ++++++++++++++++++ src/utils/settings-config.ts | 28 ++ .../handlers/count-message-handler.spec.ts | 26 ++ .../handlers/neg-open-message-handler.spec.ts | 27 ++ .../subscribe-message-handler.spec.ts | 60 ++++ .../repositories/invoice-repository.spec.ts | 63 +++- test/unit/utils/settings-config.spec.ts | 62 +++- 15 files changed, 739 insertions(+), 19 deletions(-) create mode 100644 .changeset/paid-peering-delivery.md create mode 100644 src/factories/peering-billing-service-factory.ts create mode 100644 src/services/peering-billing-service.ts diff --git a/.changeset/paid-peering-delivery.md b/.changeset/paid-peering-delivery.md new file mode 100644 index 000000000..ba2e7f653 --- /dev/null +++ b/.changeset/paid-peering-delivery.md @@ -0,0 +1,11 @@ +--- +"nostream": minor +--- + +feat(peering): deliver paid peering invoices and gate peer feeds + +Configured peers can request their BOLT11 invoice as a signed `kind 402` event +and pay it through Relay B's existing processor. Feed `REQ`, `COUNT` and NIP-77 +requests are refused with a payment-required reason until a paid period is +active. Expired invoices are reissued after a cooldown, and a missed webhook is +rechecked before access is denied. diff --git a/resources/default-settings.yaml b/resources/default-settings.yaml index 1d7ed2c34..eac3aa740 100755 --- a/resources/default-settings.yaml +++ b/resources/default-settings.yaml @@ -193,8 +193,10 @@ peering: # windowSeconds: 3600 scopes: [] # Terms for peering, reusing the NIP-11 fee-object shape. `period` is in - # seconds. An empty list means settlement-free peering. - # Example: [{ amount: 1000, unit: msats, period: 2592000 }] + # seconds. An empty list means settlement-free peering. One positive term + # makes this a paid feed: payments must be enabled, and a paid msats amount + # must be a whole number of satoshis. + # Example: [{ amount: 21000, unit: msats, period: 2592000 }] terms: [] # Peering peers trusted by their relay pubkey (hex or npub), never by URL # alone. A connection that proves one of these keys with NIP-42 AUTH is diff --git a/src/@types/services.ts b/src/@types/services.ts index de9366223..82cf6fc5a 100644 --- a/src/@types/services.ts +++ b/src/@types/services.ts @@ -1,6 +1,7 @@ import { DatabaseTransaction, Pubkey } from './base' import { Invoice } from './invoice' import { NotificationOutboxPayload } from './notification-outbox' +import { PeeringAccess } from './peering' export interface IMaintenanceService { clearOldEvents(): Promise @@ -54,6 +55,13 @@ export interface IPaymentsService { getUnconfirmedPeeringInvoices(limit?: number, offset?: number): Promise } +export interface IPeeringBillingService { + /** Access decision for a configured peer's feed. Optionally rechecks a pending invoice first. */ + getAccess(peerPubkey: Pubkey, options?: { now?: Date; recheck?: boolean }): Promise + /** Returns a usable invoice, creating or reissuing one when needed. */ + ensureInvoice(peerPubkey: Pubkey, options?: { now?: Date; renewal?: boolean }): Promise +} + export interface ISubscriptionService { /** One pass of renewal invoices, period ends and grace expiries. Returns how many subscriptions changed. */ processLifecycle(now?: Date): Promise diff --git a/src/factories/message-handler-factory.ts b/src/factories/message-handler-factory.ts index 3fe597969..26c8081f9 100644 --- a/src/factories/message-handler-factory.ts +++ b/src/factories/message-handler-factory.ts @@ -8,6 +8,7 @@ import { IReportRepository, IUserRepository, } from '../@types/repositories' +import { IPeeringBillingService } from '../@types/services' import { RedisAdapter } from '../adapters/redis-adapter' import { getCacheClient } from '../cache/client' import { getMasterDbClient } from '../database/client' @@ -22,6 +23,7 @@ import { UnsubscribeMessageHandler } from '../handlers/unsubscribe-message-handl import { UserSubscriptionRepository } from '../repositories/user-subscription-repository' import { PeeringRequestStatsStore } from '../utils/peering-request-stats' import { eventStrategyFactory } from './event-strategy-factory' +import { createPeeringBillingService } from './peering-billing-service-factory' import { rateLimiterFactory } from './rate-limiter-factory' import { createSettings } from './settings-factory' import { wotGraphServiceFactory } from './wot-graph-service-factory' @@ -50,6 +52,14 @@ export const getPeerRequestStatsStore = (): PeeringRequestStatsStore => { return peerRequestStatsStore } +let peeringBillingService: IPeeringBillingService | undefined = undefined +export const getPeeringBillingService = (): IPeeringBillingService => { + if (!peeringBillingService) { + peeringBillingService = createPeeringBillingService() + } + return peeringBillingService +} + export const messageHandlerFactory = ( eventRepository: IEventRepository, @@ -91,6 +101,7 @@ export const messageHandlerFactory = inviteCodeRepository, rateLimiterFactory, getPeerRequestStatsStore(), + getPeeringBillingService(), ) case MessageType.CLOSE: return new UnsubscribeMessageHandler(adapter) @@ -101,6 +112,7 @@ export const messageHandlerFactory = createSettings, rateLimiterFactory, getPeerRequestStatsStore(), + getPeeringBillingService(), ) case MessageType.AUTH: return new AuthMessageHandler(adapter, createSettings) @@ -111,6 +123,7 @@ export const messageHandlerFactory = createSettings, rateLimiterFactory, getPeerRequestStatsStore(), + getPeeringBillingService(), ) case MessageType.NEG_MSG: return new NegMsgMessageHandler(adapter, createSettings) diff --git a/src/factories/peering-billing-service-factory.ts b/src/factories/peering-billing-service-factory.ts new file mode 100644 index 000000000..3955875ef --- /dev/null +++ b/src/factories/peering-billing-service-factory.ts @@ -0,0 +1,20 @@ +import { getMasterDbClient } from '../database/client' +import { InvoiceRepository } from '../repositories/invoice-repository' +import { NotificationOutboxRepository } from '../repositories/notification-outbox-repository' +import { PeeringPaymentRepository } from '../repositories/peering-payment-repository' +import { PeeringBillingService } from '../services/peering-billing-service' +import { createPaymentsService } from './payments-service-factory' +import { createSettings } from './settings-factory' + +export const createPeeringBillingService = (): PeeringBillingService => { + const dbClient = getMasterDbClient() + + return new PeeringBillingService( + dbClient, + new PeeringPaymentRepository(dbClient), + new InvoiceRepository(dbClient), + createPaymentsService(), + new NotificationOutboxRepository(dbClient), + createSettings, + ) +} diff --git a/src/handlers/count-message-handler.ts b/src/handlers/count-message-handler.ts index 1f5140b4f..7e6aba8d1 100644 --- a/src/handlers/count-message-handler.ts +++ b/src/handlers/count-message-handler.ts @@ -4,7 +4,9 @@ import { IWebSocketAdapter } from '../@types/adapters' import { Factory } from '../@types/base' import { IMessageHandler } from '../@types/message-handlers' import { CountMessage } from '../@types/messages' +import { PeeringAccess } from '../@types/peering' import { IEventRepository } from '../@types/repositories' +import { IPeeringBillingService } from '../@types/services' import { Settings } from '../@types/settings' import { SubscriptionFilter, SubscriptionId } from '../@types/subscription' import { IRateLimiter } from '../@types/utils' @@ -26,6 +28,7 @@ export class CountMessageHandler implements IMessageHandler { private readonly settings: () => Settings, private readonly rateLimiter?: Factory, private readonly requestStats?: PeeringRequestStatsStore, + private readonly peeringBillingService?: IPeeringBillingService, ) {} public async handleMessage(message: CountMessage): Promise { @@ -53,6 +56,28 @@ export class CountMessageHandler implements IMessageHandler { void this.requestStats?.recordRequest(peerPubkey, 'COUNT') } + if (peerPubkey && this.peeringBillingService) { + let access: PeeringAccess + try { + access = await this.peeringBillingService.getAccess(peerPubkey, { recheck: true }) + } catch (error) { + debug('unable to check peering access for %s: %o', peerPubkey, error) + this.webSocket.emit( + WebSocketAdapterEvent.Message, + createClosedMessage(queryId, 'error: unable to check peering access'), + ) + return + } + + if (!access.allowed) { + const reason = access.reason?.startsWith('payment-required:') + ? access.reason + : `payment-required: ${access.reason ?? 'peering payment required; request the kind 402 peering invoice'}` + this.webSocket.emit(WebSocketAdapterEvent.Message, createClosedMessage(queryId, reason)) + return + } + } + // Some clients send the same filter more than once. Bound the raw list // before deduplicating it, since uniqWith() is O(n^2) and the schema no // longer caps filter counts so peer limits can be larger. diff --git a/src/handlers/neg-open-message-handler.ts b/src/handlers/neg-open-message-handler.ts index d8b9c1de3..c6f51db4a 100644 --- a/src/handlers/neg-open-message-handler.ts +++ b/src/handlers/neg-open-message-handler.ts @@ -3,7 +3,9 @@ import { Factory } from '../@types/base' import { IMessageHandler } from '../@types/message-handlers' import { NegOpenMessage, OutgoingMessage } from '../@types/messages' import { INegentropySessions } from '../@types/negentropy' +import { PeeringAccess } from '../@types/peering' import { IEventRepository, ReconciliationItem } from '../@types/repositories' +import { IPeeringBillingService } from '../@types/services' import { Settings } from '../@types/settings' import { SubscriptionFilter, SubscriptionId } from '../@types/subscription' import { IRateLimiter } from '../@types/utils' @@ -31,6 +33,7 @@ export class NegOpenMessageHandler implements IMessageHandler { private readonly settings: () => Settings, private readonly rateLimiter?: Factory, private readonly requestStats?: PeeringRequestStatsStore, + private readonly peeringBillingService?: IPeeringBillingService, ) {} public async handleMessage(message: NegOpenMessage): Promise { @@ -85,6 +88,27 @@ export class NegOpenMessageHandler implements IMessageHandler { return } + if (peerPubkey && this.peeringBillingService) { + let access: PeeringAccess + try { + access = await this.peeringBillingService.getAccess(peerPubkey, { recheck: true }) + } catch (error) { + sessions.endRead(token) + logger.error('unable to check peering access for %s: %o', peerPubkey, error) + this.reject(sessions, subscriptionId, 'error: unable to check peering access') + return + } + + if (!access.allowed) { + sessions.endRead(token) + const reason = access.reason?.startsWith('payment-required:') + ? access.reason + : `payment-required: ${access.reason ?? 'peering payment required; request the kind 402 peering invoice'}` + this.reject(sessions, subscriptionId, reason) + return + } + } + if (peerPubkey) { void this.requestStats?.recordRequest(peerPubkey, 'NEG-OPEN') } diff --git a/src/handlers/subscribe-message-handler.ts b/src/handlers/subscribe-message-handler.ts index cd81daeb4..bb605d82a 100644 --- a/src/handlers/subscribe-message-handler.ts +++ b/src/handlers/subscribe-message-handler.ts @@ -5,8 +5,10 @@ import { IWebSocketAdapter } from '../@types/adapters' import { Factory, Pubkey } from '../@types/base' import { Event } from '../@types/event' import { IAbortable, IMessageHandler } from '../@types/message-handlers' +import { PeeringAccess } from '../@types/peering' import { SubscribeMessage } from '../@types/messages' import { IEventRepository, IInviteCodeRepository } from '../@types/repositories' +import { IPeeringBillingService } from '../@types/services' import { Settings } from '../@types/settings' import { SubscriptionFilter, SubscriptionId } from '../@types/subscription' import { IRateLimiter } from '../@types/utils' @@ -25,6 +27,7 @@ import { isNip43InviteRequestFilter, isRelaySelfConsistent } from '../utils/nip4 import { buildInviteCodeEvent, issueInviteCode } from '../utils/nip43-invites' import { subscriptionLimitsFor } from '../utils/peer-limits' import { getPeerRequestRetryAfterMs } from '../utils/peer-requests' +import { isPeeringInvoiceRequest, PeeringInvoiceCooldownError } from '../utils/peering-billing' import { PeeringRequestStatsStore } from '../utils/peering-request-stats' import { streamEachAsync, streamEnd, streamFilter, streamMap } from '../utils/stream' @@ -43,6 +46,7 @@ export class SubscribeMessageHandler implements IMessageHandler, IAbortable { private readonly inviteCodeRepository: IInviteCodeRepository, private readonly rateLimiter: Factory, private readonly requestStats?: PeeringRequestStatsStore, + private readonly peeringBillingService?: IPeeringBillingService, ) { this.abortController = new AbortController() } @@ -113,6 +117,13 @@ export class SubscribeMessageHandler implements IMessageHandler, IAbortable { return } + if (peerPubkey && this.peeringBillingService) { + const rejected = await this.enforcePeeringAccess(subscriptionId, filters, peerPubkey) + if (rejected) { + return + } + } + this.webSocket.emit(WebSocketAdapterEvent.Subscribe, subscriptionId, filters) await this.maybeIssueInviteCode(subscriptionId, filters) @@ -120,6 +131,68 @@ export class SubscribeMessageHandler implements IMessageHandler, IAbortable { await this.fetchAndSend(subscriptionId, filters) } + /** + * Peers may always ask for their invoice; everything else is a paid feed and + * needs a running period. Returns true when the subscription was closed. + */ + private async enforcePeeringAccess( + subscriptionId: SubscriptionId, + filters: SubscriptionFilter[], + peerPubkey: Pubkey, + ): Promise { + const isInvoiceRequest = isPeeringInvoiceRequest(filters, peerPubkey) + + if (isInvoiceRequest) { + try { + await this.peeringBillingService!.ensureInvoice(peerPubkey) + return false + } catch (error) { + if (error instanceof PeeringInvoiceCooldownError) { + this.webSocket.emit( + WebSocketAdapterEvent.Message, + createClosedMessage( + subscriptionId, + 'rate-limited: peering invoice reissue cooling down', + String(error.retryAfterSeconds * 1000), + ), + ) + return true + } + + logger('unable to issue peering invoice for %s: %o', peerPubkey, error) + this.webSocket.emit( + WebSocketAdapterEvent.Message, + createClosedMessage(subscriptionId, 'error: unable to issue peering invoice'), + ) + return true + } + } + + let access: PeeringAccess + try { + access = await this.peeringBillingService!.getAccess(peerPubkey, { recheck: true }) + } catch (error) { + logger.error('unable to check peering access for %s: %o', peerPubkey, error) + this.webSocket.emit( + WebSocketAdapterEvent.Message, + createClosedMessage(subscriptionId, 'error: unable to check peering access'), + ) + return true + } + + if (access.allowed) { + return false + } + + const reason = access.reason?.startsWith('payment-required:') + ? access.reason + : `payment-required: ${access.reason ?? 'peering payment required; request the kind 402 peering invoice'}` + + logger('subscription %s with %o rejected for peer %s: %s', subscriptionId, filters, peerPubkey, reason) + this.webSocket.emit(WebSocketAdapterEvent.Message, createClosedMessage(subscriptionId, reason)) + return true + } + // NIP-43: kind 28935 is never published by a client and never stored. A REQ for // it asks the relay to mint an invite code, which we answer with a relay-signed // ephemeral event on this socket only. fetchAndSend still runs afterwards: its @@ -306,14 +379,25 @@ export class SubscribeMessageHandler implements IMessageHandler, IAbortable { private canSubscribe(subscriptionId: SubscriptionId, filters: SubscriptionFilter[]): string | undefined { const subscriptions = this.webSocket.getSubscriptions() const existingSubscription = subscriptions.get(subscriptionId) - const subscriptionLimits = subscriptionLimitsFor(this.settings(), this.webSocket.getPeerPubkey() !== undefined) + const peerPubkey = this.webSocket.getPeerPubkey() + const subscriptionLimits = subscriptionLimitsFor(this.settings(), peerPubkey !== undefined) + const isInvoiceRequest = peerPubkey ? isPeeringInvoiceRequest(filters, peerPubkey) : false if (existingSubscription?.length && equals(filters, existingSubscription)) { - return `Duplicate subscription ${subscriptionId}: Ignoring` + // Asking for the peering invoice again is how a peer recovers a missed event; + // it must not be rejected as a duplicate subscription. + if (!isInvoiceRequest) { + return `Duplicate subscription ${subscriptionId}: Ignoring` + } } const maxSubscriptions = subscriptionLimits?.maxSubscriptions ?? 0 - if (maxSubscriptions > 0 && !existingSubscription?.length && subscriptions.size + 1 > maxSubscriptions) { + if ( + maxSubscriptions > 0 && + !isInvoiceRequest && + !existingSubscription?.length && + subscriptions.size + 1 > maxSubscriptions + ) { return `Too many subscriptions: Number of subscriptions must be less than or equal to ${maxSubscriptions}` } diff --git a/src/services/peering-billing-service.ts b/src/services/peering-billing-service.ts new file mode 100644 index 000000000..04a764ae1 --- /dev/null +++ b/src/services/peering-billing-service.ts @@ -0,0 +1,295 @@ +import { DatabaseClient, Pubkey } from '../@types/base' +import { Invoice, InvoicePurpose, InvoiceStatus } from '../@types/invoice' +import { OperatorNotificationEventType } from '../@types/operator-notifications' +import { PeeringAccess, PeeringPayment, PeeringPaymentStatus, PeeringTermSnapshot } from '../@types/peering' +import { IInvoiceRepository, INotificationOutboxRepository, IPeeringPaymentRepository } from '../@types/repositories' +import { IPaymentsService, IPeeringBillingService } from '../@types/services' +import { Settings } from '../@types/settings' +import { Transaction } from '../database/transaction' +import { createLogger } from '../factories/logger-factory' +import { + InvalidPeeringTermError, + PEERING_RECHECK_COOLDOWN_SECONDS, + PEERING_REISSUE_COOLDOWN_SECONDS, + PeeringInvoiceCooldownError, + peeringInvoiceDescription, + resolvePaidPeeringTerm, +} from '../utils/peering-billing' + +const logger = createLogger('peering-billing-service') + +const isExpiredAt = (invoice: Invoice, now: Date): boolean => + invoice.expiresAt instanceof Date && invoice.expiresAt.getTime() <= now.getTime() + +export class PeeringBillingService implements IPeeringBillingService { + public constructor( + private readonly dbClient: DatabaseClient, + private readonly peeringPaymentRepository: IPeeringPaymentRepository, + private readonly invoiceRepository: IInvoiceRepository, + private readonly paymentsService: IPaymentsService, + private readonly notificationOutboxRepository: INotificationOutboxRepository, + private readonly settings: () => Settings, + ) {} + + public async getAccess(peerPubkey: Pubkey, options: { now?: Date; recheck?: boolean } = {}): Promise { + const now = options.now ?? new Date() + let term: PeeringTermSnapshot | undefined + + try { + term = resolvePaidPeeringTerm(this.settings()) + } catch (error) { + logger.error('invalid paid peering term for %s: %o', peerPubkey, error) + return { + allowed: false, + status: 'unconfigured', + reason: error instanceof InvalidPeeringTermError ? error.message : 'invalid peering term', + } + } + + // An empty/zero term list means settlement-free peering. + if (!term) { + return { allowed: true, status: 'free' } + } + + let payment = await this.peeringPaymentRepository.findByPeer(peerPubkey) + let access = this.accessFrom(payment, now) + + if (!access.allowed && options.recheck) { + const settled = await this.recheckPendingInvoice(peerPubkey, now) + if (settled) { + payment = await this.peeringPaymentRepository.findByPeer(peerPubkey) + access = this.accessFrom(payment, now) + } + } + + return access + } + + private accessFrom(payment: PeeringPayment | undefined, now: Date): PeeringAccess { + if (!payment) { + return { + allowed: false, + status: PeeringPaymentStatus.UNPAID, + reason: 'no peering invoice has been issued yet', + } + } + + const running = payment.periodEnd instanceof Date && payment.periodEnd.getTime() > now.getTime() + const payingStatus = + payment.status === PeeringPaymentStatus.ACTIVE || payment.status === PeeringPaymentStatus.RENEWAL_PENDING + + if (running && payingStatus) { + return { + allowed: true, + status: payment.status, + periodEnd: payment.periodEnd, + } + } + + return { + allowed: false, + status: payment.status, + periodEnd: payment.periodEnd, + reason: + payment.status === PeeringPaymentStatus.LAPSED + ? 'peering access lapsed; request a new invoice' + : 'peering payment required; request the kind 402 peering invoice', + } + } + + public async ensureInvoice( + peerPubkey: Pubkey, + options: { now?: Date; renewal?: boolean } = {}, + ): Promise { + let term: PeeringTermSnapshot | undefined + try { + term = resolvePaidPeeringTerm(this.settings()) + } catch (error) { + logger.error('unable to resolve paid peering term for %s: %o', peerPubkey, error) + throw error + } + + if (!term) { + return undefined + } + + if (this.settings().payments?.enabled !== true) { + throw new Error('paid peering requires payments to be enabled') + } + + const now = options.now ?? new Date() + const transaction = new Transaction(this.dbClient) + let invoice: Invoice | undefined + + await transaction.begin() + try { + await this.peeringPaymentRepository.ensureRow(peerPubkey, transaction.transaction) + const payment = await this.peeringPaymentRepository.lockByPeer(peerPubkey, transaction.transaction) + if (!payment) { + throw new Error(`Unable to lock peering payment row for ${peerPubkey}`) + } + + const running = payment.periodEnd instanceof Date && payment.periodEnd.getTime() > now.getTime() + + // Any open invoice is returned first, even one issued before the period ended: + // it is still payable and creating a second one would charge the peer twice. + const openInvoice = await this.findOpenInvoice(payment, now, transaction.transaction) + if (openInvoice) { + invoice = openInvoice + } else { + // findOpenInvoice clears pointers to invoices it retired. Re-read the locked row + // so the new invoice is written against those cleared pointers, not stale ones. + const current = + (await this.peeringPaymentRepository.lockByPeer(peerPubkey, transaction.transaction)) ?? payment + const renewal = options.renewal === true || running + const created = await this.paymentsService.createPeeringInvoice( + peerPubkey, + term.amountMsats, + peeringInvoiceDescription(this.settings(), peerPubkey, term), + term.periodSeconds, + transaction.transaction, + ) + + const next: PeeringPayment = { + ...current, + status: renewal ? PeeringPaymentStatus.RENEWAL_PENDING : PeeringPaymentStatus.INVOICED, + currentInvoiceId: renewal ? (current.currentInvoiceId ?? null) : created.id, + renewalInvoiceId: renewal ? created.id : null, + termAmountMsats: term.amountMsats, + termPeriodSeconds: term.periodSeconds, + lastError: null, + } + + await this.peeringPaymentRepository.save(next, transaction.transaction) + invoice = created + } + + await transaction.commit() + } catch (error) { + await transaction.rollback() + logger.error('unable to ensure peering invoice for %s: %o', peerPubkey, error) + throw error + } + + // Publishing after the commit avoids advertising an invoice the database did + // not accept. A failure here leaves the row intact; the next request republishes it. + if (invoice) { + try { + await this.paymentsService.ensurePeeringInvoiceNotification(invoice) + } catch (error) { + logger.error('unable to publish peering invoice %s: %o', invoice.id, error) + } + } + + return invoice + } + + private async findOpenInvoice( + payment: PeeringPayment, + now: Date, + client: DatabaseClient, + ): Promise { + const candidates = [payment.renewalInvoiceId, payment.currentInvoiceId].filter( + (id): id is string => typeof id === 'string' && id.length > 0, + ) + + let expired: Invoice | undefined + for (const id of candidates) { + const invoice = await this.invoiceRepository.findById(id, client) + if (!invoice) { + continue + } + + if (invoice.status === InvoiceStatus.PENDING && !isExpiredAt(invoice, now)) { + return invoice + } + + if (invoice.status === InvoiceStatus.PENDING && isExpiredAt(invoice, now)) { + expired = invoice + await this.invoiceRepository.updateStatus({ id: invoice.id, status: InvoiceStatus.EXPIRED }, client) + await this.enqueueFailed(invoice, 'expired', client) + + // Clear the pointer immediately, even if the other candidate is still usable. + payment.currentInvoiceId = payment.currentInvoiceId === invoice.id ? null : payment.currentInvoiceId + payment.renewalInvoiceId = payment.renewalInvoiceId === invoice.id ? null : payment.renewalInvoiceId + await this.peeringPaymentRepository.save(payment, client) + } + } + + if (expired) { + // The peer just let the invoice expire. Do not let it mint another one in a loop. + const expiredAt = expired.expiresAt ?? payment.updatedAt + const elapsedSeconds = Math.floor((now.getTime() - expiredAt.getTime()) / 1000) + if (elapsedSeconds >= 0 && elapsedSeconds < PEERING_REISSUE_COOLDOWN_SECONDS) { + throw new PeeringInvoiceCooldownError(PEERING_REISSUE_COOLDOWN_SECONDS - elapsedSeconds) + } + } + + return undefined + } + + public async recheckPendingInvoice(peerPubkey: Pubkey, now = new Date(), force = false): Promise { + const payment = await this.peeringPaymentRepository.findByPeer(peerPubkey) + const invoiceId = payment?.renewalInvoiceId ?? payment?.currentInvoiceId + if (!payment || !invoiceId) { + return false + } + + if ( + !force && + payment.lastCheckedAt instanceof Date && + now.getTime() - payment.lastCheckedAt.getTime() < PEERING_RECHECK_COOLDOWN_SECONDS * 1000 + ) { + return false + } + + const invoice = await this.invoiceRepository.findById(invoiceId) + if (!invoice || invoice.status !== InvoiceStatus.PENDING || isExpiredAt(invoice, now)) { + return false + } + + try { + const updated = await this.paymentsService.getInvoiceFromPaymentsProcessor(invoice) + if (updated.status === InvoiceStatus.COMPLETED && updated.confirmedAt) { + await this.paymentsService.confirmInvoice({ + ...invoice, + ...updated, + amountPaid: typeof updated.amountPaid === 'bigint' ? updated.amountPaid : invoice.amountRequested, + }) + return true + } + + await this.peeringPaymentRepository.save({ + ...payment, + lastCheckedAt: now, + lastError: updated.status ? `processor status: ${updated.status}` : null, + }) + return false + } catch (error) { + logger.error('unable to recheck peering invoice %s: %o', invoiceId, error) + await this.peeringPaymentRepository.save({ + ...payment, + lastCheckedAt: now, + lastError: error instanceof Error ? error.message : 'processor check failed', + }) + return false + } + } + + private async enqueueFailed(invoice: Invoice, reason: string, client?: DatabaseClient): Promise { + try { + await this.notificationOutboxRepository.enqueue( + OperatorNotificationEventType.PEERING_INVOICE_FAILED, + { + invoiceId: invoice.id, + pubkey: invoice.pubkey, + reason, + }, + client, + ) + } catch (error) { + logger.error('unable to enqueue peering invoice failure for %s: %o', invoice.id, error) + } + } + +} diff --git a/src/utils/settings-config.ts b/src/utils/settings-config.ts index 7f326f4e4..fdad3f414 100644 --- a/src/utils/settings-config.ts +++ b/src/utils/settings-config.ts @@ -959,6 +959,14 @@ const validatePeeringTerms = (terms: unknown, issues: ValidationIssue[]): void = if (typeof term.unit !== 'string' || !term.unit.trim()) { issues.push({ path: `${prefix}.unit`, message: 'unit must be a non-empty string' }) + } else if (typeof term.amount === 'number' && term.amount > 0) { + const unit = term.unit.trim().toLowerCase() + if (!['msats', 'sats', 'btc'].includes(unit)) { + issues.push({ path: `${prefix}.unit`, message: 'paid terms must use msats, sats or btc' }) + } else if (unit === 'msats' && term.amount % 1000 !== 0) { + // Processors such as LNbits and OpenNode truncate msats to whole sats. + issues.push({ path: `${prefix}.amount`, message: 'paid msat terms must be a whole number of satoshis' }) + } } if (!(typeof term.period === 'number' && Number.isSafeInteger(term.period) && term.period > 0)) { @@ -1078,6 +1086,26 @@ const validatePeering = (settings: Settings, issues: ValidationIssue[]): void => validatePeeringTerms(peering.terms, issues) validatePeeringPeers(peering.peers, issues) + const paidTerms = Array.isArray(peering.terms) + ? peering.terms.filter( + (term) => isPlainObject(term) && typeof term.amount === 'number' && term.amount > 0, + ) + : [] + + if (paidTerms.length > 1) { + issues.push({ + path: 'peering.terms', + message: 'only one paid peering term is supported', + }) + } + + if (paidTerms.length > 0 && settings.payments?.enabled !== true) { + issues.push({ + path: 'peering.terms', + message: 'payments must be enabled to charge for peering', + }) + } + if (!enabled) { return } diff --git a/test/unit/handlers/count-message-handler.spec.ts b/test/unit/handlers/count-message-handler.spec.ts index 172eb011c..91afde4ce 100644 --- a/test/unit/handlers/count-message-handler.spec.ts +++ b/test/unit/handlers/count-message-handler.spec.ts @@ -68,6 +68,32 @@ describe('CountMessageHandler', () => { webSocket.on(WebSocketAdapterEvent.Message, webSocketOnMessageStub) }) + it('closes a peer count when paid access is not active', async () => { + const peer = 'a'.repeat(64) + ;(webSocket.getPeerPubkey as Sinon.SinonStub).returns(peer) + const billing = { + getAccess: sandbox.stub().resolves({ allowed: false, reason: 'peering payment required' }), + } + handler = new CountMessageHandler( + webSocket, + eventRepository, + () => ({ nip45: { enabled: true }, limits: { peer: { subscription: { maxFilters: 10 } } } }) as Settings, + rateLimiterFactory, + requestStats as any, + billing as any, + ) + + await handler.handleMessage([MessageType.COUNT, 'q1', {}] as any) + + expect(billing.getAccess).to.have.been.calledWith(peer, { recheck: true }) + expect(webSocketOnMessageStub).to.have.been.calledWith([ + MessageType.CLOSED, + 'q1', + 'payment-required: peering payment required', + ]) + expect(eventRepository.countByFilters).not.to.have.been.called + }) + it('returns COUNT with the result when counting works', async () => { const message = [MessageType.COUNT, 'q1', {}] as any diff --git a/test/unit/handlers/neg-open-message-handler.spec.ts b/test/unit/handlers/neg-open-message-handler.spec.ts index 84d512007..0386bf026 100644 --- a/test/unit/handlers/neg-open-message-handler.spec.ts +++ b/test/unit/handlers/neg-open-message-handler.spec.ts @@ -109,6 +109,33 @@ describe('NegOpenMessageHandler', () => { expect(findReconciliationItems).to.have.been.calledOnceWithExactly({ kinds: [1], limit: 3 }, 101) }) + it('rejects a reconciliation from a peer without paid access', async () => { + const peer = 'a'.repeat(64) + ;(webSocket as any).getPeerPubkey = () => peer + const billing = { + getAccess: sandbox.stub().resolves({ allowed: false, reason: 'peering payment required' }), + } + handler = new NegOpenMessageHandler( + webSocket, + eventRepository, + () => settings, + rateLimiterFactory, + requestStats as any, + billing as any, + ) + + await open() + + expect(billing.getAccess).to.have.been.calledWith(peer, { recheck: true }) + expect(findReconciliationItems).not.to.have.been.called + expect(sessions.getSessionIds()).to.deep.equal([]) + expect(emitted.firstCall.args[0]).to.deep.equal([ + MessageType.NEG_ERR, + 'sync1', + 'payment-required: peering payment required', + ]) + }) + describe('when disabled', () => { it('replies NEG-ERR blocked and does not read events', async () => { settings = { nip77: { enabled: false } } as Settings diff --git a/test/unit/handlers/subscribe-message-handler.spec.ts b/test/unit/handlers/subscribe-message-handler.spec.ts index 389aaa29f..3c2fd5df3 100644 --- a/test/unit/handlers/subscribe-message-handler.spec.ts +++ b/test/unit/handlers/subscribe-message-handler.spec.ts @@ -132,6 +132,66 @@ describe('SubscribeMessageHandler', () => { expect(webSocketOnMessageStub).to.have.been.calledOnceWithExactly(['NOTICE', 'Subscription rejected: reason']) }) + it('closes a peer feed request when paid access is not active', async () => { + const peer = 'a'.repeat(64) + const billing = { + getAccess: sandbox.stub().resolves({ allowed: false, reason: 'peering payment required' }), + ensureInvoice: sandbox.stub().resolves(), + } + ;(webSocket.getPeerPubkey as Sinon.SinonStub).returns(peer) + settingsFactory.returns({ + limits: { peer: { subscription: { maxFilters: 10, maxLimit: 0 } } }, + }) + handler = new SubscribeMessageHandler( + webSocket, + eventRepository, + settingsFactory, + inviteCodeRepository, + rateLimiterFactory, + requestStats as any, + billing as any, + ) + message = [MessageType.REQ, subscriptionId, { kinds: [1] }] as any + + await handler.handleMessage(message) + + expect(billing.getAccess).to.have.been.calledWith(peer, { recheck: true }) + expect(webSocketOnMessageStub.firstCall.args[0]).to.deep.equal([ + 'CLOSED', + subscriptionId, + 'payment-required: peering payment required', + ]) + expect(fetchAndSendStub).not.to.have.been.called + }) + + it('lets a peer request its invoice without paid access', async () => { + const peer = 'a'.repeat(64) + const billing = { + getAccess: sandbox.stub(), + ensureInvoice: sandbox.stub().resolves(), + } + ;(webSocket.getPeerPubkey as Sinon.SinonStub).returns(peer) + settingsFactory.returns({ + limits: { peer: { subscription: { maxFilters: 10, maxLimit: 0 } } }, + }) + handler = new SubscribeMessageHandler( + webSocket, + eventRepository, + settingsFactory, + inviteCodeRepository, + rateLimiterFactory, + requestStats as any, + billing as any, + ) + message = [MessageType.REQ, subscriptionId, { kinds: [402], '#p': [peer], '#t': ['peering'], limit: 1 }] as any + + await handler.handleMessage(message) + + expect(billing.ensureInvoice).to.have.been.calledWith(peer) + expect(billing.getAccess).not.to.have.been.called + expect(fetchAndSendStub).to.have.been.calledOnce + }) + it('bounds raw filters before deduplicating them', async () => { settingsFactory.returns({ limits: { client: { subscription: { maxFilters: 10 } } } }) const duplicate = { kinds: [1] } diff --git a/test/unit/repositories/invoice-repository.spec.ts b/test/unit/repositories/invoice-repository.spec.ts index ac92b1420..79e85cc7f 100644 --- a/test/unit/repositories/invoice-repository.spec.ts +++ b/test/unit/repositories/invoice-repository.spec.ts @@ -1,8 +1,8 @@ import * as chai from 'chai' -import * as sinon from 'sinon' +import chaiAsPromised from 'chai-as-promised' import knex from 'knex' +import * as sinon from 'sinon' import sinonChai from 'sinon-chai' -import chaiAsPromised from 'chai-as-promised' import { DatabaseClient } from '../../../src/@types/base' import { Invoice, InvoicePurpose, InvoiceStatus, InvoiceUnit } from '../../../src/@types/invoice' @@ -71,19 +71,18 @@ describe('InvoiceRepository', () => { await repository.confirmInvoice('invoice-123', 5000n, fixedDate, client) - expect(rawStub).to.have.been.calledOnceWithExactly( - 'select confirm_invoice(?, ?, ?)', - ['invoice-123', '5000', fixedDate.toISOString()], - ) + expect(rawStub).to.have.been.calledOnceWithExactly('select confirm_invoice(?, ?, ?)', [ + 'invoice-123', + '5000', + fixedDate.toISOString(), + ]) }) it('re-throws when raw call rejects', async () => { const dbError = new Error('connection refused') const client = { raw: sandbox.stub().rejects(dbError) } as unknown as DatabaseClient - await expect( - repository.confirmInvoice('invoice-123', 5000n, fixedDate, client), - ).to.be.rejectedWith(dbError) + await expect(repository.confirmInvoice('invoice-123', 5000n, fixedDate, client)).to.be.rejectedWith(dbError) }) }) @@ -114,6 +113,27 @@ describe('InvoiceRepository', () => { expect(result!.amountRequested).to.equal(1000n) }) + it('maps the purpose and peering term snapshot', async () => { + const client = sandbox.stub().returns({ + where: sandbox.stub().returns({ + select: sandbox.stub().resolves([ + { + ...dbInvoiceRow, + purpose: 'peering', + term_amount_msats: '21000', + term_period_seconds: 2592000, + }, + ]), + }), + }) as unknown as DatabaseClient + + const result = await repository.findById('test-invoice-id', client) + + expect(result!.purpose).to.equal('peering') + expect(result!.termAmountMsats).to.equal(21000n) + expect(result!.termPeriodSeconds).to.equal(2592000) + }) + it('maps the subscription plan and period when the invoice has them', async () => { const periodStart = new Date('2026-02-01T00:00:00.000Z') const periodEnd = new Date('2026-03-03T00:00:00.000Z') @@ -154,6 +174,20 @@ describe('InvoiceRepository', () => { }) }) + describe('.updatePeriod', () => { + it('updates the period columns for the invoice', async () => { + const update = sandbox.stub().returns({ where: sandbox.stub().resolves() }) + const client = sandbox.stub().returns({ update }) as unknown as DatabaseClient + const start = new Date('2026-11-01T00:00:00.000Z') + const end = new Date('2026-12-01T00:00:00.000Z') + + await repository.updatePeriod('inv-1', start, end, client) + + expect(client).to.have.been.calledWith('invoices') + expect(update).to.have.been.calledWith(sandbox.match({ period_start: start, period_end: end })) + }) + }) + describe('.findPendingInvoices', () => { function makePendingClient(results: any[]): DatabaseClient { const selectStub = sandbox.stub().resolves(results) @@ -347,6 +381,17 @@ describe('InvoiceRepository', () => { expect(sql).to.include('"period_end"') }) + it('stores the invoice purpose and peering term snapshot', () => { + const sql = repository + .upsert({ ...testInvoice, purpose: 'peering' as any, termAmountMsats: 21000n, termPeriodSeconds: 2592000 }) + .toString() + + expect(sql).to.include('"purpose"') + expect(sql).to.include(`'peering'`) + expect(sql).to.include('"term_amount_msats"') + expect(sql).to.include('"term_period_seconds"') + }) + it('never rewrites the plan or period of an existing invoice', () => { const sql = repository .upsert({ ...testInvoice, planId: 'basic', periodStart: fixedDate, periodEnd: fixedDate }) diff --git a/test/unit/utils/settings-config.spec.ts b/test/unit/utils/settings-config.spec.ts index 8bd0cf5e3..fc6384e40 100644 --- a/test/unit/utils/settings-config.spec.ts +++ b/test/unit/utils/settings-config.spec.ts @@ -113,6 +113,7 @@ describe('settings-config', () => { contact: 'test@example.com', }, network: { maxPayloadSize: 1024 }, + payments: { enabled: true, processor: 'lnbits' }, peering, }) as any @@ -244,6 +245,51 @@ describe('settings-config', () => { expect(issues.some((issue) => issue.path === 'peering.terms[0].currency')).to.equal(true) }) + it('rejects a paid msat term that is not a whole number of satoshis', () => { + const issues = peeringIssues({ + ...settlementFreePeering, + terms: [{ amount: 1500, unit: 'msats', period: 2592000 }], + }) + + expect(issues.some((issue) => issue.path === 'peering.terms[0].amount')).to.equal(true) + }) + + it('rejects a paid term in an unsupported unit', () => { + const issues = peeringIssues({ + ...settlementFreePeering, + terms: [{ amount: 1, unit: 'usd', period: 2592000 }], + }) + + expect(issues.some((issue) => issue.path === 'peering.terms[0].unit')).to.equal(true) + }) + + it('rejects more than one paid term', () => { + const issues = peeringIssues({ + ...settlementFreePeering, + terms: [ + { amount: 21000, unit: 'msats', period: 2592000 }, + { amount: 42000, unit: 'msats', period: 2592000 }, + ], + }) + + expect(issues.some((issue) => issue.path === 'peering.terms')).to.equal(true) + expect(issues.find((issue) => issue.path === 'peering.terms')?.message).to.include('only one paid') + }) + + it('rejects paid terms while payments are disabled', () => { + const issues = validateSettings({ + info: { relay_url: 'wss://relay.example.com', name: 'Test Relay' }, + network: { maxPayloadSize: 1024 }, + peering: { + ...settlementFreePeering, + terms: [{ amount: 21000, unit: 'msats', period: 2592000 }], + }, + } as any) + + expect(issues.some((issue) => issue.path === 'peering.terms')).to.equal(true) + expect(issues.find((issue) => issue.path === 'peering.terms')?.message).to.include('payments must be enabled') + }) + it('rejects a proof-of-work requirement above the NIP-13 maximum', () => { const issues = peeringIssues({ ...settlementFreePeering, minRequestPowDifficulty: 257 }) @@ -376,8 +422,7 @@ describe('settings-config', () => { expect( issues.some( (issue) => - issue.path === 'info.relay_url' && - issue.message === 'relay_url must use ws, wss, http, or https protocol', + issue.path === 'info.relay_url' && issue.message === 'relay_url must use ws, wss, http, or https protocol', ), `expected ${relay_url} to be rejected for protocol`, ).to.equal(true) @@ -476,7 +521,9 @@ describe('settings-config', () => { const settings = baseSettings() settings.limits = { event: { rateLimits: [{ period: 0.5, rate: 10 }] } } - const message = validateSettings(settings).find((issue) => issue.path === 'limits.event.rateLimits[0].period')?.message + const message = validateSettings(settings).find( + (issue) => issue.path === 'limits.event.rateLimits[0].period', + )?.message expect(message).to.contain('milliseconds') }) @@ -858,8 +905,13 @@ describe('settings-config', () => { describe('settings-guided-schema', () => { it('exports guided categories for admin and CLI use', () => { expect(guidedSettingCategories.length).to.be.greaterThan(0) - expect(getGuidedSettingCategory('payments')?.settings.some((entry) => entry.path === 'payments.enabled')).to.equal(true) - expect(getGuidedSettingField('limits', 'limits.rateLimiter.strategy')?.options).to.deep.equal(['ewma', 'sliding_window']) + expect(getGuidedSettingCategory('payments')?.settings.some((entry) => entry.path === 'payments.enabled')).to.equal( + true, + ) + expect(getGuidedSettingField('limits', 'limits.rateLimiter.strategy')?.options).to.deep.equal([ + 'ewma', + 'sliding_window', + ]) }) it('validates guided numeric fields', () => { From 1a27e19a0bb9b91b89431ed427286bc22d5698f5 Mon Sep 17 00:00:00 2001 From: ABHAY PANDEY Date: Mon, 9 Nov 2026 14:13:33 +0530 Subject: [PATCH 7/9] fix(peering): harden invoice delivery and access gating - require every filter in an invoice request to be invoice-only - let only the checked columns be updated after a processor recheck - recheck a locally expired invoice before retiring it - close paid feeds when their access period ends - cap invoice subscriptions and ignore stale billing waits - keep payment terms valid without requiring payments for advertisement --- src/@types/repositories.ts | 2 + src/handlers/neg-open-message-handler.ts | 13 +- src/handlers/subscribe-message-handler.ts | 105 ++++++++++++-- .../peering-payment-repository.ts | 18 +++ src/services/peering-billing-service.ts | 48 +++++-- src/utils/settings-config.ts | 7 - .../handlers/neg-open-message-handler.spec.ts | 29 ++++ .../subscribe-message-handler.spec.ts | 135 ++++++++++++++++++ .../peering-payment-repository.spec.ts | 14 ++ test/unit/utils/settings-config.spec.ts | 15 -- 10 files changed, 341 insertions(+), 45 deletions(-) diff --git a/src/@types/repositories.ts b/src/@types/repositories.ts index 1a03d5086..2009c3360 100644 --- a/src/@types/repositories.ts +++ b/src/@types/repositories.ts @@ -90,6 +90,8 @@ export interface IPeeringPaymentRepository { /** SELECT ... FOR UPDATE; must run inside a transaction. */ lockByPeer(pubkey: Pubkey, client: DatabaseClient): Promise save(payment: PeeringPayment, client?: DatabaseClient): Promise + /** Updates only the processor-check diagnostics, so a concurrent settlement is not overwritten. */ + recordCheck(pubkey: Pubkey, checkedAt: Date, error: string | null, client?: DatabaseClient): Promise list(): Promise /** Active or renewal-pending relationships whose period ends by `before`. */ findDueForRenewal(before: Date, limit: number, offset: number): Promise diff --git a/src/handlers/neg-open-message-handler.ts b/src/handlers/neg-open-message-handler.ts index c6f51db4a..f49a27567 100644 --- a/src/handlers/neg-open-message-handler.ts +++ b/src/handlers/neg-open-message-handler.ts @@ -94,8 +94,17 @@ export class NegOpenMessageHandler implements IMessageHandler { access = await this.peeringBillingService.getAccess(peerPubkey, { recheck: true }) } catch (error) { sessions.endRead(token) - logger.error('unable to check peering access for %s: %o', peerPubkey, error) - this.reject(sessions, subscriptionId, 'error: unable to check peering access') + if (sessions.isCurrent(subscriptionId, token)) { + logger.error('unable to check peering access for %s: %o', peerPubkey, error) + this.reject(sessions, subscriptionId, 'error: unable to check peering access') + } + return + } + + // A replacement NEG-OPEN may have claimed this id while billing was pending. + // The older check must not close the newer session. + if (!sessions.isCurrent(subscriptionId, token)) { + sessions.endRead(token) return } diff --git a/src/handlers/subscribe-message-handler.ts b/src/handlers/subscribe-message-handler.ts index bb605d82a..a624e9127 100644 --- a/src/handlers/subscribe-message-handler.ts +++ b/src/handlers/subscribe-message-handler.ts @@ -33,8 +33,14 @@ import { streamEachAsync, streamEnd, streamFilter, streamMap } from '../utils/st const logger = createLogger('subscribe-message-handler') +/** Keep invoice re-requests from growing the socket's subscription map without bound. */ +const MAX_PEERING_INVOICE_SUBSCRIPTIONS = 1 + export class SubscribeMessageHandler implements IMessageHandler, IAbortable { private readonly abortController: AbortController + private peeringExpiryTimer?: NodeJS.Timeout + private peeringExpiryGeneration = 0 + private subscriptionFilters: SubscriptionFilter[] = [] /** Set while `handleMessage` runs; used to abort in-flight reads when the sub is closed early. */ public activeSubscriptionId: SubscriptionId | undefined @@ -52,6 +58,7 @@ export class SubscribeMessageHandler implements IMessageHandler, IAbortable { } public abort(): void { + this.clearPeeringExpiry() this.abortController.abort() } @@ -117,35 +124,105 @@ export class SubscribeMessageHandler implements IMessageHandler, IAbortable { return } + let peeringAccess: PeeringAccess | undefined if (peerPubkey && this.peeringBillingService) { - const rejected = await this.enforcePeeringAccess(subscriptionId, filters, peerPubkey) - if (rejected) { + const result = await this.enforcePeeringAccess(subscriptionId, filters, peerPubkey) + if (this.abortController.signal.aborted || result.rejected) { return } + peeringAccess = result.access } + // A CLOSE may have arrived while billing was pending. Do not register a + // subscription the peer has already abandoned. + if (this.abortController.signal.aborted) { + return + } + + this.subscriptionFilters = filters this.webSocket.emit(WebSocketAdapterEvent.Subscribe, subscriptionId, filters) + if (peeringAccess?.periodEnd) { + this.schedulePeeringExpiry(subscriptionId, peerPubkey!, peeringAccess.periodEnd) + } + await this.maybeIssueInviteCode(subscriptionId, filters) await this.fetchAndSend(subscriptionId, filters) } + private schedulePeeringExpiry( + subscriptionId: SubscriptionId, + peerPubkey: Pubkey, + periodEnd: Date, + ): void { + this.clearPeeringExpiry() + const generation = this.peeringExpiryGeneration + const remaining = Math.max(0, periodEnd.getTime() - Date.now()) + const delay = Math.min(remaining, 2_147_483_647) + + this.peeringExpiryTimer = setTimeout(() => { + if (generation !== this.peeringExpiryGeneration) { + return + } + + if (remaining > delay) { + this.schedulePeeringExpiry(subscriptionId, peerPubkey, periodEnd) + return + } + + void this.expirePeeringSubscription(subscriptionId, peerPubkey) + }, delay) + } + + private clearPeeringExpiry(): void { + this.peeringExpiryGeneration++ + if (this.peeringExpiryTimer) { + clearTimeout(this.peeringExpiryTimer) + this.peeringExpiryTimer = undefined + } + } + + private async expirePeeringSubscription(subscriptionId: SubscriptionId, peerPubkey: Pubkey): Promise { + // The id may have been reused by a different subscription in the meantime. + const registered = this.webSocket.getSubscriptions().get(subscriptionId) + if (!registered || !equals(registered, this.subscriptionFilters)) { + return + } + + try { + const access = await this.peeringBillingService?.getAccess(peerPubkey) + if (access?.allowed && access.periodEnd && access.periodEnd.getTime() > Date.now()) { + this.schedulePeeringExpiry(subscriptionId, peerPubkey, access.periodEnd) + return + } + } catch (error) { + logger.error('unable to check peering expiry for %s: %o', peerPubkey, error) + } + + this.webSocket.endSubscription(subscriptionId) + this.webSocket.emit( + WebSocketAdapterEvent.Message, + createClosedMessage(subscriptionId, 'payment-required: peering access expired'), + ) + this.clearPeeringExpiry() + } + /** * Peers may always ask for their invoice; everything else is a paid feed and - * needs a running period. Returns true when the subscription was closed. + * needs a running period. Returns the access window when the feed is allowed. */ private async enforcePeeringAccess( subscriptionId: SubscriptionId, filters: SubscriptionFilter[], peerPubkey: Pubkey, - ): Promise { + ): Promise<{ rejected: boolean; access?: PeeringAccess }> { const isInvoiceRequest = isPeeringInvoiceRequest(filters, peerPubkey) if (isInvoiceRequest) { try { await this.peeringBillingService!.ensureInvoice(peerPubkey) - return false + return { rejected: false } } catch (error) { if (error instanceof PeeringInvoiceCooldownError) { this.webSocket.emit( @@ -156,7 +233,7 @@ export class SubscribeMessageHandler implements IMessageHandler, IAbortable { String(error.retryAfterSeconds * 1000), ), ) - return true + return { rejected: true } } logger('unable to issue peering invoice for %s: %o', peerPubkey, error) @@ -164,7 +241,7 @@ export class SubscribeMessageHandler implements IMessageHandler, IAbortable { WebSocketAdapterEvent.Message, createClosedMessage(subscriptionId, 'error: unable to issue peering invoice'), ) - return true + return { rejected: true } } } @@ -177,11 +254,11 @@ export class SubscribeMessageHandler implements IMessageHandler, IAbortable { WebSocketAdapterEvent.Message, createClosedMessage(subscriptionId, 'error: unable to check peering access'), ) - return true + return { rejected: true } } if (access.allowed) { - return false + return { rejected: false, access } } const reason = access.reason?.startsWith('payment-required:') @@ -190,7 +267,7 @@ export class SubscribeMessageHandler implements IMessageHandler, IAbortable { logger('subscription %s with %o rejected for peer %s: %s', subscriptionId, filters, peerPubkey, reason) this.webSocket.emit(WebSocketAdapterEvent.Message, createClosedMessage(subscriptionId, reason)) - return true + return { rejected: true } } // NIP-43: kind 28935 is never published by a client and never stored. A REQ for @@ -391,6 +468,14 @@ export class SubscribeMessageHandler implements IMessageHandler, IAbortable { } } + const invoiceSubscriptions = peerPubkey + ? [...subscriptions.values()].filter((current) => isPeeringInvoiceRequest(current, peerPubkey)).length + : 0 + const isNewInvoiceSubscription = isInvoiceRequest && !existingSubscription?.length + if (isNewInvoiceSubscription && invoiceSubscriptions >= MAX_PEERING_INVOICE_SUBSCRIPTIONS) { + return `Too many peering invoice subscriptions: close the previous invoice subscription before opening another` + } + const maxSubscriptions = subscriptionLimits?.maxSubscriptions ?? 0 if ( maxSubscriptions > 0 && diff --git a/src/repositories/peering-payment-repository.ts b/src/repositories/peering-payment-repository.ts index 708cf6342..95d136b1f 100644 --- a/src/repositories/peering-payment-repository.ts +++ b/src/repositories/peering-payment-repository.ts @@ -62,6 +62,24 @@ export class PeeringPaymentRepository implements IPeeringPaymentRepository { return row ? fromDBPeeringPayment(row) : undefined } + public async recordCheck( + pubkey: Pubkey, + checkedAt: Date, + error: string | null, + client: DatabaseClient = this.dbClient, + ): Promise { + await client('peering_payments') + .update({ + last_checked_at: checkedAt, + last_error: error, + updated_at: new Date(), + }) + .where({ + peer_pubkey: toBuffer(pubkey), + direction: PeeringPaymentDirection.SELL, + }) + } + public async save(payment: PeeringPayment, client: DatabaseClient = this.dbClient): Promise { const row = toRow(payment) const now = new Date() diff --git a/src/services/peering-billing-service.ts b/src/services/peering-billing-service.ts index 04a764ae1..8f75a3fa2 100644 --- a/src/services/peering-billing-service.ts +++ b/src/services/peering-billing-service.ts @@ -118,6 +118,28 @@ export class PeeringBillingService implements IPeeringBillingService { } const now = options.now ?? new Date() + + // Check the open invoice before retiring a locally expired one. The processor + // may have accepted payment just before expiry, especially without webhooks. + const settled = await this.recheckPendingInvoice(peerPubkey, now, true) + if (settled) { + const payment = await this.peeringPaymentRepository.findByPeer(peerPubkey) + const settledInvoiceId = payment?.lastSettledInvoiceId + if (settledInvoiceId) { + const settledInvoice = await this.invoiceRepository.findById(settledInvoiceId) + if (settledInvoice) { + try { + await this.paymentsService.ensurePeeringInvoiceNotification(settledInvoice) + } catch (error) { + logger.error('unable to publish settled peering invoice %s: %o', settledInvoice.id, error) + } + return settledInvoice + } + } + + return undefined + } + const transaction = new Transaction(this.dbClient) let invoice: Invoice | undefined @@ -244,7 +266,9 @@ export class PeeringBillingService implements IPeeringBillingService { } const invoice = await this.invoiceRepository.findById(invoiceId) - if (!invoice || invoice.status !== InvoiceStatus.PENDING || isExpiredAt(invoice, now)) { + // A locally expired invoice can still have been paid before its expiry. Ask the + // processor before retiring it; LNURL and NWC rely on polling for settlement. + if (!invoice || invoice.status !== InvoiceStatus.PENDING) { return false } @@ -259,19 +283,21 @@ export class PeeringBillingService implements IPeeringBillingService { return true } - await this.peeringPaymentRepository.save({ - ...payment, - lastCheckedAt: now, - lastError: updated.status ? `processor status: ${updated.status}` : null, - }) + // Update only the check columns. The processor call yields, and a webhook may + // have settled this peer in the meantime; saving the old row would undo it. + await this.peeringPaymentRepository.recordCheck( + peerPubkey, + now, + updated.status ? `processor status: ${updated.status}` : null, + ) return false } catch (error) { logger.error('unable to recheck peering invoice %s: %o', invoiceId, error) - await this.peeringPaymentRepository.save({ - ...payment, - lastCheckedAt: now, - lastError: error instanceof Error ? error.message : 'processor check failed', - }) + await this.peeringPaymentRepository.recordCheck( + peerPubkey, + now, + error instanceof Error ? error.message : 'processor check failed', + ) return false } } diff --git a/src/utils/settings-config.ts b/src/utils/settings-config.ts index fdad3f414..0d791e686 100644 --- a/src/utils/settings-config.ts +++ b/src/utils/settings-config.ts @@ -1099,13 +1099,6 @@ const validatePeering = (settings: Settings, issues: ValidationIssue[]): void => }) } - if (paidTerms.length > 0 && settings.payments?.enabled !== true) { - issues.push({ - path: 'peering.terms', - message: 'payments must be enabled to charge for peering', - }) - } - if (!enabled) { return } diff --git a/test/unit/handlers/neg-open-message-handler.spec.ts b/test/unit/handlers/neg-open-message-handler.spec.ts index 0386bf026..8520f4567 100644 --- a/test/unit/handlers/neg-open-message-handler.spec.ts +++ b/test/unit/handlers/neg-open-message-handler.spec.ts @@ -136,6 +136,35 @@ describe('NegOpenMessageHandler', () => { ]) }) + it('does not close a replacement NEG-OPEN after a stale billing wait', async () => { + const peer = 'a'.repeat(64) + let releaseAccess: ((access: unknown) => void) | undefined + ;(webSocket as any).getPeerPubkey = () => peer + const billing = { + getAccess: sandbox.stub().returns( + new Promise((resolve) => { + releaseAccess = resolve + }), + ), + } + handler = new NegOpenMessageHandler( + webSocket, + eventRepository, + () => settings, + rateLimiterFactory, + requestStats as any, + billing as any, + ) + + const pending = open() + await new Promise((resolve) => setImmediate(resolve)) + sessions.close('sync1') + releaseAccess?.({ allowed: false, reason: 'peering payment required' }) + await pending + + expect(emitted).not.to.have.been.called + }) + describe('when disabled', () => { it('replies NEG-ERR blocked and does not read events', async () => { settings = { nip77: { enabled: false } } as Settings diff --git a/test/unit/handlers/subscribe-message-handler.spec.ts b/test/unit/handlers/subscribe-message-handler.spec.ts index 3c2fd5df3..9973f4e45 100644 --- a/test/unit/handlers/subscribe-message-handler.spec.ts +++ b/test/unit/handlers/subscribe-message-handler.spec.ts @@ -192,6 +192,113 @@ describe('SubscribeMessageHandler', () => { expect(fetchAndSendStub).to.have.been.calledOnce }) + it('does not exempt a mixed invoice and feed request from the paid gate', async () => { + const peer = 'a'.repeat(64) + const billing = { + getAccess: sandbox.stub().resolves({ allowed: false, reason: 'peering payment required' }), + ensureInvoice: sandbox.stub().resolves(), + } + ;(webSocket.getPeerPubkey as Sinon.SinonStub).returns(peer) + settingsFactory.returns({ + limits: { peer: { subscription: { maxFilters: 10, maxLimit: 0 } } }, + }) + handler = new SubscribeMessageHandler( + webSocket, + eventRepository, + settingsFactory, + inviteCodeRepository, + rateLimiterFactory, + requestStats as any, + billing as any, + ) + const invoice = { kinds: [402], '#p': [peer], '#t': ['peering'], limit: 1 } + message = [MessageType.REQ, subscriptionId, invoice, { kinds: [1] }] as any + + await handler.handleMessage(message) + + expect(billing.ensureInvoice).not.to.have.been.called + expect(billing.getAccess).to.have.been.calledWith(peer, { recheck: true }) + expect(webSocketOnMessageStub.firstCall.args[0]).to.deep.equal([ + 'CLOSED', + subscriptionId, + 'payment-required: peering payment required', + ]) + expect(fetchAndSendStub).not.to.have.been.called + }) + + it('does not register a subscription closed while billing is pending', async () => { + const peer = 'a'.repeat(64) + let releaseAccess: ((access: unknown) => void) | undefined + const billing = { + getAccess: sandbox.stub().returns( + new Promise((resolve) => { + releaseAccess = resolve + }), + ), + ensureInvoice: sandbox.stub().resolves(), + } + ;(webSocket.getPeerPubkey as Sinon.SinonStub).returns(peer) + settingsFactory.returns({ + limits: { peer: { subscription: { maxFilters: 10, maxLimit: 0 } } }, + }) + handler = new SubscribeMessageHandler( + webSocket, + eventRepository, + settingsFactory, + inviteCodeRepository, + rateLimiterFactory, + requestStats as any, + billing as any, + ) + message = [MessageType.REQ, subscriptionId, { kinds: [1] }] as any + + const pending = handler.handleMessage(message) + ;(handler as any).abort() + releaseAccess?.({ allowed: true, periodEnd: new Date(Date.now() + 60_000) }) + await pending + + expect(webSocketOnSubscribeStub).not.to.have.been.called + expect(fetchAndSendStub).not.to.have.been.called + }) + + it('closes a paid feed when its access period ends', async () => { + const peer = 'a'.repeat(64) + const clock = Sinon.useFakeTimers({ now: new Date('2026-11-09T12:00:00.000Z') }) + const periodEnd = new Date('2026-11-09T12:00:01.000Z') + const billing = { + getAccess: sandbox.stub().resolves({ allowed: true, periodEnd }), + ensureInvoice: sandbox.stub().resolves(), + } + ;(webSocket.getPeerPubkey as Sinon.SinonStub).returns(peer) + settingsFactory.returns({ + limits: { peer: { subscription: { maxFilters: 10, maxLimit: 0 } } }, + }) + webSocketOnSubscribeStub.callsFake((id: string, registered: any) => subscriptions.set(id, registered)) + handler = new SubscribeMessageHandler( + webSocket, + eventRepository, + settingsFactory, + inviteCodeRepository, + rateLimiterFactory, + requestStats as any, + billing as any, + ) + message = [MessageType.REQ, subscriptionId, { kinds: [1] }] as any + + await handler.handleMessage(message) + await clock.tickAsync(1000) + + expect(endSubscriptionStub).to.have.been.calledWith(subscriptionId) + expect(webSocketOnMessageStub).to.have.been.calledWith([ + 'CLOSED', + subscriptionId, + 'payment-required: peering access expired', + ]) + + handler.abort() + clock.restore() + }) + it('bounds raw filters before deduplicating them', async () => { settingsFactory.returns({ limits: { client: { subscription: { maxFilters: 10 } } } }) const duplicate = { kinds: [1] } @@ -972,6 +1079,34 @@ describe('SubscribeMessageHandler', () => { expect((handler as any).canSubscribe(subscriptionId, filters)).to.be.undefined }) + it('caps peering invoice subscriptions for a peer', () => { + const peer = 'a'.repeat(64) + const invoice = { kinds: [402], '#p': [peer], '#t': ['peering'], limit: 1 } + ;(webSocket.getPeerPubkey as Sinon.SinonStub).returns(peer) + settingsFactory.returns({ + limits: { peer: { subscription: { maxSubscriptions: 10, maxFilters: 10 } } }, + }) + subscriptions.set('invoice-1', [invoice]) + + expect((handler as any).canSubscribe('invoice-2', [invoice])).to.equal( + 'Too many peering invoice subscriptions: close the previous invoice subscription before opening another', + ) + }) + + it('does not exempt a mixed invoice and feed request from maxSubscriptions', () => { + const peer = 'a'.repeat(64) + const invoice = { kinds: [402], '#p': [peer], '#t': ['peering'], limit: 1 } + ;(webSocket.getPeerPubkey as Sinon.SinonStub).returns(peer) + settingsFactory.returns({ + limits: { peer: { subscription: { maxSubscriptions: 1, maxFilters: 10 } } }, + }) + subscriptions.set('other', []) + + expect((handler as any).canSubscribe('mixed', [invoice, { kinds: [1] }])).to.equal( + 'Too many subscriptions: Number of subscriptions must be less than or equal to 1', + ) + }) + it('uses peer limits instead of client limits for a recognized peer', () => { settingsFactory.returns({ limits: { diff --git a/test/unit/repositories/peering-payment-repository.spec.ts b/test/unit/repositories/peering-payment-repository.spec.ts index 7c13d3a8d..20f5b311a 100644 --- a/test/unit/repositories/peering-payment-repository.spec.ts +++ b/test/unit/repositories/peering-payment-repository.spec.ts @@ -100,6 +100,20 @@ describe('PeeringPaymentRepository', () => { expect(saved.status).to.equal(PeeringPaymentStatus.LAPSED) }) + it('updates only the processor-check diagnostics', async () => { + const recording = createRecordingKnex() + const repository = new PeeringPaymentRepository(recording.db) + recording.respondWith(1) + + await repository.recordCheck(pubkey, now, 'processor status: pending', recording.db) + + const sql = recording.queries[0].sql + expect(sql).to.include('"last_checked_at"') + expect(sql).to.include('"last_error"') + expect(sql).to.not.include('"status"') + expect(sql).to.not.include('"period_end"') + }) + it('finds periods due for renewal and periods that ended', async () => { const recording = createRecordingKnex() const repository = new PeeringPaymentRepository(recording.db) diff --git a/test/unit/utils/settings-config.spec.ts b/test/unit/utils/settings-config.spec.ts index fc6384e40..f6c237307 100644 --- a/test/unit/utils/settings-config.spec.ts +++ b/test/unit/utils/settings-config.spec.ts @@ -113,7 +113,6 @@ describe('settings-config', () => { contact: 'test@example.com', }, network: { maxPayloadSize: 1024 }, - payments: { enabled: true, processor: 'lnbits' }, peering, }) as any @@ -276,20 +275,6 @@ describe('settings-config', () => { expect(issues.find((issue) => issue.path === 'peering.terms')?.message).to.include('only one paid') }) - it('rejects paid terms while payments are disabled', () => { - const issues = validateSettings({ - info: { relay_url: 'wss://relay.example.com', name: 'Test Relay' }, - network: { maxPayloadSize: 1024 }, - peering: { - ...settlementFreePeering, - terms: [{ amount: 21000, unit: 'msats', period: 2592000 }], - }, - } as any) - - expect(issues.some((issue) => issue.path === 'peering.terms')).to.equal(true) - expect(issues.find((issue) => issue.path === 'peering.terms')?.message).to.include('payments must be enabled') - }) - it('rejects a proof-of-work requirement above the NIP-13 maximum', () => { const issues = peeringIssues({ ...settlementFreePeering, minRequestPowDifficulty: 257 }) From 62d63f7f76f8281e82470f922052b98f5f33b46f Mon Sep 17 00:00:00 2001 From: ABHAY PANDEY Date: Mon, 9 Nov 2026 15:04:44 +0530 Subject: [PATCH 8/9] fix(peering): make paid feed expiry lifecycle-safe - move expiry timers onto the socket adapter - clear timers on CLOSE, socket close and subscription ID reuse - guard async expiry checks with a subscription token - wait for the pending billing call in the abort regression test --- src/@types/adapters.ts | 7 ++ src/adapters/web-socket-adapter.ts | 78 +++++++++++++++++ src/handlers/subscribe-message-handler.ts | 67 +-------------- test/unit/adapters/web-socket-adapter.spec.ts | 83 +++++++++++++++++++ .../handlers/neg-open-message-handler.spec.ts | 15 ++-- .../subscribe-message-handler.spec.ts | 21 ++--- 6 files changed, 189 insertions(+), 82 deletions(-) diff --git a/src/@types/adapters.ts b/src/@types/adapters.ts index 36c8005b0..cfb482ea1 100644 --- a/src/@types/adapters.ts +++ b/src/@types/adapters.ts @@ -39,6 +39,13 @@ export type IWebSocketAdapter = EventEmitter & { getNegentropySessions(): INegentropySessions /** Removes a subscription and aborts any in-flight read still serving it. */ endSubscription(subscriptionId: string): void + /** + * Schedules a paid-access expiry check for this subscription. The adapter owns + * the timer and clears it on CLOSE, socket close and subscription ID reuse. + * `check` returns the next period end when access continues, or undefined when + * the subscription should be closed. + */ + setSubscriptionExpiry(subscriptionId: string, periodEnd: Date, check: () => Promise): void } export interface ICacheAdapter { diff --git a/src/adapters/web-socket-adapter.ts b/src/adapters/web-socket-adapter.ts index 9d175180e..47f650efa 100644 --- a/src/adapters/web-socket-adapter.ts +++ b/src/adapters/web-socket-adapter.ts @@ -77,6 +77,7 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter private alive: boolean private pendingSend: Promise | undefined private subscriptions: Map + private readonly subscriptionExpiries = new Map() private readonly session: Nip42SessionManager /** Configured peering peer proven by AUTH on this socket, if any. */ private peerPubkey: string | undefined @@ -152,14 +153,88 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter public onUnsubscribed(subscriptionId: string): void { logger('client %s unsubscribed %s', this.clientId, subscriptionId) + this.clearSubscriptionExpiry(subscriptionId) this.subscriptions.delete(subscriptionId) } public onSubscribed(subscriptionId: string, filters: SubscriptionFilter[]): void { logger('client %s subscribed %s to %o', this.clientId, subscriptionId, filters) + // Reusing an id starts a new subscription; the old expiry must not close it. + this.clearSubscriptionExpiry(subscriptionId) this.subscriptions.set(subscriptionId, filters) } + public setSubscriptionExpiry( + subscriptionId: SubscriptionId, + periodEnd: Date, + check: () => Promise, + ): void { + this.clearSubscriptionExpiry(subscriptionId) + + const token = Symbol(subscriptionId) + const remaining = Math.max(0, periodEnd.getTime() - Date.now()) + const delay = Math.min(remaining, 2_147_483_647) + + const timer = setTimeout(() => { + if (this.subscriptionExpiries.get(subscriptionId)?.token !== token) { + return + } + + if (remaining > delay) { + this.setSubscriptionExpiry(subscriptionId, periodEnd, check) + return + } + + void this.runSubscriptionExpiry(subscriptionId, token, check) + }, delay) + + this.subscriptionExpiries.set(subscriptionId, { timer, token }) + } + + private async runSubscriptionExpiry( + subscriptionId: SubscriptionId, + token: symbol, + check: () => Promise, + ): Promise { + let nextPeriodEnd: Date | undefined + + try { + nextPeriodEnd = await check() + } catch (error) { + logger.error('unable to check paid subscription expiry for %s: %o', subscriptionId, error) + } + + // The id may have been reused, closed or disconnected while the check ran. + if (this.subscriptionExpiries.get(subscriptionId)?.token !== token) { + return + } + + if (nextPeriodEnd && nextPeriodEnd.getTime() > Date.now()) { + this.setSubscriptionExpiry(subscriptionId, nextPeriodEnd, check) + return + } + + this.clearSubscriptionExpiry(subscriptionId) + this.endSubscription(subscriptionId) + this.sendMessage(createClosedMessage(subscriptionId, 'payment-required: peering access expired')) + } + + private clearSubscriptionExpiry(subscriptionId: SubscriptionId): void { + const entry = this.subscriptionExpiries.get(subscriptionId) + if (!entry) { + return + } + + clearTimeout(entry.timer) + this.subscriptionExpiries.delete(subscriptionId) + } + + private clearAllSubscriptionExpiries(): void { + for (const subscriptionId of this.subscriptionExpiries.keys()) { + this.clearSubscriptionExpiry(subscriptionId) + } + } + public onBroadcast(event: Event): void { this.webSocketServer.emit(WebSocketServerAdapterEvent.Broadcast, event) if (cluster.isWorker && typeof process.send === 'function') { @@ -245,6 +320,8 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter } public drainAndClose(reason = 'relay shutting down'): void { + this.clearAllSubscriptionExpiries() + this.subscriptions.forEach((_filters, subscriptionId) => { this.sendMessage(createClosedMessage(subscriptionId, `error: ${reason}`)) }) @@ -466,6 +543,7 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter private onClientClose() { recordWebsocketConnectionClosed() this.alive = false + this.clearAllSubscriptionExpiries() this.subscriptions.clear() this.negentropySessions.closeAll() this.session.clear() diff --git a/src/handlers/subscribe-message-handler.ts b/src/handlers/subscribe-message-handler.ts index a624e9127..11e1b518b 100644 --- a/src/handlers/subscribe-message-handler.ts +++ b/src/handlers/subscribe-message-handler.ts @@ -38,9 +38,6 @@ const MAX_PEERING_INVOICE_SUBSCRIPTIONS = 1 export class SubscribeMessageHandler implements IMessageHandler, IAbortable { private readonly abortController: AbortController - private peeringExpiryTimer?: NodeJS.Timeout - private peeringExpiryGeneration = 0 - private subscriptionFilters: SubscriptionFilter[] = [] /** Set while `handleMessage` runs; used to abort in-flight reads when the sub is closed early. */ public activeSubscriptionId: SubscriptionId | undefined @@ -58,7 +55,6 @@ export class SubscribeMessageHandler implements IMessageHandler, IAbortable { } public abort(): void { - this.clearPeeringExpiry() this.abortController.abort() } @@ -139,11 +135,13 @@ export class SubscribeMessageHandler implements IMessageHandler, IAbortable { return } - this.subscriptionFilters = filters this.webSocket.emit(WebSocketAdapterEvent.Subscribe, subscriptionId, filters) if (peeringAccess?.periodEnd) { - this.schedulePeeringExpiry(subscriptionId, peerPubkey!, peeringAccess.periodEnd) + this.webSocket.setSubscriptionExpiry(subscriptionId, peeringAccess.periodEnd, async () => { + const latest = await this.peeringBillingService!.getAccess(peerPubkey!) + return latest.allowed ? (latest.periodEnd ?? undefined) : undefined + }) } await this.maybeIssueInviteCode(subscriptionId, filters) @@ -151,63 +149,6 @@ export class SubscribeMessageHandler implements IMessageHandler, IAbortable { await this.fetchAndSend(subscriptionId, filters) } - private schedulePeeringExpiry( - subscriptionId: SubscriptionId, - peerPubkey: Pubkey, - periodEnd: Date, - ): void { - this.clearPeeringExpiry() - const generation = this.peeringExpiryGeneration - const remaining = Math.max(0, periodEnd.getTime() - Date.now()) - const delay = Math.min(remaining, 2_147_483_647) - - this.peeringExpiryTimer = setTimeout(() => { - if (generation !== this.peeringExpiryGeneration) { - return - } - - if (remaining > delay) { - this.schedulePeeringExpiry(subscriptionId, peerPubkey, periodEnd) - return - } - - void this.expirePeeringSubscription(subscriptionId, peerPubkey) - }, delay) - } - - private clearPeeringExpiry(): void { - this.peeringExpiryGeneration++ - if (this.peeringExpiryTimer) { - clearTimeout(this.peeringExpiryTimer) - this.peeringExpiryTimer = undefined - } - } - - private async expirePeeringSubscription(subscriptionId: SubscriptionId, peerPubkey: Pubkey): Promise { - // The id may have been reused by a different subscription in the meantime. - const registered = this.webSocket.getSubscriptions().get(subscriptionId) - if (!registered || !equals(registered, this.subscriptionFilters)) { - return - } - - try { - const access = await this.peeringBillingService?.getAccess(peerPubkey) - if (access?.allowed && access.periodEnd && access.periodEnd.getTime() > Date.now()) { - this.schedulePeeringExpiry(subscriptionId, peerPubkey, access.periodEnd) - return - } - } catch (error) { - logger.error('unable to check peering expiry for %s: %o', peerPubkey, error) - } - - this.webSocket.endSubscription(subscriptionId) - this.webSocket.emit( - WebSocketAdapterEvent.Message, - createClosedMessage(subscriptionId, 'payment-required: peering access expired'), - ) - this.clearPeeringExpiry() - } - /** * Peers may always ask for their invoice; everything else is a paid feed and * needs a running period. Returns the access window when the feed is allowed. diff --git a/test/unit/adapters/web-socket-adapter.spec.ts b/test/unit/adapters/web-socket-adapter.spec.ts index 2b35d28e4..a466f699f 100644 --- a/test/unit/adapters/web-socket-adapter.spec.ts +++ b/test/unit/adapters/web-socket-adapter.spec.ts @@ -188,6 +188,89 @@ describe('WebSocketAdapter', () => { }) }) + describe('setSubscriptionExpiry', () => { + let clock: Sinon.SinonFakeTimers + + beforeEach(() => { + clock = Sinon.useFakeTimers({ now: new Date('2026-11-09T12:00:00.000Z') }) + }) + + afterEach(() => { + clock.restore() + }) + + const closedMessage = (subscriptionId: string) => + JSON.stringify(['CLOSED', subscriptionId, 'payment-required: peering access expired']) + + it('closes the subscription when paid access ends', async () => { + adapter.onSubscribed('sub-1', [{ kinds: [1] }]) + adapter.setSubscriptionExpiry( + 'sub-1', + new Date('2026-11-09T12:00:01.000Z'), + sandbox.stub().resolves(undefined), + ) + + await clock.tickAsync(1000) + + expect(adapter.getSubscriptions().has('sub-1')).to.be.false + expect(client.send).to.have.been.calledWith(closedMessage('sub-1')) + }) + + it('clears the timer when the subscription is closed', async () => { + const check = sandbox.stub().resolves(undefined) + adapter.onSubscribed('sub-1', [{ kinds: [1] }]) + adapter.setSubscriptionExpiry('sub-1', new Date('2026-11-09T12:00:01.000Z'), check) + adapter.onUnsubscribed('sub-1') + + await clock.tickAsync(1000) + + expect(check).not.to.have.been.called + }) + + it('clears the timer when the subscription id is reused', async () => { + const check = sandbox.stub().resolves(undefined) + adapter.onSubscribed('sub-1', [{ kinds: [1] }]) + adapter.setSubscriptionExpiry('sub-1', new Date('2026-11-09T12:00:01.000Z'), check) + adapter.onSubscribed('sub-1', [{ kinds: [2] }]) + + await clock.tickAsync(1000) + + expect(check).not.to.have.been.called + expect(adapter.getSubscriptions().get('sub-1')).to.deep.equal([{ kinds: [2] }]) + }) + + it('does not close a reused subscription when an old expiry check resolves late', async () => { + let releaseCheck: ((periodEnd: Date | undefined) => void) | undefined + const check = () => + new Promise((resolve) => { + releaseCheck = resolve + }) + adapter.onSubscribed('sub-1', [{ kinds: [1] }]) + adapter.setSubscriptionExpiry('sub-1', new Date('2026-11-09T12:00:00.000Z'), check) + + clock.tick(1) + expect(releaseCheck).to.be.a('function') + adapter.onSubscribed('sub-1', [{ kinds: [2] }]) + releaseCheck?.(undefined) + await clock.tickAsync(0) + + expect(adapter.getSubscriptions().get('sub-1')).to.deep.equal([{ kinds: [2] }]) + expect(client.send).not.to.have.been.calledWith(closedMessage('sub-1')) + }) + + it('clears all timers when the socket closes', async () => { + const check = sandbox.stub().resolves(undefined) + adapter.onSubscribed('sub-1', [{ kinds: [1] }]) + adapter.setSubscriptionExpiry('sub-1', new Date('2026-11-09T12:00:01.000Z'), check) + const closeCall = client.on.getCalls().find((call: any) => call.args[0] === 'close') + closeCall.args[1]() + + await clock.tickAsync(1000) + + expect(check).not.to.have.been.called + }) + }) + describe('onBroadcast', () => { it('emits broadcast event on the WebSocket server adapter', () => { const emitSpy = sandbox.spy(webSocketServer, 'emit') diff --git a/test/unit/handlers/neg-open-message-handler.spec.ts b/test/unit/handlers/neg-open-message-handler.spec.ts index 8520f4567..0fb02fbab 100644 --- a/test/unit/handlers/neg-open-message-handler.spec.ts +++ b/test/unit/handlers/neg-open-message-handler.spec.ts @@ -139,13 +139,18 @@ describe('NegOpenMessageHandler', () => { it('does not close a replacement NEG-OPEN after a stale billing wait', async () => { const peer = 'a'.repeat(64) let releaseAccess: ((access: unknown) => void) | undefined + let markGetAccessCalled: (() => void) | undefined + const getAccessCalled = new Promise((resolve) => { + markGetAccessCalled = resolve + }) ;(webSocket as any).getPeerPubkey = () => peer const billing = { - getAccess: sandbox.stub().returns( - new Promise((resolve) => { + getAccess: sandbox.stub().callsFake(() => { + markGetAccessCalled?.() + return new Promise((resolve) => { releaseAccess = resolve - }), - ), + }) + }), } handler = new NegOpenMessageHandler( webSocket, @@ -157,7 +162,7 @@ describe('NegOpenMessageHandler', () => { ) const pending = open() - await new Promise((resolve) => setImmediate(resolve)) + await getAccessCalled sessions.close('sync1') releaseAccess?.({ allowed: false, reason: 'peering payment required' }) await pending diff --git a/test/unit/handlers/subscribe-message-handler.spec.ts b/test/unit/handlers/subscribe-message-handler.spec.ts index 9973f4e45..fffc5e0ab 100644 --- a/test/unit/handlers/subscribe-message-handler.spec.ts +++ b/test/unit/handlers/subscribe-message-handler.spec.ts @@ -253,6 +253,8 @@ describe('SubscribeMessageHandler', () => { message = [MessageType.REQ, subscriptionId, { kinds: [1] }] as any const pending = handler.handleMessage(message) + await new Promise((resolve) => setImmediate(resolve)) + expect(billing.getAccess).to.have.been.calledOnce ;(handler as any).abort() releaseAccess?.({ allowed: true, periodEnd: new Date(Date.now() + 60_000) }) await pending @@ -261,19 +263,19 @@ describe('SubscribeMessageHandler', () => { expect(fetchAndSendStub).not.to.have.been.called }) - it('closes a paid feed when its access period ends', async () => { + it('schedules a paid feed expiry with the socket adapter', async () => { const peer = 'a'.repeat(64) - const clock = Sinon.useFakeTimers({ now: new Date('2026-11-09T12:00:00.000Z') }) - const periodEnd = new Date('2026-11-09T12:00:01.000Z') + const periodEnd = new Date(Date.now() + 60_000) + const setSubscriptionExpiry = sandbox.stub() const billing = { getAccess: sandbox.stub().resolves({ allowed: true, periodEnd }), ensureInvoice: sandbox.stub().resolves(), } + ;(webSocket as any).setSubscriptionExpiry = setSubscriptionExpiry ;(webSocket.getPeerPubkey as Sinon.SinonStub).returns(peer) settingsFactory.returns({ limits: { peer: { subscription: { maxFilters: 10, maxLimit: 0 } } }, }) - webSocketOnSubscribeStub.callsFake((id: string, registered: any) => subscriptions.set(id, registered)) handler = new SubscribeMessageHandler( webSocket, eventRepository, @@ -286,17 +288,8 @@ describe('SubscribeMessageHandler', () => { message = [MessageType.REQ, subscriptionId, { kinds: [1] }] as any await handler.handleMessage(message) - await clock.tickAsync(1000) - expect(endSubscriptionStub).to.have.been.calledWith(subscriptionId) - expect(webSocketOnMessageStub).to.have.been.calledWith([ - 'CLOSED', - subscriptionId, - 'payment-required: peering access expired', - ]) - - handler.abort() - clock.restore() + expect(setSubscriptionExpiry).to.have.been.calledWith(subscriptionId, periodEnd, Sinon.match.func) }) it('bounds raw filters before deduplicating them', async () => { From 32755880c26e62309651d3f04a9c670adda0a71f Mon Sep 17 00:00:00 2001 From: ABHAY PANDEY Date: Mon, 9 Nov 2026 15:32:12 +0530 Subject: [PATCH 9/9] fix(peering): preserve renewals and free feeds on expiry - recheck the pending invoice before closing a feed at period end - keep free feeds open when access no longer has an expiry - restore the payments-enabled check for paid peering settings - make the integration settings settlement-free for the peering tests --- src/@types/adapters.ts | 6 +++--- src/adapters/web-socket-adapter.ts | 14 ++++++++++---- src/handlers/subscribe-message-handler.ts | 11 +++++++++-- src/utils/settings-config.ts | 7 +++++++ .../integration/features/nip-11/nip-11.feature.ts | 12 +++++++++--- test/integration/settings.yaml | 4 ---- test/unit/adapters/web-socket-adapter.spec.ts | 14 ++++++++++++++ .../handlers/subscribe-message-handler.spec.ts | 12 ++++++++++++ test/unit/utils/settings-config.spec.ts | 15 +++++++++++++++ 9 files changed, 79 insertions(+), 16 deletions(-) diff --git a/src/@types/adapters.ts b/src/@types/adapters.ts index cfb482ea1..465f999b4 100644 --- a/src/@types/adapters.ts +++ b/src/@types/adapters.ts @@ -42,10 +42,10 @@ export type IWebSocketAdapter = EventEmitter & { /** * Schedules a paid-access expiry check for this subscription. The adapter owns * the timer and clears it on CLOSE, socket close and subscription ID reuse. - * `check` returns the next period end when access continues, or undefined when - * the subscription should be closed. + * `check` returns the next period end when access continues, `null` when access + * is free and no longer expires, or `false` when the subscription should close. */ - setSubscriptionExpiry(subscriptionId: string, periodEnd: Date, check: () => Promise): void + setSubscriptionExpiry(subscriptionId: string, periodEnd: Date, check: () => Promise): void } export interface ICacheAdapter { diff --git a/src/adapters/web-socket-adapter.ts b/src/adapters/web-socket-adapter.ts index 47f650efa..48551bd33 100644 --- a/src/adapters/web-socket-adapter.ts +++ b/src/adapters/web-socket-adapter.ts @@ -167,7 +167,7 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter public setSubscriptionExpiry( subscriptionId: SubscriptionId, periodEnd: Date, - check: () => Promise, + check: () => Promise, ): void { this.clearSubscriptionExpiry(subscriptionId) @@ -194,9 +194,9 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter private async runSubscriptionExpiry( subscriptionId: SubscriptionId, token: symbol, - check: () => Promise, + check: () => Promise, ): Promise { - let nextPeriodEnd: Date | undefined + let nextPeriodEnd: Date | null | false = false try { nextPeriodEnd = await check() @@ -209,7 +209,13 @@ export class WebSocketAdapter extends EventEmitter implements IWebSocketAdapter return } - if (nextPeriodEnd && nextPeriodEnd.getTime() > Date.now()) { + if (nextPeriodEnd === null) { + // Free peering: access no longer has an expiry, so stop watching it. + this.clearSubscriptionExpiry(subscriptionId) + return + } + + if (nextPeriodEnd instanceof Date && nextPeriodEnd.getTime() > Date.now()) { this.setSubscriptionExpiry(subscriptionId, nextPeriodEnd, check) return } diff --git a/src/handlers/subscribe-message-handler.ts b/src/handlers/subscribe-message-handler.ts index 11e1b518b..aa6ed2de0 100644 --- a/src/handlers/subscribe-message-handler.ts +++ b/src/handlers/subscribe-message-handler.ts @@ -139,8 +139,15 @@ export class SubscribeMessageHandler implements IMessageHandler, IAbortable { if (peeringAccess?.periodEnd) { this.webSocket.setSubscriptionExpiry(subscriptionId, peeringAccess.periodEnd, async () => { - const latest = await this.peeringBillingService!.getAccess(peerPubkey!) - return latest.allowed ? (latest.periodEnd ?? undefined) : undefined + // A renewal may have been paid after the last maintenance poll but before + // this period ended. Recheck the pending invoice before closing the feed. + const latest = await this.peeringBillingService!.getAccess(peerPubkey!, { recheck: true }) + if (!latest.allowed) { + return false + } + + // Free peering has no expiry; keep the feed open and stop the timer. + return latest.periodEnd ?? null }) } diff --git a/src/utils/settings-config.ts b/src/utils/settings-config.ts index 0d791e686..fdad3f414 100644 --- a/src/utils/settings-config.ts +++ b/src/utils/settings-config.ts @@ -1099,6 +1099,13 @@ const validatePeering = (settings: Settings, issues: ValidationIssue[]): void => }) } + if (paidTerms.length > 0 && settings.payments?.enabled !== true) { + issues.push({ + path: 'peering.terms', + message: 'payments must be enabled to charge for peering', + }) + } + if (!enabled) { return } diff --git a/test/integration/features/nip-11/nip-11.feature.ts b/test/integration/features/nip-11/nip-11.feature.ts index 728a830f4..ea8ba8eaa 100644 --- a/test/integration/features/nip-11/nip-11.feature.ts +++ b/test/integration/features/nip-11/nip-11.feature.ts @@ -97,12 +97,18 @@ Then('the peering object matches the configured peering settings', function(this { kinds: [1, 6], window_seconds: 3600 }, { kinds: [3], since: 1700000000 }, ], - terms: [{ amount: 1000, unit: 'msats', period: 2592000 }], + terms: [], }) }) -const peeringIssuesFor = (peering: unknown) => - validateSettings({ ...createSettings(), peering } as Settings).filter((issue) => issue.path.startsWith('peering')) +const peeringIssuesFor = (peering: unknown) => { + const settings = createSettings() + return validateSettings({ + ...settings, + payments: { ...settings.payments, enabled: true, processor: 'lnbits' }, + peering, + } as Settings).filter((issue) => issue.path.startsWith('peering')) +} When('peering settings are validated with a complete configuration', function(this: World>) { this.parameters.peeringValidationIssues = peeringIssuesFor({ diff --git a/test/integration/settings.yaml b/test/integration/settings.yaml index 79dbe9a61..c72304620 100644 --- a/test/integration/settings.yaml +++ b/test/integration/settings.yaml @@ -15,7 +15,3 @@ peering: windowSeconds: 3600 - kinds: [3] since: 1700000000 - terms: - - amount: 1000 - unit: msats - period: 2592000 diff --git a/test/unit/adapters/web-socket-adapter.spec.ts b/test/unit/adapters/web-socket-adapter.spec.ts index a466f699f..979d1b067 100644 --- a/test/unit/adapters/web-socket-adapter.spec.ts +++ b/test/unit/adapters/web-socket-adapter.spec.ts @@ -216,6 +216,20 @@ describe('WebSocketAdapter', () => { expect(client.send).to.have.been.calledWith(closedMessage('sub-1')) }) + it('keeps the subscription open when access becomes free', async () => { + adapter.onSubscribed('sub-1', [{ kinds: [1] }]) + adapter.setSubscriptionExpiry( + 'sub-1', + new Date('2026-11-09T12:00:01.000Z'), + sandbox.stub().resolves(null), + ) + + await clock.tickAsync(1000) + + expect(adapter.getSubscriptions().has('sub-1')).to.be.true + expect(client.send).not.to.have.been.calledWith(closedMessage('sub-1')) + }) + it('clears the timer when the subscription is closed', async () => { const check = sandbox.stub().resolves(undefined) adapter.onSubscribed('sub-1', [{ kinds: [1] }]) diff --git a/test/unit/handlers/subscribe-message-handler.spec.ts b/test/unit/handlers/subscribe-message-handler.spec.ts index fffc5e0ab..72e320a97 100644 --- a/test/unit/handlers/subscribe-message-handler.spec.ts +++ b/test/unit/handlers/subscribe-message-handler.spec.ts @@ -290,6 +290,18 @@ describe('SubscribeMessageHandler', () => { await handler.handleMessage(message) expect(setSubscriptionExpiry).to.have.been.calledWith(subscriptionId, periodEnd, Sinon.match.func) + + const check = setSubscriptionExpiry.firstCall.args[2] as () => Promise + const renewedPeriodEnd = new Date(periodEnd.getTime() + 60_000) + billing.getAccess.resolves({ allowed: true, periodEnd: renewedPeriodEnd }) + expect(await check()).to.equal(renewedPeriodEnd) + expect(billing.getAccess).to.have.been.calledWith(peer, { recheck: true }) + + billing.getAccess.resolves({ allowed: true, status: 'free' }) + expect(await check()).to.be.null + + billing.getAccess.resolves({ allowed: false }) + expect(await check()).to.be.false }) it('bounds raw filters before deduplicating them', async () => { diff --git a/test/unit/utils/settings-config.spec.ts b/test/unit/utils/settings-config.spec.ts index f6c237307..fc6384e40 100644 --- a/test/unit/utils/settings-config.spec.ts +++ b/test/unit/utils/settings-config.spec.ts @@ -113,6 +113,7 @@ describe('settings-config', () => { contact: 'test@example.com', }, network: { maxPayloadSize: 1024 }, + payments: { enabled: true, processor: 'lnbits' }, peering, }) as any @@ -275,6 +276,20 @@ describe('settings-config', () => { expect(issues.find((issue) => issue.path === 'peering.terms')?.message).to.include('only one paid') }) + it('rejects paid terms while payments are disabled', () => { + const issues = validateSettings({ + info: { relay_url: 'wss://relay.example.com', name: 'Test Relay' }, + network: { maxPayloadSize: 1024 }, + peering: { + ...settlementFreePeering, + terms: [{ amount: 21000, unit: 'msats', period: 2592000 }], + }, + } as any) + + expect(issues.some((issue) => issue.path === 'peering.terms')).to.equal(true) + expect(issues.find((issue) => issue.path === 'peering.terms')?.message).to.include('payments must be enabled') + }) + it('rejects a proof-of-work requirement above the NIP-13 maximum', () => { const issues = peeringIssues({ ...settlementFreePeering, minRequestPowDifficulty: 257 })