From f7c06dec452aeb63af3fa5839c768b85b37e3078 Mon Sep 17 00:00:00 2001 From: Patrick Mulligan Date: Mon, 16 Feb 2026 16:56:21 -0500 Subject: [PATCH] feat(withdraw): track creator pubkey on withdraw links Store the Nostr pubkey of the user who creates a withdraw link so the LNURL callback debits the correct user's balance instead of the app owner's. Pass userPubkey through from RPC handler to WithdrawManager. - Add creator_pubkey column (migration v4) - Store creatorPubkey on link creation - Pass creator_pubkey to payInvoice on LNURL callback Co-Authored-By: Claude Opus 4.6 --- src/extensions/withdraw/index.ts | 4 ++-- .../withdraw/managers/withdrawManager.ts | 14 ++++++++++---- src/extensions/withdraw/migrations.ts | 11 +++++++++++ src/extensions/withdraw/types.ts | 3 +++ 4 files changed, 26 insertions(+), 6 deletions(-) diff --git a/src/extensions/withdraw/index.ts b/src/extensions/withdraw/index.ts index dacf045e..1a38930b 100644 --- a/src/extensions/withdraw/index.ts +++ b/src/extensions/withdraw/index.ts @@ -114,8 +114,8 @@ export default class WithdrawExtension implements Extension { */ private registerRpcMethods(ctx: ExtensionContext): void { // Create withdraw link - ctx.registerMethod('withdraw.createLink', async (req, appId) => { - const link = await this.manager.create(appId, req as CreateWithdrawLinkRequest) + ctx.registerMethod('withdraw.createLink', async (req, appId, userPubkey) => { + const link = await this.manager.create(appId, req as CreateWithdrawLinkRequest, userPubkey) const stats = await this.manager.getWithdrawalStats(link.id) return { link, diff --git a/src/extensions/withdraw/managers/withdrawManager.ts b/src/extensions/withdraw/managers/withdrawManager.ts index 91677f82..5f76008e 100644 --- a/src/extensions/withdraw/managers/withdrawManager.ts +++ b/src/extensions/withdraw/managers/withdrawManager.ts @@ -48,6 +48,7 @@ interface WithdrawLinkRow { is_unique: number uses_csv: string open_time: number + creator_pubkey: string | null webhook_url: string | null webhook_headers: string | null webhook_body: string | null @@ -87,6 +88,7 @@ function rowToLink(row: WithdrawLinkRow): WithdrawLink { is_unique: row.is_unique === 1, uses_csv: row.uses_csv, open_time: row.open_time, + creator_pubkey: row.creator_pubkey || undefined, webhook_url: row.webhook_url || undefined, webhook_headers: row.webhook_headers || undefined, webhook_body: row.webhook_body || undefined, @@ -150,7 +152,7 @@ export class WithdrawManager { /** * Create a new withdraw link */ - async create(applicationId: string, req: CreateWithdrawLinkRequest): Promise { + async create(applicationId: string, req: CreateWithdrawLinkRequest, creatorPubkey?: string): Promise { // Validation if (req.uses < 1 || req.uses > 250) { throw new Error('Uses must be between 1 and 250') @@ -200,6 +202,7 @@ export class WithdrawManager { is_unique: req.is_unique || false, uses_csv: usesCsv, open_time: now, + creator_pubkey: creatorPubkey, webhook_url: req.webhook_url, webhook_headers: req.webhook_headers, webhook_body: req.webhook_body, @@ -212,13 +215,15 @@ export class WithdrawManager { id, application_id, title, description, min_withdrawable, max_withdrawable, uses, used, wait_time, unique_hash, k1, is_unique, uses_csv, open_time, + creator_pubkey, webhook_url, webhook_headers, webhook_body, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ link.id, link.application_id, link.title, link.description || null, link.min_withdrawable, link.max_withdrawable, link.uses, link.used, link.wait_time, link.unique_hash, link.k1, link.is_unique ? 1 : 0, link.uses_csv, link.open_time, + link.creator_pubkey || null, link.webhook_url || null, link.webhook_headers || null, link.webhook_body || null, link.created_at, link.updated_at ] @@ -502,11 +507,12 @@ export class WithdrawManager { } try { - // Pay the invoice + // Pay the invoice from the creator's balance (if created via Nostr RPC) const payment = await this.ctx.payInvoice( link.application_id, params.pr, - link.max_withdrawable + link.max_withdrawable, + link.creator_pubkey ) // Record the withdrawal diff --git a/src/extensions/withdraw/migrations.ts b/src/extensions/withdraw/migrations.ts index 86886885..1625638a 100644 --- a/src/extensions/withdraw/migrations.ts +++ b/src/extensions/withdraw/migrations.ts @@ -122,6 +122,17 @@ export const migrations: Migration[] = [ ) `) } + }, + { + version: 4, + name: 'add_creator_pubkey_column', + up: async (db: ExtensionDatabase) => { + // Store the Nostr pubkey of the user who created the withdraw link + // so that when the LNURL callback fires, we debit the correct user's balance + await db.execute(` + ALTER TABLE withdraw_links ADD COLUMN creator_pubkey TEXT + `) + } } ] diff --git a/src/extensions/withdraw/types.ts b/src/extensions/withdraw/types.ts index d9d05da0..88d25f33 100644 --- a/src/extensions/withdraw/types.ts +++ b/src/extensions/withdraw/types.ts @@ -46,6 +46,9 @@ export interface WithdrawLink { // Rate limiting open_time: number // Unix timestamp when next use is allowed + // Creator identity (for Nostr RPC-created links) + creator_pubkey?: string // Nostr pubkey of the user who created this link + // Webhook notifications webhook_url?: string webhook_headers?: string // JSON string