diff --git a/src/nostrMiddleware.ts b/src/nostrMiddleware.ts index 034dbce8..250543c7 100644 --- a/src/nostrMiddleware.ts +++ b/src/nostrMiddleware.ts @@ -79,6 +79,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') { diff --git a/src/services/main/index.ts b/src/services/main/index.ts index 3d885681..df943d62 100644 --- a/src/services/main/index.ts +++ b/src/services/main/index.ts @@ -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 @@ -88,6 +90,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) } @@ -103,6 +106,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) + } }) } @@ -504,6 +510,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] diff --git a/src/services/main/nwcManager.ts b/src/services/main/nwcManager.ts new file mode 100644 index 00000000..d1800b1c --- /dev/null +++ b/src/services/main/nwcManager.ts @@ -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 +} + +type NwcResponse = { + result_type: string + error?: { code: string, message: string } + result?: Record +} + +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, 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, 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) => { + 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, 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) => { + 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 } } + ) + } +} diff --git a/src/services/nostr/nostrPool.ts b/src/services/nostr/nostrPool.ts index d41da382..1d3c0e5e 100644 --- a/src/services/nostr/nostrPool.ts +++ b/src/services/nostr/nostrPool.ts @@ -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 { diff --git a/src/services/storage/db/db.ts b/src/services/storage/db/db.ts index 335f6848..ed129e03 100644 --- a/src/services/storage/db/db.ts +++ b/src/services/storage/db/db.ts @@ -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" @@ -70,6 +71,7 @@ export const MainDbEntities = { 'TrackedProvider': TrackedProvider, 'InviteToken': InviteToken, 'DebitAccess': DebitAccess, + 'NwcConnection': NwcConnection, 'UserOffer': UserOffer, 'Product': Product, 'ManagementGrant': ManagementGrant, diff --git a/src/services/storage/entity/NwcConnection.ts b/src/services/storage/entity/NwcConnection.ts new file mode 100644 index 00000000..628b49a2 --- /dev/null +++ b/src/services/storage/entity/NwcConnection.ts @@ -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 +} diff --git a/src/services/storage/index.ts b/src/services/storage/index.ts index fea4c8c9..e7741c84 100644 --- a/src/services/storage/index.ts +++ b/src/services/storage/index.ts @@ -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) { } diff --git a/src/services/storage/migrations/1770000000000-nwc_connection.ts b/src/services/storage/migrations/1770000000000-nwc_connection.ts new file mode 100644 index 00000000..f5644b86 --- /dev/null +++ b/src/services/storage/migrations/1770000000000-nwc_connection.ts @@ -0,0 +1,17 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class NwcConnection1770000000000 implements MigrationInterface { + name = 'NwcConnection1770000000000' + + public async up(queryRunner: QueryRunner): Promise { + 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 { + await queryRunner.query(`DROP INDEX "idx_nwc_app_user"`); + await queryRunner.query(`DROP INDEX "unique_nwc_connection"`); + await queryRunner.query(`DROP TABLE "nwc_connection"`); + } +} diff --git a/src/services/storage/migrations/runner.ts b/src/services/storage/migrations/runner.ts index d14b8381..5a8aba94 100644 --- a/src/services/storage/migrations/runner.ts +++ b/src/services/storage/migrations/runner.ts @@ -32,6 +32,7 @@ import { TxSwapAddress1764779178945 } from './1764779178945-tx_swap_address.js' import { ClinkRequester1765497600000 } from './1765497600000-clink_requester.js' import { TrackedProviderHeight1766504040000 } from './1766504040000-tracked_provider_height.js' import { SwapsServiceUrl1768413055036 } from './1768413055036-swaps_service_url.js' +import { NwcConnection1770000000000 } from './1770000000000-nwc_connection.js' export const allMigrations = [Initial1703170309875, LspOrder1718387847693, LiquidityProvider1719335699480, LndNodeInfo1720187506189, @@ -39,7 +40,7 @@ export const allMigrations = [Initial1703170309875, LspOrder1718387847693, Liqui DebitToPub1727105758354, UserCbUrl1727112281043, UserOffer1733502626042, ManagementGrant1751307732346, ManagementGrantBanned1751989251513, InvoiceCallbackUrls1752425992291, OldSomethingLeftover1753106599604, UserReceivingInvoiceIdx1753109184611, AppUserDevice1753285173175, UserAccess1759426050669, AddBlindToUserOffer1760000000000, ApplicationAvatarUrl1761000001000, AdminSettings1761683639419, TxSwap1762890527098, - TxSwapAddress1764779178945, ClinkRequester1765497600000, TrackedProviderHeight1766504040000, SwapsServiceUrl1768413055036] + TxSwapAddress1764779178945, ClinkRequester1765497600000, TrackedProviderHeight1766504040000, SwapsServiceUrl1768413055036, NwcConnection1770000000000] export const allMetricsMigrations = [LndMetrics1703170330183, ChannelRouting1709316653538, HtlcCount1724266887195, BalanceEvents1724860966825, RootOps1732566440447, RootOpsTime1745428134124, ChannelEvents1750777346411] /* export const TypeOrmMigrationRunner = async (log: PubLogger, storageManager: Storage, settings: DbSettings, arg: string | undefined): Promise => { diff --git a/src/services/storage/nwcStorage.ts b/src/services/storage/nwcStorage.ts new file mode 100644 index 00000000..73aaad17 --- /dev/null +++ b/src/services/storage/nwcStorage.ts @@ -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', { + 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', { where: { app_id: appId, client_pubkey: clientPubkey } }, txId) + } + + async GetUserConnections(appUserId: string, txId?: string) { + return this.dbs.Find('NwcConnection', { where: { app_user_id: appUserId } }, txId) + } + + async DeleteConnection(serialId: number, txId?: string) { + return this.dbs.Delete('NwcConnection', { serial_id: serialId }, txId) + } + + async DeleteConnectionByPubkey(appId: string, clientPubkey: string, txId?: string) { + return this.dbs.Delete('NwcConnection', { app_id: appId, client_pubkey: clientPubkey }, txId) + } + + async IncrementTotalSpent(appId: string, clientPubkey: string, amount: number, txId?: string) { + return this.dbs.Increment('NwcConnection', { app_id: appId, client_pubkey: clientPubkey }, 'total_spent', amount, txId) + } +}