feat: noffer receipts
This commit is contained in:
parent
9b2c71cf16
commit
e69512748d
6 changed files with 89 additions and 17 deletions
|
|
@ -7,7 +7,7 @@ import { ApplicationUser } from '../storage/entity/ApplicationUser.js'
|
||||||
import { PubLogger, getLogger } from '../helpers/logger.js'
|
import { PubLogger, getLogger } from '../helpers/logger.js'
|
||||||
import crypto from 'crypto'
|
import crypto from 'crypto'
|
||||||
import { Application } from '../storage/entity/Application.js'
|
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 { nofferEncode, ndebitEncode, OfferPriceType, nmanageEncode } from '@shocknet/clink-sdk'
|
||||||
import SettingsManager from './settingsManager.js'
|
import SettingsManager from './settingsManager.js'
|
||||||
const TOKEN_EXPIRY_TIME = 2 * 60 * 1000 // 2 minutes, in milliseconds
|
const TOKEN_EXPIRY_TIME = 2 * 60 * 1000 // 2 minutes, in milliseconds
|
||||||
|
|
@ -185,7 +185,7 @@ export default class {
|
||||||
return invoice
|
return invoice
|
||||||
}
|
}
|
||||||
|
|
||||||
async AddAppUserInvoice(appId: string, req: Types.AddAppUserInvoiceRequest): Promise<Types.NewInvoiceResponse> {
|
async AddAppUserInvoice(appId: string, req: Types.AddAppUserInvoiceRequest, clinkRequester?: ClinkRequester): Promise<Types.NewInvoiceResponse> {
|
||||||
const app = await this.storage.applicationStorage.GetApplication(appId)
|
const app = await this.storage.applicationStorage.GetApplication(appId)
|
||||||
const log = getLogger({ appName: app.name })
|
const log = getLogger({ appName: app.name })
|
||||||
const receiver = await this.storage.applicationStorage.GetApplicationUser(app, req.receiver_identifier)
|
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,
|
callbackUrl: cbUrl, expiry: expiry, expectedPayer: payer.user, linkedApplication: app, zapInfo,
|
||||||
offerId: req.offer_string, payerData: req.payer_data?.data, rejectUnauthorized: req.rejectUnauthorized,
|
offerId: req.offer_string, payerData: req.payer_data?.data, rejectUnauthorized: req.rejectUnauthorized,
|
||||||
token: req.token,
|
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)
|
const appUserInvoice = await this.paymentManager.NewInvoice(receiver.user.user_id, req.invoice_req, opts)
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -10,11 +10,10 @@ import { AddressPaidCb, ChannelEventCb, HtlcCb, InvoicePaidCb, NewBlockCb } from
|
||||||
import { ERROR, getLogger, PubLogger } from "../helpers/logger.js"
|
import { ERROR, getLogger, PubLogger } from "../helpers/logger.js"
|
||||||
import AppUserManager from "./appUserManager.js"
|
import AppUserManager from "./appUserManager.js"
|
||||||
import { Application } from '../storage/entity/Application.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 { UnsignedEvent } from 'nostr-tools'
|
||||||
import { NostrEvent, NostrSend } from '../nostr/handler.js'
|
import { NostrSend } from '../nostr/handler.js'
|
||||||
import MetricsManager from '../metrics/index.js'
|
import MetricsManager from '../metrics/index.js'
|
||||||
import { LoggedEvent } from '../storage/eventsLog.js'
|
|
||||||
import { LiquidityProvider } from "./liquidityProvider.js"
|
import { LiquidityProvider } from "./liquidityProvider.js"
|
||||||
import { LiquidityManager } from "./liquidityManager.js"
|
import { LiquidityManager } from "./liquidityManager.js"
|
||||||
import { Utils } from "../helpers/utilsWrapper.js"
|
import { Utils } from "../helpers/utilsWrapper.js"
|
||||||
|
|
@ -291,6 +290,14 @@ export default class {
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
log(ERROR, "cannot create zap receipt", err.message || "")
|
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.liquidityManager.afterInInvoicePaid()
|
||||||
this.utils.stateBundler.AddTxPoint('invoiceWasPaid', amount, { used, from: 'system', timeDiscount: true }, userInvoice.linkedApplication.app_id)
|
this.utils.stateBundler.AddTxPoint('invoiceWasPaid', amount, { used, from: 'system', timeDiscount: true }, userInvoice.linkedApplication.app_id)
|
||||||
} catch (err: any) {
|
} 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)
|
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() {
|
async ResetNostr() {
|
||||||
const apps = await this.storage.applicationStorage.GetApplications()
|
const apps = await this.storage.applicationStorage.GetApplications()
|
||||||
const nextRelay = this.settings.getSettings().nostrRelaySettings.relays[0]
|
const nextRelay = this.settings.getSettings().nostrRelaySettings.relays[0]
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import { UserOffer } from '../storage/entity/UserOffer.js';
|
||||||
import { LiquidityManager } from "./liquidityManager.js"
|
import { LiquidityManager } from "./liquidityManager.js"
|
||||||
import { NofferData, OfferPriceType, nofferEncode } from '@shocknet/clink-sdk';
|
import { NofferData, OfferPriceType, nofferEncode } from '@shocknet/clink-sdk';
|
||||||
import SettingsManager from "./settingsManager.js";
|
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 mapToOfferConfig = (appUserId: string, offer: UserOffer, { pubkey, relay }: { pubkey: string, relay: string }): Types.OfferConfig => {
|
||||||
const offerStr = offer.offer_id
|
const offerStr = offer.offer_id
|
||||||
|
|
@ -164,7 +165,14 @@ export class OfferManager {
|
||||||
payerData: offerReq.payer_data
|
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) {
|
if (!offerInvoice.success) {
|
||||||
const code = offerInvoice.code
|
const code = offerInvoice.code
|
||||||
|
|
@ -198,7 +206,7 @@ export class OfferManager {
|
||||||
return
|
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
|
const { amount_sats: amount, offer } = offerReq
|
||||||
if (!amount || isNaN(amount) || amount < 10 || amount > remote) {
|
if (!amount || isNaN(amount) || amount < 10 || amount > remote) {
|
||||||
return { success: false, code: 5, max: remote }
|
return { success: false, code: 5, max: remote }
|
||||||
|
|
@ -207,17 +215,17 @@ export class OfferManager {
|
||||||
http_callback_url: "", payer_identifier: offer, receiver_identifier: offer,
|
http_callback_url: "", payer_identifier: offer, receiver_identifier: offer,
|
||||||
invoice_req: { amountSats: amount, memo: memo || "Default CLINK Offer", zap: offerReq.zap, expiry },
|
invoice_req: { amountSats: amount, memo: memo || "Default CLINK Offer", zap: offerReq.zap, expiry },
|
||||||
offer_string: 'offer'
|
offer_string: 'offer'
|
||||||
})
|
}, clinkRequester)
|
||||||
return { success: true, invoice: res.invoice }
|
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 { amount_sats: amount, offer } = offerReq
|
||||||
const userOffer = await this.storage.offerStorage.GetOffer(offer)
|
const userOffer = await this.storage.offerStorage.GetOffer(offer)
|
||||||
const expiry = offerReq.expires_in_seconds ? offerReq.expires_in_seconds : undefined
|
const expiry = offerReq.expires_in_seconds ? offerReq.expires_in_seconds : undefined
|
||||||
|
|
||||||
if (!userOffer) {
|
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.app_user_id === userOffer.offer_id) {
|
||||||
if (userOffer.price_sats !== 0 || userOffer.payer_data) {
|
if (userOffer.price_sats !== 0 || userOffer.payer_data) {
|
||||||
|
|
@ -250,11 +258,11 @@ export class OfferManager {
|
||||||
offer_string: offer,
|
offer_string: offer,
|
||||||
rejectUnauthorized: userOffer.rejectUnauthorized,
|
rejectUnauthorized: userOffer.rejectUnauthorized,
|
||||||
token: userOffer.bearer_token
|
token: userOffer.bearer_token
|
||||||
})
|
}, clinkRequester)
|
||||||
return { success: true, invoice: res.invoice }
|
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 {
|
try {
|
||||||
const { remote } = await this.lnd.ChannelBalance()
|
const { remote } = await this.lnd.ChannelBalance()
|
||||||
let maxSendable = remote
|
let maxSendable = remote
|
||||||
|
|
@ -263,7 +271,7 @@ export class OfferManager {
|
||||||
}
|
}
|
||||||
const split = offerReq.offer.split(':')
|
const split = offerReq.offer.split(':')
|
||||||
if (split.length === 1) {
|
if (split.length === 1) {
|
||||||
return this.HandleUserOffer(offerReq, appId, maxSendable)
|
return this.HandleUserOffer(offerReq, appId, maxSendable, clinkRequester)
|
||||||
} else if (split[0] === 'p') {
|
} else if (split[0] === 'p') {
|
||||||
const product = await this.productManager.NewProductInvoice(split[1])
|
const product = await this.productManager.NewProductInvoice(split[1])
|
||||||
return { success: true, invoice: product.invoice }
|
return { success: true, invoice: product.invoice }
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,12 @@ export type ZapInfo = {
|
||||||
relays: string[]
|
relays: string[]
|
||||||
description: string
|
description: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ClinkRequester = {
|
||||||
|
pub: string // requester's pubkey
|
||||||
|
eventId: string // original request event ID
|
||||||
|
appId: string // app context for nostrSend
|
||||||
|
}
|
||||||
@Entity()
|
@Entity()
|
||||||
@Index("recv_invoice_paid_serial", ["user.serial_id", "paid_at_unix", "serial_id"], { where: "paid_at_unix > 0" })
|
@Index("recv_invoice_paid_serial", ["user.serial_id", "paid_at_unix", "serial_id"], { where: "paid_at_unix > 0" })
|
||||||
export class UserReceivingInvoice {
|
export class UserReceivingInvoice {
|
||||||
|
|
@ -80,6 +86,12 @@ export class UserReceivingInvoice {
|
||||||
})
|
})
|
||||||
liquidityProvider?: string
|
liquidityProvider?: string
|
||||||
|
|
||||||
|
@Column({
|
||||||
|
nullable: true,
|
||||||
|
type: 'simple-json'
|
||||||
|
})
|
||||||
|
clink_requester?: ClinkRequester
|
||||||
|
|
||||||
@CreateDateColumn()
|
@CreateDateColumn()
|
||||||
created_at: Date
|
created_at: Date
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||||
|
|
||||||
|
export class ClinkRequester1765354000000 implements MigrationInterface {
|
||||||
|
name = 'ClinkRequester1765354000000'
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`ALTER TABLE "user_receiving_invoice" ADD COLUMN "clink_requester" text`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`ALTER TABLE "user_receiving_invoice" DROP COLUMN "clink_requester"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { And, Between, Equal, FindOperator, IsNull, LessThan, LessThanOrEqual, M
|
||||||
import { User } from './entity/User.js';
|
import { User } from './entity/User.js';
|
||||||
import { UserTransactionPayment } from './entity/UserTransactionPayment.js';
|
import { UserTransactionPayment } from './entity/UserTransactionPayment.js';
|
||||||
import { EphemeralKeyType, UserEphemeralKey } from './entity/UserEphemeralKey.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 { UserReceivingAddress } from './entity/UserReceivingAddress.js';
|
||||||
import { Product } from './entity/Product.js';
|
import { Product } from './entity/Product.js';
|
||||||
import UserStorage from './userStorage.js';
|
import UserStorage from './userStorage.js';
|
||||||
|
|
@ -14,7 +14,7 @@ import { Application } from './entity/Application.js';
|
||||||
import TransactionsQueue from "./db/transactionsQueue.js";
|
import TransactionsQueue from "./db/transactionsQueue.js";
|
||||||
import { LoggedEvent } from './eventsLog.js';
|
import { LoggedEvent } from './eventsLog.js';
|
||||||
import { StorageInterface } from './db/storageInterface.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<string, string>, 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<string, string>, rejectUnauthorized?: boolean, token?: string, blind?: boolean, clinkRequester?: ClinkRequester }
|
||||||
export const defaultInvoiceExpiry = 60 * 60
|
export const defaultInvoiceExpiry = 60 * 60
|
||||||
export default class {
|
export default class {
|
||||||
dbs: StorageInterface
|
dbs: StorageInterface
|
||||||
|
|
@ -129,7 +129,8 @@ export default class {
|
||||||
offer_id: options.offerId,
|
offer_id: options.offerId,
|
||||||
payer_data: options.payerData,
|
payer_data: options.payerData,
|
||||||
rejectUnauthorized: options.rejectUnauthorized,
|
rejectUnauthorized: options.rejectUnauthorized,
|
||||||
bearer_token: options.token
|
bearer_token: options.token,
|
||||||
|
clink_requester: options.clinkRequester
|
||||||
}, txId)
|
}, txId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue