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
11 changes: 11 additions & 0 deletions .changeset/paid-peering-lifecycle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"nostream": minor
---

feat(peering): renew, lapse and expose paid peering access

The billing worker now issues renewal invoices before a period ends, rechecks
pending payments before lapsing a peer, closes live peer sockets on lapse, and
keeps stored events and sync state for a later resume. `GET /admin/peering` and
the dashboard show billing status, access end, the open invoice and the last
processor error.
36 changes: 36 additions & 0 deletions docs/PEERING.md
Original file line number Diff line number Diff line change
Expand Up @@ -191,3 +191,39 @@ authenticate the remote relay, because NIP-42 is a client-to-relay proof. The
remote relay's identity is established separately by the configured
`peering.peers` pubkey (and, in later phases, by discovery and owner
attestation).

## Paid peering

When `peering.terms` contains exactly one positive term, this relay sells the
feed instead of serving it settlement-free. Terms use the NIP-11 fee-object
shape (`amount`, `unit`, `period`); `unit` must be `msats`, `sats` or `btc`, and
a paid `msats` amount must be a whole number of satoshis so every payment
processor charges the same value. Payments must be enabled.

The buyer, Relay A, has no relationship with this relay's payment processor.
The invoice travels over the authenticated relay connection:

1. Relay A authenticates with NIP-42 and sends
`REQ {"kinds":[402],"#p":["<A pubkey>"],"#t":["peering"],"limit":1}`.
2. Relay B creates a BOLT11 invoice with its existing payments processor and
persists it with `invoices.purpose = peering` and a snapshot of the term.
3. Relay B publishes a signed `kind 402` event tagged `p` (Relay A), `t`
(`peering`), `bolt11`, `i` (processor invoice id) and `expiration`.
4. Relay A pays the invoice. The processor calls Relay B's existing callback,
or the billing worker finds the payment by polling. Settlement validates the
amount and extends `peering_payments.period_end` exactly once.
5. Feed access is allowed while `period_end` is in the future. An unpaid period
marks the peer `lapsed`, closes its peer sockets, and refuses feed `REQ`,
`COUNT` and `NIP-77`; it does not delete events or sync state.
6. Renewal is the same loop: the billing worker issues a renewal invoice before
the period ends and publishes it on the same `kind 402` path.

An expired invoice is retired and reissued on request after a short cooldown.
Before denying access, the relay rechecks a pending invoice with the processor,
so a missed webhook heals on its own. A peer can always authenticate and ask for
its invoice, even while lapsed, but cannot read the feed.

Billing state is stored in `peering_payments`, one row per peer and direction
(`sell` here; `buy` is reserved for the paying side). `GET /admin/peering`
merges it with the sync and request rows, showing status, access end, the open
invoice and the last processor error.
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')
})
}
14 changes: 13 additions & 1 deletion resources/admin/assets/admin-ops.js
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,16 @@
return `${Math.round(seconds / 86400)}d`
}

const peeringBillingBadge = (billing) => {
if (!billing?.status) {
return '<span class="status-badge"><span class="status-led" aria-hidden="true"></span>Not billed</span>'
}

const status = String(billing.status)
const degraded = status === 'lapsed' || status === 'unpaid'
return `<span class="status-badge${degraded ? ' status-degraded' : ''}"><span class="status-led" aria-hidden="true"></span>${escapeHtml(status)}</span>`
}

const peeringSignalBadge = (row) => {
if (!row.configured || !row.enabled) {
return '<span class="status-badge"><span class="status-led" aria-hidden="true"></span>Disabled</span>'
Expand Down Expand Up @@ -145,7 +155,7 @@
const renderPeering = (rows) => {
if (!Array.isArray(rows) || !rows.length) {
elements.peeringBody.innerHTML =
'<tr><td colspan="8" class="admin-muted small">No peering peers configured.</td></tr>'
'<tr><td colspan="10" class="admin-muted small">No peering peers configured.</td></tr>'
return
}

Expand All @@ -158,6 +168,8 @@
return `<tr>
<td><code class="small" title="${escapeAttr(title)}">${escapeHtml(shortPubkey(row.pubkey))}</code></td>
<td class="small">${escapeHtml(row.relayUrl)}</td>
<td>${peeringBillingBadge(row.billing)}</td>
<td class="small">${escapeHtml(formatTime(row.billing?.periodEnd))}</td>
Comment thread
Ferryx349 marked this conversation as resolved.
Resolved
<td class="small">${escapeHtml(formatTime(state?.lastReconciledAt))}</td>
<td>${escapeHtml(formatLag(state?.lagSeconds))}</td>
<td>${escapeHtml(state?.missingEvents ?? '—')}</td>
Expand Down
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
28 changes: 27 additions & 1 deletion resources/admin/dashboard.html
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,8 @@ <h2 class="section-title">Peering</h2>
<tr>
<th scope="col">Peer</th>
<th scope="col">Relay</th>
<th scope="col">Billing</th>
<th scope="col">Access until</th>
<th scope="col">Last reconciliation</th>
<th scope="col">Lag</th>
<th scope="col">Missing</th>
Expand All @@ -245,7 +247,7 @@ <h2 class="section-title">Peering</h2>
</tr>
</thead>
<tbody id="peering-body">
<tr><td colspan="8" class="admin-muted small">No peering peers configured.</td></tr>
<tr><td colspan="10" class="admin-muted small">No peering peers configured.</td></tr>
</tbody>
</table>
</div>
Expand Down Expand Up @@ -406,6 +408,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
Comment thread
Ferryx349 marked this conversation as resolved.
Resolved
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',
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
Loading
Loading