Add NIP-47 (Nostr Wallet Connect) support
Some checks failed
Docker Compose Actions Workflow / test (push) Has been cancelled

Implements NWC protocol alongside the existing CLINK/Ndebit system,
allowing any NWC-compatible wallet (Alby, Amethyst, Damus, etc.) to
connect to a Lightning Pub node.

Supported NIP-47 methods: pay_invoice, make_invoice, get_balance,
get_info, lookup_invoice, list_transactions.

New files:
- NwcConnection entity with per-connection spending limits
- NwcStorage for connection CRUD operations
- NwcManager for NIP-47 request handling and connection management
- Database migration for nwc_connection table

Modified files:
- nostrPool: subscribe to kind 23194 events
- nostrMiddleware: route kind 23194 to NwcManager
- main/index: wire NwcManager, publish kind 13194 info events
- storage: register NwcConnection entity and NwcStorage

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Patrick Mulligan 2026-02-25 11:29:57 -05:00
parent 68c71599f8
commit 5a26676a24
10 changed files with 473 additions and 4 deletions

View file

@ -85,6 +85,13 @@ export default (serverMethods: Types.ServerMethods, mainHandler: Main, nostrSett
const nmanageReq = j as NmanageRequest
mainHandler.managementManager.handleRequest(nmanageReq, event);
return;
} else if (event.kind === 23194) {
if (event.relayConstraint === 'provider') {
log("got NWC request on provider only relay, ignoring")
return
}
mainHandler.nwcManager.handleNwcRequest(event.content, event)
return
}
if (!j.rpcName) {
if (event.relayConstraint === 'service') {

View file

@ -26,6 +26,7 @@ import { OfferManager } from "./offerManager.js"
import { parse } from "uri-template"
import webRTC from "../webRTC/index.js"
import { ManagementManager } from "./managementManager.js"
import { NwcManager } from "./nwcManager.js"
import { Agent } from "https"
import { NotificationsManager } from "./notificationsManager.js"
import { ApplicationUser } from '../storage/entity/ApplicationUser.js'
@ -58,6 +59,7 @@ export default class {
debitManager: DebitManager
offerManager: OfferManager
managementManager: ManagementManager
nwcManager: NwcManager
utils: Utils
rugPullTracker: RugPullTracker
unlocker: Unlocker
@ -89,6 +91,7 @@ export default class {
this.debitManager = new DebitManager(this.storage, this.lnd, this.applicationManager)
this.offerManager = new OfferManager(this.storage, this.settings, this.lnd, this.applicationManager, this.productManager, this.liquidityManager)
this.managementManager = new ManagementManager(this.storage, this.settings)
this.nwcManager = new NwcManager(this.storage, this.settings, this.lnd, this.applicationManager)
this.notificationsManager = new NotificationsManager(this.settings)
//this.webRTC = new webRTC(this.storage, this.utils)
}
@ -104,6 +107,9 @@ export default class {
StartBeacons() {
this.applicationManager.StartAppsServiceBeacon((app, fees) => {
this.UpdateBeacon(app, { type: 'service', name: app.name, avatarUrl: app.avatar_url, fees })
if (app.nostr_public_key) {
this.nwcManager.publishNwcInfo(app.app_id, app.nostr_public_key)
}
})
}
@ -535,6 +541,9 @@ export default class {
const fees = this.paymentManager.GetFees()
for (const app of apps) {
await this.UpdateBeacon(app, { type: 'service', name: app.name, avatarUrl: app.avatar_url, nextRelay, fees })
if (app.nostr_public_key) {
this.nwcManager.publishNwcInfo(app.app_id, app.nostr_public_key)
}
}
const defaultNames = ['wallet', 'wallet-test', this.settings.getSettings().serviceSettings.defaultAppName]

View file

@ -0,0 +1,346 @@
import { generateSecretKey, getPublicKey, UnsignedEvent } from 'nostr-tools'
import { bytesToHex } from '@noble/hashes/utils'
import ApplicationManager from "./applicationManager.js"
import Storage from '../storage/index.js'
import LND from "../lnd/lnd.js"
import { ERROR, getLogger } from "../helpers/logger.js"
import { NostrEvent } from '../nostr/nostrPool.js'
import SettingsManager from "./settingsManager.js"
type NwcRequest = {
method: string
params: Record<string, any>
}
type NwcResponse = {
result_type: string
error?: { code: string, message: string }
result?: Record<string, any>
}
const NWC_REQUEST_KIND = 23194
const NWC_RESPONSE_KIND = 23195
const NWC_INFO_KIND = 13194
const SUPPORTED_METHODS = [
'pay_invoice',
'make_invoice',
'get_balance',
'get_info',
'lookup_invoice',
'list_transactions',
]
const NWC_ERRORS = {
UNAUTHORIZED: 'UNAUTHORIZED',
RESTRICTED: 'RESTRICTED',
PAYMENT_FAILED: 'PAYMENT_FAILED',
QUOTA_EXCEEDED: 'QUOTA_EXCEEDED',
INSUFFICIENT_BALANCE: 'INSUFFICIENT_BALANCE',
NOT_IMPLEMENTED: 'NOT_IMPLEMENTED',
NOT_FOUND: 'NOT_FOUND',
INTERNAL: 'INTERNAL',
OTHER: 'OTHER',
} as const
const newNwcResponse = (content: string, event: { pub: string, id: string }): UnsignedEvent => {
return {
content,
created_at: Math.floor(Date.now() / 1000),
kind: NWC_RESPONSE_KIND,
pubkey: "",
tags: [
['p', event.pub],
['e', event.id],
],
}
}
export class NwcManager {
applicationManager: ApplicationManager
storage: Storage
settings: SettingsManager
lnd: LND
logger = getLogger({ component: 'NwcManager' })
constructor(storage: Storage, settings: SettingsManager, lnd: LND, applicationManager: ApplicationManager) {
this.storage = storage
this.settings = settings
this.lnd = lnd
this.applicationManager = applicationManager
}
handleNwcRequest = async (content: string, event: NostrEvent) => {
if (!this.storage.NostrSender().IsReady()) {
this.logger(ERROR, "Nostr sender not ready, dropping NWC request")
return
}
let request: NwcRequest
try {
request = JSON.parse(content)
} catch {
this.logger(ERROR, "invalid NWC request JSON")
this.sendError(event, 'unknown', NWC_ERRORS.OTHER, 'Invalid request JSON')
return
}
const { method, params } = request
if (!method) {
this.sendError(event, 'unknown', NWC_ERRORS.OTHER, 'Missing method')
return
}
const connection = await this.storage.nwcStorage.GetConnection(event.appId, event.pub)
if (!connection) {
this.logger("NWC request from unknown client pubkey:", event.pub)
this.sendError(event, method, NWC_ERRORS.UNAUTHORIZED, 'Unknown connection')
return
}
if (connection.expires_at > 0 && connection.expires_at < Math.floor(Date.now() / 1000)) {
this.logger("NWC connection expired for client:", event.pub)
this.sendError(event, method, NWC_ERRORS.UNAUTHORIZED, 'Connection expired')
return
}
if (connection.permissions && connection.permissions.length > 0 && !connection.permissions.includes(method)) {
this.sendError(event, method, NWC_ERRORS.RESTRICTED, `Method ${method} not permitted`)
return
}
try {
switch (method) {
case 'pay_invoice':
await this.handlePayInvoice(event, params, connection.app_user_id, connection.max_amount, connection.total_spent)
break
case 'make_invoice':
await this.handleMakeInvoice(event, params, connection.app_user_id)
break
case 'get_balance':
await this.handleGetBalance(event, connection.app_user_id)
break
case 'get_info':
await this.handleGetInfo(event)
break
case 'lookup_invoice':
await this.handleLookupInvoice(event, params)
break
case 'list_transactions':
await this.handleListTransactions(event, params, connection.app_user_id)
break
default:
this.sendError(event, method, NWC_ERRORS.NOT_IMPLEMENTED, `Method ${method} not supported`)
}
} catch (e: any) {
this.logger(ERROR, `NWC ${method} failed:`, e.message || e)
this.sendError(event, method, NWC_ERRORS.INTERNAL, e.message || 'Internal error')
}
}
private handlePayInvoice = async (event: NostrEvent, params: Record<string, any>, appUserId: string, maxAmount: number, totalSpent: number) => {
const { invoice } = params
if (!invoice) {
this.sendError(event, 'pay_invoice', NWC_ERRORS.OTHER, 'Missing invoice parameter')
return
}
if (maxAmount > 0) {
const decoded = await this.lnd.DecodeInvoice(invoice)
const amountSats = decoded.numSatoshis
if (amountSats > 0 && (totalSpent + amountSats) > maxAmount) {
this.sendError(event, 'pay_invoice', NWC_ERRORS.QUOTA_EXCEEDED, 'Spending limit exceeded')
return
}
}
try {
const paid = await this.applicationManager.PayAppUserInvoice(event.appId, {
amount: 0,
invoice,
user_identifier: appUserId,
debit_npub: event.pub,
})
await this.storage.nwcStorage.IncrementTotalSpent(event.appId, event.pub, paid.amount_paid + paid.service_fee)
this.sendResult(event, 'pay_invoice', { preimage: paid.preimage })
} catch (e: any) {
this.sendError(event, 'pay_invoice', NWC_ERRORS.PAYMENT_FAILED, e.message || 'Payment failed')
}
}
private handleMakeInvoice = async (event: NostrEvent, params: Record<string, any>, appUserId: string) => {
const amountMsats = params.amount
if (amountMsats === undefined || amountMsats === null) {
this.sendError(event, 'make_invoice', NWC_ERRORS.OTHER, 'Missing amount parameter')
return
}
const amountSats = Math.floor(amountMsats / 1000)
const description = params.description || ''
const expiry = params.expiry || undefined
const result = await this.applicationManager.AddAppUserInvoice(event.appId, {
receiver_identifier: appUserId,
payer_identifier: '',
http_callback_url: '',
invoice_req: {
amountSats,
memo: description,
expiry,
},
})
this.sendResult(event, 'make_invoice', {
type: 'incoming',
invoice: result.invoice,
description,
description_hash: '',
preimage: '',
payment_hash: '',
amount: amountMsats,
fees_paid: 0,
created_at: Math.floor(Date.now() / 1000),
expires_at: Math.floor(Date.now() / 1000) + (expiry || 3600),
metadata: {},
})
}
private handleGetBalance = async (event: NostrEvent, appUserId: string) => {
const app = await this.storage.applicationStorage.GetApplication(event.appId)
const appUser = await this.storage.applicationStorage.GetApplicationUser(app, appUserId)
const balanceMsats = appUser.user.balance_sats * 1000
this.sendResult(event, 'get_balance', { balance: balanceMsats })
}
private handleGetInfo = async (event: NostrEvent) => {
const info = await this.lnd.GetInfo()
this.sendResult(event, 'get_info', {
alias: info.alias,
color: '',
pubkey: info.identityPubkey,
network: 'mainnet',
block_height: info.blockHeight,
block_hash: info.blockHash,
methods: SUPPORTED_METHODS,
})
}
private handleLookupInvoice = async (event: NostrEvent, params: Record<string, any>) => {
const { invoice, payment_hash } = params
if (!invoice && !payment_hash) {
this.sendError(event, 'lookup_invoice', NWC_ERRORS.OTHER, 'Missing invoice or payment_hash parameter')
return
}
if (invoice) {
const found = await this.storage.paymentStorage.GetInvoiceOwner(invoice)
if (!found) {
this.sendError(event, 'lookup_invoice', NWC_ERRORS.NOT_FOUND, 'Invoice not found')
return
}
this.sendResult(event, 'lookup_invoice', {
type: 'incoming',
invoice: found.invoice,
description: '',
description_hash: '',
preimage: '',
payment_hash: '',
amount: found.paid_amount * 1000,
fees_paid: 0,
created_at: Math.floor(found.created_at.getTime() / 1000),
settled_at: found.paid_at_unix > 0 ? found.paid_at_unix : undefined,
metadata: {},
})
} else {
this.sendError(event, 'lookup_invoice', NWC_ERRORS.NOT_IMPLEMENTED, 'Lookup by payment_hash not supported')
}
}
private handleListTransactions = async (event: NostrEvent, params: Record<string, any>, appUserId: string) => {
const app = await this.storage.applicationStorage.GetApplication(event.appId)
const appUser = await this.storage.applicationStorage.GetApplicationUser(app, appUserId)
const from = params.from || 0
const limit = Math.min(params.limit || 50, 50)
const invoices = await this.storage.paymentStorage.GetUserInvoicesFlaggedAsPaid(appUser.user.serial_id, 0, from, limit)
const transactions = invoices.map(inv => ({
type: 'incoming' as const,
invoice: inv.invoice,
description: '',
description_hash: '',
preimage: '',
payment_hash: '',
amount: inv.paid_amount * 1000,
fees_paid: 0,
created_at: Math.floor(inv.created_at.getTime() / 1000),
settled_at: inv.paid_at_unix > 0 ? inv.paid_at_unix : undefined,
metadata: {},
}))
this.sendResult(event, 'list_transactions', { transactions })
}
// --- Connection management methods ---
createConnection = async (appId: string, appUserId: string, permissions?: string[], options?: { maxAmount?: number, expiresAt?: number }) => {
const secretBytes = generateSecretKey()
const clientPubkey = getPublicKey(secretBytes)
const secret = bytesToHex(secretBytes)
await this.storage.nwcStorage.AddConnection({
app_id: appId,
app_user_id: appUserId,
client_pubkey: clientPubkey,
permissions,
max_amount: options?.maxAmount,
expires_at: options?.expiresAt,
})
const app = await this.storage.applicationStorage.GetApplication(appId)
const relays = this.settings.getSettings().nostrRelaySettings.relays
const relay = relays[0] || ''
const uri = `nostr+walletconnect://${app.nostr_public_key}?relay=${encodeURIComponent(relay)}&secret=${secret}`
return { uri, clientPubkey, secret }
}
listConnections = async (appUserId: string) => {
return this.storage.nwcStorage.GetUserConnections(appUserId)
}
revokeConnection = async (appId: string, clientPubkey: string) => {
return this.storage.nwcStorage.DeleteConnectionByPubkey(appId, clientPubkey)
}
// --- Kind 13194 info event ---
publishNwcInfo = (appId: string, appPubkey: string) => {
const content = SUPPORTED_METHODS.join(' ')
const event: UnsignedEvent = {
content,
created_at: Math.floor(Date.now() / 1000),
kind: NWC_INFO_KIND,
pubkey: appPubkey,
tags: [],
}
this.storage.NostrSender().Send({ type: 'app', appId }, { type: 'event', event })
}
// --- Response helpers ---
private sendResult = (event: NostrEvent, resultType: string, result: Record<string, any>) => {
const response: NwcResponse = { result_type: resultType, result }
const e = newNwcResponse(JSON.stringify(response), event)
this.storage.NostrSender().Send(
{ type: 'app', appId: event.appId },
{ type: 'event', event: e, encrypt: { toPub: event.pub } }
)
}
private sendError = (event: NostrEvent, resultType: string, code: string, message: string) => {
const response: NwcResponse = { result_type: resultType, error: { code, message } }
const e = newNwcResponse(JSON.stringify(response), event)
this.storage.NostrSender().Send(
{ type: 'app', appId: event.appId },
{ type: 'event', event: e, encrypt: { toPub: event.pub } }
)
}
}

View file

@ -48,7 +48,7 @@ const splitContent = (content: string, maxLength: number) => {
}
return parts
}
const actionKinds = [21000, 21001, 21002, 21003]
const actionKinds = [21000, 21001, 21002, 21003, 23194]
const beaconKind = 30078
const appTag = "Lightning.Pub"
export class NostrPool {

View file

@ -21,6 +21,7 @@ import { LndNodeInfo } from "../entity/LndNodeInfo.js"
import { TrackedProvider } from "../entity/TrackedProvider.js"
import { InviteToken } from "../entity/InviteToken.js"
import { DebitAccess } from "../entity/DebitAccess.js"
import { NwcConnection } from "../entity/NwcConnection.js"
import { RootOperation } from "../entity/RootOperation.js"
import { UserOffer } from "../entity/UserOffer.js"
import { ManagementGrant } from "../entity/ManagementGrant.js"
@ -71,6 +72,7 @@ export const MainDbEntities = {
'TrackedProvider': TrackedProvider,
'InviteToken': InviteToken,
'DebitAccess': DebitAccess,
'NwcConnection': NwcConnection,
'UserOffer': UserOffer,
'Product': Product,
'ManagementGrant': ManagementGrant,

View file

@ -0,0 +1,37 @@
import { Entity, PrimaryGeneratedColumn, Column, Index, CreateDateColumn, UpdateDateColumn } from "typeorm"
@Entity()
@Index("unique_nwc_connection", ["app_id", "client_pubkey"], { unique: true })
@Index("idx_nwc_app_user", ["app_user_id"])
export class NwcConnection {
@PrimaryGeneratedColumn()
serial_id: number
@Column()
app_id: string
@Column()
app_user_id: string
@Column()
client_pubkey: string
@Column({ type: 'simple-json', default: null, nullable: true })
permissions: string[] | null
@Column({ default: 0 })
max_amount: number
@Column({ default: 0 })
expires_at: number
@Column({ default: 0 })
total_spent: number
@CreateDateColumn()
created_at: Date
@UpdateDateColumn()
updated_at: Date
}

View file

@ -9,6 +9,7 @@ import MetricsEventStorage from "./tlv/metricsEventStorage.js";
import EventsLogManager from "./eventsLog.js";
import { LiquidityStorage } from "./liquidityStorage.js";
import DebitStorage from "./debitStorage.js"
import NwcStorage from "./nwcStorage.js"
import OfferStorage from "./offerStorage.js"
import { ManagementStorage } from "./managementStorage.js";
import { StorageInterface, TX } from "./db/storageInterface.js";
@ -78,6 +79,7 @@ export default class {
metricsEventStorage: MetricsEventStorage
liquidityStorage: LiquidityStorage
debitStorage: DebitStorage
nwcStorage: NwcStorage
offerStorage: OfferStorage
managementStorage: ManagementStorage
eventsLog: EventsLogManager
@ -103,6 +105,7 @@ export default class {
this.metricsEventStorage = new MetricsEventStorage(this.settings, this.utils.tlvStorageFactory)
this.liquidityStorage = new LiquidityStorage(this.dbs)
this.debitStorage = new DebitStorage(this.dbs)
this.nwcStorage = new NwcStorage(this.dbs)
this.offerStorage = new OfferStorage(this.dbs)
this.managementStorage = new ManagementStorage(this.dbs);
try { if (this.settings.dataDir) fs.mkdirSync(this.settings.dataDir) } catch (e) { }

View file

@ -0,0 +1,17 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class NwcConnection1770000000000 implements MigrationInterface {
name = 'NwcConnection1770000000000'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`CREATE TABLE "nwc_connection" ("serial_id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "app_id" varchar NOT NULL, "app_user_id" varchar NOT NULL, "client_pubkey" varchar NOT NULL, "permissions" text, "max_amount" integer NOT NULL DEFAULT (0), "expires_at" integer NOT NULL DEFAULT (0), "total_spent" integer NOT NULL DEFAULT (0), "created_at" datetime NOT NULL DEFAULT (datetime('now')), "updated_at" datetime NOT NULL DEFAULT (datetime('now')))`);
await queryRunner.query(`CREATE UNIQUE INDEX "unique_nwc_connection" ON "nwc_connection" ("app_id", "client_pubkey") `);
await queryRunner.query(`CREATE INDEX "idx_nwc_app_user" ON "nwc_connection" ("app_user_id") `);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX "idx_nwc_app_user"`);
await queryRunner.query(`DROP INDEX "unique_nwc_connection"`);
await queryRunner.query(`DROP TABLE "nwc_connection"`);
}
}

View file

@ -27,6 +27,7 @@ import { TrackedProviderHeight1766504040000 } from './1766504040000-tracked_prov
import { SwapsServiceUrl1768413055036 } from './1768413055036-swaps_service_url.js'
import { InvoiceSwaps1769529793283 } from './1769529793283-invoice_swaps.js'
import { InvoiceSwapsFixes1769805357459 } from './1769805357459-invoice_swaps_fixes.js'
import { NwcConnection1770000000000 } from './1770000000000-nwc_connection.js'
import { ApplicationUserTopicId1770038768784 } from './1770038768784-application_user_topic_id.js'
import { SwapTimestamps1771347307798 } from './1771347307798-swap_timestamps.js'
import { TxSwapTimestamps1771878683383 } from './1771878683383-tx_swap_timestamps.js'
@ -43,15 +44,13 @@ import { ChannelEvents1750777346411 } from './1750777346411-channel_events.js'
import { RootOpPending1771524665409 } from './1771524665409-root_op_pending.js'
export const allMigrations = [Initial1703170309875, LspOrder1718387847693, LiquidityProvider1719335699480, LndNodeInfo1720187506189,
TrackedProvider1720814323679, CreateInviteTokenTable1721751414878, PaymentIndex1721760297610, DebitAccess1726496225078, DebitAccessFixes1726685229264,
DebitToPub1727105758354, UserCbUrl1727112281043, UserOffer1733502626042, ManagementGrant1751307732346, ManagementGrantBanned1751989251513,
InvoiceCallbackUrls1752425992291, OldSomethingLeftover1753106599604, UserReceivingInvoiceIdx1753109184611, AppUserDevice1753285173175,
UserAccess1759426050669, AddBlindToUserOffer1760000000000, ApplicationAvatarUrl1761000001000, AdminSettings1761683639419, TxSwap1762890527098,
TxSwapAddress1764779178945, ClinkRequester1765497600000, TrackedProviderHeight1766504040000, SwapsServiceUrl1768413055036,
InvoiceSwaps1769529793283, InvoiceSwapsFixes1769805357459, ApplicationUserTopicId1770038768784, SwapTimestamps1771347307798,
InvoiceSwaps1769529793283, InvoiceSwapsFixes1769805357459, NwcConnection1770000000000, ApplicationUserTopicId1770038768784, SwapTimestamps1771347307798,
TxSwapTimestamps1771878683383, RefundSwapInfo1773082318982]

View file

@ -0,0 +1,49 @@
import { NwcConnection } from "./entity/NwcConnection.js";
import { StorageInterface } from "./db/storageInterface.js";
type ConnectionToAdd = {
app_id: string
app_user_id: string
client_pubkey: string
permissions?: string[]
max_amount?: number
expires_at?: number
}
export default class {
dbs: StorageInterface
constructor(dbs: StorageInterface) {
this.dbs = dbs
}
async AddConnection(connection: ConnectionToAdd) {
return this.dbs.CreateAndSave<NwcConnection>('NwcConnection', {
app_id: connection.app_id,
app_user_id: connection.app_user_id,
client_pubkey: connection.client_pubkey,
permissions: connection.permissions || null,
max_amount: connection.max_amount || 0,
expires_at: connection.expires_at || 0,
})
}
async GetConnection(appId: string, clientPubkey: string, txId?: string) {
return this.dbs.FindOne<NwcConnection>('NwcConnection', { where: { app_id: appId, client_pubkey: clientPubkey } }, txId)
}
async GetUserConnections(appUserId: string, txId?: string) {
return this.dbs.Find<NwcConnection>('NwcConnection', { where: { app_user_id: appUserId } }, txId)
}
async DeleteConnection(serialId: number, txId?: string) {
return this.dbs.Delete<NwcConnection>('NwcConnection', { serial_id: serialId }, txId)
}
async DeleteConnectionByPubkey(appId: string, clientPubkey: string, txId?: string) {
return this.dbs.Delete<NwcConnection>('NwcConnection', { app_id: appId, client_pubkey: clientPubkey }, txId)
}
async IncrementTotalSpent(appId: string, clientPubkey: string, amount: number, txId?: string) {
return this.dbs.Increment<NwcConnection>('NwcConnection', { app_id: appId, client_pubkey: clientPubkey }, 'total_spent', amount, txId)
}
}