Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/paid-peering-delivery.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"nostream": minor
---

feat(peering): deliver paid peering invoices and gate peer feeds

Configured peers can request their BOLT11 invoice as a signed `kind 402` event
and pay it through Relay B's existing processor. Feed `REQ`, `COUNT` and NIP-77
requests are refused with a payment-required reason until a paid period is
active. Expired invoices are reissued after a cooldown, and a missed webhook is
rechecked before access is denied.
5 changes: 5 additions & 0 deletions .changeset/paid-peering-ledger.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"nostream": minor
---

feat(peering): add the paid peering billing ledger
130 changes: 130 additions & 0 deletions migrations/20261109_120000_add_peering_billing.js
Original file line number Diff line number Diff line change
@@ -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')
})
}
4 changes: 4 additions & 0 deletions resources/admin/assets/notifications.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
24 changes: 24 additions & 0 deletions resources/admin/dashboard.html
Original file line number Diff line number Diff line change
Expand Up @@ -406,6 +406,30 @@ <h2 class="section-title">Operator notifications</h2>
<label class="form-check-label small" for="notifications-event-invoice-failed">Invoice failed</label>
</div>
</div>
<div class="col-md-6 col-lg-4">
<div class="form-check">
<input id="notifications-event-peering-invoice-created" class="form-check-input" type="checkbox" data-event-key="peering.invoice.created">
<label class="form-check-label small" for="notifications-event-peering-invoice-created">Peering invoice created</label>
</div>
</div>
<div class="col-md-6 col-lg-4">
<div class="form-check">
<input id="notifications-event-peering-invoice-paid" class="form-check-input" type="checkbox" data-event-key="peering.invoice.paid">
<label class="form-check-label small" for="notifications-event-peering-invoice-paid">Peering invoice paid</label>
</div>
</div>
<div class="col-md-6 col-lg-4">
<div class="form-check">
<input id="notifications-event-peering-invoice-failed" class="form-check-input" type="checkbox" data-event-key="peering.invoice.failed">
<label class="form-check-label small" for="notifications-event-peering-invoice-failed">Peering invoice failed</label>
</div>
</div>
<div class="col-md-6 col-lg-4">
<div class="form-check">
<input id="notifications-event-peering-access-lapsed" class="form-check-input" type="checkbox" data-event-key="peering.access.lapsed">
<label class="form-check-label small" for="notifications-event-peering-access-lapsed">Peering access lapsed</label>
</div>
</div>
<div class="col-md-6 col-lg-4">
<div class="form-check">
<input id="notifications-event-settings-changed" class="form-check-input" type="checkbox" data-event-key="settings.changed">
Expand Down
10 changes: 8 additions & 2 deletions resources/default-settings.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -193,8 +193,10 @@ peering:
# windowSeconds: 3600
scopes: []
# Terms for peering, reusing the NIP-11 fee-object shape. `period` is in
# seconds. An empty list means settlement-free peering.
# Example: [{ amount: 1000, unit: msats, period: 2592000 }]
# seconds. An empty list means settlement-free peering. One positive term
# makes this a paid feed: payments must be enabled, and a paid msats amount
# must be a whole number of satoshis.
# Example: [{ amount: 21000, unit: msats, period: 2592000 }]
terms: []
# Peering peers trusted by their relay pubkey (hex or npub), never by URL
# alone. A connection that proves one of these keys with NIP-42 AUTH is
Expand Down Expand Up @@ -462,6 +464,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
Expand Down
7 changes: 7 additions & 0 deletions src/@types/adapters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,13 @@ export type IWebSocketAdapter = EventEmitter & {
getNegentropySessions(): INegentropySessions
/** Removes a subscription and aborts any in-flight read still serving it. */
endSubscription(subscriptionId: string): void
/**
* Schedules a paid-access expiry check for this subscription. The adapter owns
* the timer and clears it on CLOSE, socket close and subscription ID reuse.
* `check` returns the next period end when access continues, `null` when access
* is free and no longer expires, or `false` when the subscription should close.
*/
setSubscriptionExpiry(subscriptionId: string, periodEnd: Date, check: () => Promise<Date | null | false>): void
}

export interface ICacheAdapter {
Expand Down
15 changes: 15 additions & 0 deletions src/@types/invoice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand All @@ -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
}
8 changes: 8 additions & 0 deletions src/@types/operator-notifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Lapse alerts never arrive

The new peering.access.lapsed switch cannot send an alert. Nothing enqueues PEERING_ACCESS_LAPSED or moves an ended peering period to LAPSED. Access checks deny the expired period, but operators who enable this alert never hear that the peer lost access.

Connect the ended-period scan to a one-time state change and notification.

Knowledge Base Used: Notifications and maintenance

SETTINGS_CHANGED = 'settings.changed',
RELAY_RESTARTED = 'relay.restarted',
PROBE_FAILED = 'probe.failed',
Expand All @@ -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
Expand Down
63 changes: 63 additions & 0 deletions src/@types/peering.ts
Original file line number Diff line number Diff line change
@@ -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. */
Expand All @@ -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
Expand Down
Loading
Loading