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 <noreply@anthropic.com>
This commit is contained in:
Patrick Mulligan 2026-02-16 16:56:21 -05:00
parent 03f78c0362
commit 121c22c4b8
4 changed files with 26 additions and 6 deletions

View file

@ -114,8 +114,8 @@ export default class WithdrawExtension implements Extension {
*/ */
private registerRpcMethods(ctx: ExtensionContext): void { private registerRpcMethods(ctx: ExtensionContext): void {
// Create withdraw link // Create withdraw link
ctx.registerMethod('withdraw.createLink', async (req, appId) => { ctx.registerMethod('withdraw.createLink', async (req, appId, userPubkey) => {
const link = await this.manager.create(appId, req as CreateWithdrawLinkRequest) const link = await this.manager.create(appId, req as CreateWithdrawLinkRequest, userPubkey)
const stats = await this.manager.getWithdrawalStats(link.id) const stats = await this.manager.getWithdrawalStats(link.id)
return { return {
link, link,

View file

@ -48,6 +48,7 @@ interface WithdrawLinkRow {
is_unique: number is_unique: number
uses_csv: string uses_csv: string
open_time: number open_time: number
creator_pubkey: string | null
webhook_url: string | null webhook_url: string | null
webhook_headers: string | null webhook_headers: string | null
webhook_body: string | null webhook_body: string | null
@ -87,6 +88,7 @@ function rowToLink(row: WithdrawLinkRow): WithdrawLink {
is_unique: row.is_unique === 1, is_unique: row.is_unique === 1,
uses_csv: row.uses_csv, uses_csv: row.uses_csv,
open_time: row.open_time, open_time: row.open_time,
creator_pubkey: row.creator_pubkey || undefined,
webhook_url: row.webhook_url || undefined, webhook_url: row.webhook_url || undefined,
webhook_headers: row.webhook_headers || undefined, webhook_headers: row.webhook_headers || undefined,
webhook_body: row.webhook_body || undefined, webhook_body: row.webhook_body || undefined,
@ -150,7 +152,7 @@ export class WithdrawManager {
/** /**
* Create a new withdraw link * Create a new withdraw link
*/ */
async create(applicationId: string, req: CreateWithdrawLinkRequest): Promise<WithdrawLinkWithLnurl> { async create(applicationId: string, req: CreateWithdrawLinkRequest, creatorPubkey?: string): Promise<WithdrawLinkWithLnurl> {
// Validation // Validation
if (req.uses < 1 || req.uses > 250) { if (req.uses < 1 || req.uses > 250) {
throw new Error('Uses must be between 1 and 250') throw new Error('Uses must be between 1 and 250')
@ -200,6 +202,7 @@ export class WithdrawManager {
is_unique: req.is_unique || false, is_unique: req.is_unique || false,
uses_csv: usesCsv, uses_csv: usesCsv,
open_time: now, open_time: now,
creator_pubkey: creatorPubkey,
webhook_url: req.webhook_url, webhook_url: req.webhook_url,
webhook_headers: req.webhook_headers, webhook_headers: req.webhook_headers,
webhook_body: req.webhook_body, webhook_body: req.webhook_body,
@ -212,13 +215,15 @@ export class WithdrawManager {
id, application_id, title, description, id, application_id, title, description,
min_withdrawable, max_withdrawable, uses, used, wait_time, min_withdrawable, max_withdrawable, uses, used, wait_time,
unique_hash, k1, is_unique, uses_csv, open_time, unique_hash, k1, is_unique, uses_csv, open_time,
creator_pubkey,
webhook_url, webhook_headers, webhook_body, webhook_url, webhook_headers, webhook_body,
created_at, updated_at created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[ [
link.id, link.application_id, link.title, link.description || null, link.id, link.application_id, link.title, link.description || null,
link.min_withdrawable, link.max_withdrawable, link.uses, link.used, link.wait_time, 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.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.webhook_url || null, link.webhook_headers || null, link.webhook_body || null,
link.created_at, link.updated_at link.created_at, link.updated_at
] ]
@ -502,11 +507,12 @@ export class WithdrawManager {
} }
try { try {
// Pay the invoice // Pay the invoice from the creator's balance (if created via Nostr RPC)
const payment = await this.ctx.payInvoice( const payment = await this.ctx.payInvoice(
link.application_id, link.application_id,
params.pr, params.pr,
link.max_withdrawable link.max_withdrawable,
link.creator_pubkey
) )
// Record the withdrawal // Record the withdrawal

View file

@ -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
`)
}
} }
] ]

View file

@ -46,6 +46,9 @@ export interface WithdrawLink {
// Rate limiting // Rate limiting
open_time: number // Unix timestamp when next use is allowed 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 notifications
webhook_url?: string webhook_url?: string
webhook_headers?: string // JSON string webhook_headers?: string // JSON string