From e69512748deaa5b7e0cdbfc9ae281714a14179f9 Mon Sep 17 00:00:00 2001 From: shocknet-justin Date: Fri, 12 Dec 2025 14:10:37 -0500 Subject: [PATCH] feat: noffer receipts --- src/services/main/applicationManager.ts | 7 ++-- src/services/main/index.ts | 41 +++++++++++++++++-- src/services/main/offerManager.ts | 24 +++++++---- .../storage/entity/UserReceivingInvoice.ts | 12 ++++++ .../1765354000000-clink_requester.ts | 15 +++++++ src/services/storage/paymentStorage.ts | 7 ++-- 6 files changed, 89 insertions(+), 17 deletions(-) create mode 100644 src/services/storage/migrations/1765354000000-clink_requester.ts diff --git a/src/services/main/applicationManager.ts b/src/services/main/applicationManager.ts index 76ae76db..14646746 100644 --- a/src/services/main/applicationManager.ts +++ b/src/services/main/applicationManager.ts @@ -7,7 +7,7 @@ import { ApplicationUser } from '../storage/entity/ApplicationUser.js' import { PubLogger, getLogger } from '../helpers/logger.js' import crypto from 'crypto' import { Application } from '../storage/entity/Application.js' -import { ZapInfo } from '../storage/entity/UserReceivingInvoice.js' +import { ZapInfo, ClinkRequester } from '../storage/entity/UserReceivingInvoice.js' import { nofferEncode, ndebitEncode, OfferPriceType, nmanageEncode } from '@shocknet/clink-sdk' import SettingsManager from './settingsManager.js' const TOKEN_EXPIRY_TIME = 2 * 60 * 1000 // 2 minutes, in milliseconds @@ -185,7 +185,7 @@ export default class { return invoice } - async AddAppUserInvoice(appId: string, req: Types.AddAppUserInvoiceRequest): Promise { + async AddAppUserInvoice(appId: string, req: Types.AddAppUserInvoiceRequest, clinkRequester?: ClinkRequester): Promise { const app = await this.storage.applicationStorage.GetApplication(appId) const log = getLogger({ appName: app.name }) const receiver = await this.storage.applicationStorage.GetApplicationUser(app, req.receiver_identifier) @@ -200,7 +200,8 @@ export default class { callbackUrl: cbUrl, expiry: expiry, expectedPayer: payer.user, linkedApplication: app, zapInfo, offerId: req.offer_string, payerData: req.payer_data?.data, rejectUnauthorized: req.rejectUnauthorized, token: req.token, - blind: req.invoice_req.blind + blind: req.invoice_req.blind, + clinkRequester } const appUserInvoice = await this.paymentManager.NewInvoice(receiver.user.user_id, req.invoice_req, opts) return { diff --git a/src/services/main/index.ts b/src/services/main/index.ts index 00f65a68..41eb2e9b 100644 --- a/src/services/main/index.ts +++ b/src/services/main/index.ts @@ -10,11 +10,10 @@ import { AddressPaidCb, ChannelEventCb, HtlcCb, InvoicePaidCb, NewBlockCb } from import { ERROR, getLogger, PubLogger } from "../helpers/logger.js" import AppUserManager from "./appUserManager.js" import { Application } from '../storage/entity/Application.js' -import { UserReceivingInvoice, ZapInfo } from '../storage/entity/UserReceivingInvoice.js' +import { UserReceivingInvoice } from '../storage/entity/UserReceivingInvoice.js' import { UnsignedEvent } from 'nostr-tools' -import { NostrEvent, NostrSend } from '../nostr/handler.js' +import { NostrSend } from '../nostr/handler.js' import MetricsManager from '../metrics/index.js' -import { LoggedEvent } from '../storage/eventsLog.js' import { LiquidityProvider } from "./liquidityProvider.js" import { LiquidityManager } from "./liquidityManager.js" import { Utils } from "../helpers/utilsWrapper.js" @@ -291,6 +290,14 @@ export default class { } catch (err: any) { log(ERROR, "cannot create zap receipt", err.message || "") } + // Send CLINK receipt if this invoice was from a noffer request + try { + if (userInvoice.clink_requester) { + this.createClinkReceipt(log, userInvoice) + } + } catch (err: any) { + log(ERROR, "cannot create clink receipt", err.message || "") + } this.liquidityManager.afterInInvoicePaid() this.utils.stateBundler.AddTxPoint('invoiceWasPaid', amount, { used, from: 'system', timeDiscount: true }, userInvoice.linkedApplication.app_id) } catch (err: any) { @@ -432,6 +439,34 @@ export default class { this.nostrSend({ type: 'app', appId: invoice.linkedApplication.app_id }, { type: 'event', event }, zapInfo.relays || undefined) } + async createClinkReceipt(log: PubLogger, invoice: UserReceivingInvoice) { + const clinkRequester = invoice.clink_requester + if (!clinkRequester || !invoice.linkedApplication) { + return + } + log("📤 [CLINK RECEIPT] Sending payment receipt", { + toPub: clinkRequester.pub, + eventId: clinkRequester.eventId + }) + // Receipt payload - payer's wallet already has the preimage + const content = JSON.stringify({ res: 'ok' }) + const event: UnsignedEvent = { + content, + created_at: Math.floor(Date.now() / 1000), + kind: 21001, + pubkey: "", + tags: [ + ["p", clinkRequester.pub], + ["e", clinkRequester.eventId], + ["clink_version", "1"] + ], + } + this.nostrSend( + { type: 'app', appId: clinkRequester.appId }, + { type: 'event', event, encrypt: { toPub: clinkRequester.pub } } + ) + } + async ResetNostr() { const apps = await this.storage.applicationStorage.GetApplications() const nextRelay = this.settings.getSettings().nostrRelaySettings.relays[0] diff --git a/src/services/main/offerManager.ts b/src/services/main/offerManager.ts index bbc3b64d..9f3b92f3 100644 --- a/src/services/main/offerManager.ts +++ b/src/services/main/offerManager.ts @@ -10,6 +10,7 @@ import { UserOffer } from '../storage/entity/UserOffer.js'; import { LiquidityManager } from "./liquidityManager.js" import { NofferData, OfferPriceType, nofferEncode } from '@shocknet/clink-sdk'; import SettingsManager from "./settingsManager.js"; +import { ClinkRequester } from '../storage/entity/UserReceivingInvoice.js'; const mapToOfferConfig = (appUserId: string, offer: UserOffer, { pubkey, relay }: { pubkey: string, relay: string }): Types.OfferConfig => { const offerStr = offer.offer_id @@ -164,7 +165,14 @@ export class OfferManager { payerData: offerReq.payer_data }) - const offerInvoice = await this.getNofferInvoice(offerReq, event.appId) + // Store requester info for sending receipt when invoice is paid + const clinkRequester: ClinkRequester = { + pub: event.pub, + eventId: event.id, + appId: event.appId + } + + const offerInvoice = await this.getNofferInvoice(offerReq, event.appId, clinkRequester) if (!offerInvoice.success) { const code = offerInvoice.code @@ -198,7 +206,7 @@ export class OfferManager { return } - async HandleDefaultUserOffer(offerReq: NofferData, appId: string, remote: number, { memo, expiry }: { memo?: string, expiry?: number }): Promise<{ success: true, invoice: string } | { success: false, code: number, max: number }> { + async HandleDefaultUserOffer(offerReq: NofferData, appId: string, remote: number, { memo, expiry }: { memo?: string, expiry?: number }, clinkRequester?: ClinkRequester): Promise<{ success: true, invoice: string } | { success: false, code: number, max: number }> { const { amount_sats: amount, offer } = offerReq if (!amount || isNaN(amount) || amount < 10 || amount > remote) { return { success: false, code: 5, max: remote } @@ -207,17 +215,17 @@ export class OfferManager { http_callback_url: "", payer_identifier: offer, receiver_identifier: offer, invoice_req: { amountSats: amount, memo: memo || "Default CLINK Offer", zap: offerReq.zap, expiry }, offer_string: 'offer' - }) + }, clinkRequester) return { success: true, invoice: res.invoice } } - async HandleUserOffer(offerReq: NofferData, appId: string, remote: number): Promise<{ success: true, invoice: string } | { success: false, code: number, max: number }> { + async HandleUserOffer(offerReq: NofferData, appId: string, remote: number, clinkRequester?: ClinkRequester): Promise<{ success: true, invoice: string } | { success: false, code: number, max: number }> { const { amount_sats: amount, offer } = offerReq const userOffer = await this.storage.offerStorage.GetOffer(offer) const expiry = offerReq.expires_in_seconds ? offerReq.expires_in_seconds : undefined if (!userOffer) { - return this.HandleDefaultUserOffer(offerReq, appId, remote, { memo: offerReq.description, expiry }) + return this.HandleDefaultUserOffer(offerReq, appId, remote, { memo: offerReq.description, expiry }, clinkRequester) } if (userOffer.app_user_id === userOffer.offer_id) { if (userOffer.price_sats !== 0 || userOffer.payer_data) { @@ -250,11 +258,11 @@ export class OfferManager { offer_string: offer, rejectUnauthorized: userOffer.rejectUnauthorized, token: userOffer.bearer_token - }) + }, clinkRequester) return { success: true, invoice: res.invoice } } - async getNofferInvoice(offerReq: NofferData, appId: string): Promise<{ success: true, invoice: string } | { success: false, code: number, max: number }> { + async getNofferInvoice(offerReq: NofferData, appId: string, clinkRequester?: ClinkRequester): Promise<{ success: true, invoice: string } | { success: false, code: number, max: number }> { try { const { remote } = await this.lnd.ChannelBalance() let maxSendable = remote @@ -263,7 +271,7 @@ export class OfferManager { } const split = offerReq.offer.split(':') if (split.length === 1) { - return this.HandleUserOffer(offerReq, appId, maxSendable) + return this.HandleUserOffer(offerReq, appId, maxSendable, clinkRequester) } else if (split[0] === 'p') { const product = await this.productManager.NewProductInvoice(split[1]) return { success: true, invoice: product.invoice } diff --git a/src/services/storage/entity/UserReceivingInvoice.ts b/src/services/storage/entity/UserReceivingInvoice.ts index f8fb7001..3d6b7056 100644 --- a/src/services/storage/entity/UserReceivingInvoice.ts +++ b/src/services/storage/entity/UserReceivingInvoice.ts @@ -8,6 +8,12 @@ export type ZapInfo = { relays: string[] description: string } + +export type ClinkRequester = { + pub: string // requester's pubkey + eventId: string // original request event ID + appId: string // app context for nostrSend +} @Entity() @Index("recv_invoice_paid_serial", ["user.serial_id", "paid_at_unix", "serial_id"], { where: "paid_at_unix > 0" }) export class UserReceivingInvoice { @@ -80,6 +86,12 @@ export class UserReceivingInvoice { }) liquidityProvider?: string + @Column({ + nullable: true, + type: 'simple-json' + }) + clink_requester?: ClinkRequester + @CreateDateColumn() created_at: Date diff --git a/src/services/storage/migrations/1765354000000-clink_requester.ts b/src/services/storage/migrations/1765354000000-clink_requester.ts new file mode 100644 index 00000000..01ee2030 --- /dev/null +++ b/src/services/storage/migrations/1765354000000-clink_requester.ts @@ -0,0 +1,15 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class ClinkRequester1765354000000 implements MigrationInterface { + name = 'ClinkRequester1765354000000' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "user_receiving_invoice" ADD COLUMN "clink_requester" text`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "user_receiving_invoice" DROP COLUMN "clink_requester"`); + } + +} + diff --git a/src/services/storage/paymentStorage.ts b/src/services/storage/paymentStorage.ts index ef29957c..52a20613 100644 --- a/src/services/storage/paymentStorage.ts +++ b/src/services/storage/paymentStorage.ts @@ -3,7 +3,7 @@ import { And, Between, Equal, FindOperator, IsNull, LessThan, LessThanOrEqual, M import { User } from './entity/User.js'; import { UserTransactionPayment } from './entity/UserTransactionPayment.js'; import { EphemeralKeyType, UserEphemeralKey } from './entity/UserEphemeralKey.js'; -import { UserReceivingInvoice, ZapInfo } from './entity/UserReceivingInvoice.js'; +import { UserReceivingInvoice, ZapInfo, ClinkRequester } from './entity/UserReceivingInvoice.js'; import { UserReceivingAddress } from './entity/UserReceivingAddress.js'; import { Product } from './entity/Product.js'; import UserStorage from './userStorage.js'; @@ -14,7 +14,7 @@ import { Application } from './entity/Application.js'; import TransactionsQueue from "./db/transactionsQueue.js"; import { LoggedEvent } from './eventsLog.js'; import { StorageInterface } from './db/storageInterface.js'; -export type InboundOptionals = { product?: Product, callbackUrl?: string, expiry: number, expectedPayer?: User, linkedApplication?: Application, zapInfo?: ZapInfo, offerId?: string, payerData?: Record, rejectUnauthorized?: boolean, token?: string, blind?: boolean } +export type InboundOptionals = { product?: Product, callbackUrl?: string, expiry: number, expectedPayer?: User, linkedApplication?: Application, zapInfo?: ZapInfo, offerId?: string, payerData?: Record, rejectUnauthorized?: boolean, token?: string, blind?: boolean, clinkRequester?: ClinkRequester } export const defaultInvoiceExpiry = 60 * 60 export default class { dbs: StorageInterface @@ -129,7 +129,8 @@ export default class { offer_id: options.offerId, payer_data: options.payerData, rejectUnauthorized: options.rejectUnauthorized, - bearer_token: options.token + bearer_token: options.token, + clink_requester: options.clinkRequester }, txId) }