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/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/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..1a03d5086 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,7 +73,28 @@ 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 + /** + * 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, offset?: 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 { diff --git a/src/@types/services.ts b/src/@types/services.ts index 21a04c177..de9366223 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,7 +37,21 @@ 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 + /** Completed peering invoices whose settlement did not commit, for retry. */ + getUnconfirmedPeeringInvoices(limit?: number, offset?: number): Promise } export interface ISubscriptionService { diff --git a/src/app/maintenance-worker.ts b/src/app/maintenance-worker.ts index b3d9e1f42..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, @@ -187,12 +192,45 @@ 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 short page means we reached the end, so start over next pass. + // 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 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) from offset %d', + pending.length, + this.pendingInvoiceOffset, + retryablePeering.length, + retryOffset, + ) + + // 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 = - 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 + 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 + // 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 +249,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([ @@ -232,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/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/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/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/invoice-repository.ts b/src/repositories/invoice-repository.ts index 36734916e..cd7428ab2 100644 --- a/src/repositories/invoice-repository.ts +++ b/src/repositories/invoice-repository.ts @@ -1,6 +1,6 @@ -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 { DBInvoice, Invoice, InvoicePurpose, InvoiceStatus } from '../@types/invoice' import { fromDBInvoice, toBuffer } from '../utils/transform' import { createLogger } from '../factories/logger-factory' import { DatabaseClient } from '../@types/base' @@ -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() @@ -53,6 +68,29 @@ export class InvoiceRepository implements IInvoiceRepository { return dbInvoices.map(fromDBInvoice) } + 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. Offset advances + // the scan past invoices that keep failing so later ones are not starved. + const dbInvoices = await client('invoices') + .where({ + purpose: InvoicePurpose.PEERING, + status: InvoiceStatus.COMPLETED, + }) + .whereNull('confirmed_at') + .orderBy('created_at', 'asc') + .offset(offset) + .limit(limit) + .select() + + return dbInvoices.map(fromDBInvoice) + } + public updateStatus(invoice: Invoice, client: DatabaseClient = this.dbClient): Promise { logger('updating invoice status: %o', invoice) @@ -92,9 +130,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 +158,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/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/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 36f6ec3f3..cbc9a2190 100644 --- a/src/services/payments-service.ts +++ b/src/services/payments-service.ts @@ -1,25 +1,32 @@ 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 { + InvalidPeeringTermError, + PEERING_INVOICE_TAG, + peeringPeriodEnd, +} from '../utils/peering-billing' const logger = createLogger('payments-service') @@ -36,6 +43,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 { @@ -49,6 +57,20 @@ export class PaymentsService implements IPaymentsService { } } + 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, offset) + } 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( @@ -96,6 +118,7 @@ export class PaymentsService implements IPaymentsService { updatedAt: date, createdAt: date, verifyURL: invoiceResponse.verifyURL, + purpose: planId ? InvoicePurpose.SUBSCRIPTION : InvoicePurpose.ADMISSION, planId, }, transaction.transaction, @@ -126,6 +149,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 +160,105 @@ 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()) + + // 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() + } + 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 +302,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 +324,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 ?? [] @@ -255,6 +412,131 @@ 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 } + } + + // 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( + 'peering invoice %s paid %s msats, below term %s msats', + invoice.id, + amountPaidMsat.toString(), + termAmountMsats.toString(), + ) + + await this.peeringPaymentRepository.save( + { + ...payment, + lastCheckedAt: settledAt, + lastError: `underpaid: received ${amountPaidMsat.toString()} of ${termAmountMsats.toString()} msats`, + }, + client, + ) + + 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() > settledAt.getTime() + const periodStart = stillRunning ? (payment.periodEnd as Date) : settledAt + const periodEnd = peeringPeriodEnd(periodStart, termPeriodSeconds) + + await this.peeringPaymentRepository.save( + { + ...payment, + status: PeeringPaymentStatus.ACTIVE, + currentInvoiceId: null, + renewalInvoiceId: null, + periodStart, + periodEnd, + lastSettledInvoiceId: invoice.id, + lastSettledAt: settledAt, + lastCheckedAt: settledAt, + 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) @@ -293,7 +575,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() @@ -310,6 +597,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..9ce024d5b --- /dev/null +++ b/src/utils/peering-billing.ts @@ -0,0 +1,143 @@ +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') + } + + // 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') + } + + 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/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) + }, +) diff --git a/test/unit/app/maintenance-worker.spec.ts b/test/unit/app/maintenance-worker.spec.ts index dc3defe1c..cbd03e1d6 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,147 @@ 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, + 0, + ) + 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('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 @@ -581,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/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..ac92b1420 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,57 @@ describe('InvoiceRepository', () => { }) }) + describe('.findUnconfirmedPeeringInvoices', () => { + function makeClient(results: any[]) { + const selectStub = sandbox.stub().resolves(results) + const limitStub = sandbox.stub().returns({ select: selectStub }) + 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 { + client: sandbox.stub().returns({ where: whereStub }) as unknown as DatabaseClient, + whereStub, + whereNullStub, + orderByStub, + offsetStub, + 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, 0, 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, offsetStub, limitStub } = makeClient([]) + + await repository.findUnconfirmedPeeringInvoices(25, 5, 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(offsetStub).to.have.been.calledWith(5) + 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/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" <=') + }) +}) diff --git a/test/unit/services/payments-service.spec.ts b/test/unit/services/payments-service.spec.ts index c8a5c8135..576fc653a 100644 --- a/test/unit/services/payments-service.spec.ts +++ b/test/unit/services/payments-service.spec.ts @@ -7,9 +7,9 @@ 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' +import { PENDING_INVOICE_PAGE_SIZE, PaymentsService } from '../../../src/services/payments-service' const { expect } = chai @@ -63,10 +63,13 @@ 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(), + updatePeriod: sandbox.stub().resolves(), } eventRepository = { @@ -138,6 +141,38 @@ 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, + 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, + ) + }) + + 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 } @@ -702,12 +737,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 }) @@ -847,4 +882,429 @@ 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('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( + 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('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( + 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('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( + 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('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(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({ + 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..233e7d300 --- /dev/null +++ b/test/unit/utils/peering-billing.spec.ts @@ -0,0 +1,84 @@ +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 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, + ) + }) + + 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 + }) +})