fix missed tx + notification info

This commit is contained in:
boufni95 2026-01-21 18:25:56 +00:00
parent 4c58cab1d6
commit 0b9a2ee3d3
7 changed files with 111 additions and 33 deletions

View file

@ -14,11 +14,18 @@ export type ClientParams = {
checkResult?: true
}
export default (params: ClientParams) => ({
EnrollServicePub: async (request: Types.ServiceNpub): Promise<ResultError | ({ status: 'OK' })> => {
EnrollServicePub: async (query: Types.EnrollServicePub_Query): Promise<ResultError | ({ status: 'OK' })> => {
let finalRoute = '/api/admin/service/enroll'
const initialQuery = query as Record<string, string | string[]>
const finalQuery: Record<string, string> = {}
for (const key in initialQuery)
if (Array.isArray(initialQuery[key]))
finalQuery[key] = initialQuery[key].join(',')
const q = (new URLSearchParams(finalQuery)).toString()
finalRoute = finalRoute + (q === '' ? '' : '?' + q)
const auth = await params.retrieveAdminAuth()
if (auth === null) throw new Error('retrieveAdminAuth() returned null')
const { data } = await axios.post(params.baseUrl + finalRoute, request, { headers: { 'authorization': auth } })
const { data } = await axios.get(params.baseUrl + finalRoute, { headers: { 'authorization': auth } })
if (data.status === 'ERROR' && typeof data.reason === 'string') return data
if (data.status === 'OK') {
return data

View file

@ -25,7 +25,10 @@ export type NostrAppMethodInputs = SendNotification_Input
export type NostrAppMethodOutputs = SendNotification_Output
export type AuthContext = AdminContext | GuestContext | NostrAppContext
export type EnrollServicePub_Input = { rpcName: 'EnrollServicePub', req: ServiceNpub }
export type EnrollServicePub_Query = {
pubkey_hex?: string[] | string
}
export type EnrollServicePub_Input = { rpcName: 'EnrollServicePub', query: EnrollServicePub_Query }
export type EnrollServicePub_Output = ResultError | { status: 'OK' }
export type Health_Input = { rpcName: 'Health' }
@ -59,19 +62,27 @@ export const EmptyValidate = (o?: Empty, opts: EmptyOptions = {}, path: string =
}
export type Notification = {
body?: string
data: string
recipient_registration_tokens: string[]
title?: string
}
export const NotificationOptionalFields: [] = []
export type NotificationOptionalField = 'body' | 'title'
export const NotificationOptionalFields: NotificationOptionalField[] = ['body', 'title']
export type NotificationOptions = OptionsBaseMessage & {
checkOptionalsAreSet?: []
checkOptionalsAreSet?: NotificationOptionalField[]
body_CustomCheck?: (v?: string) => boolean
data_CustomCheck?: (v: string) => boolean
recipient_registration_tokens_CustomCheck?: (v: string[]) => boolean
title_CustomCheck?: (v?: string) => boolean
}
export const NotificationValidate = (o?: Notification, opts: NotificationOptions = {}, path: string = 'Notification::root.'): Error | null => {
if (opts.checkOptionalsAreSet && opts.allOptionalsAreSet) return new Error(path + ': only one of checkOptionalsAreSet or allOptionalNonDefault can be set for each message')
if (typeof o !== 'object' || o === null) return new Error(path + ': object is not an instance of an object or is null')
if ((o.body || opts.allOptionalsAreSet || opts.checkOptionalsAreSet?.includes('body')) && typeof o.body !== 'string') return new Error(`${path}.body: is not a string`)
if (opts.body_CustomCheck && !opts.body_CustomCheck(o.body)) return new Error(`${path}.body: custom check failed`)
if (typeof o.data !== 'string') return new Error(`${path}.data: is not a string`)
if (opts.data_CustomCheck && !opts.data_CustomCheck(o.data)) return new Error(`${path}.data: custom check failed`)
@ -81,6 +92,9 @@ export const NotificationValidate = (o?: Notification, opts: NotificationOptions
}
if (opts.recipient_registration_tokens_CustomCheck && !opts.recipient_registration_tokens_CustomCheck(o.recipient_registration_tokens)) return new Error(`${path}.recipient_registration_tokens: custom check failed`)
if ((o.title || opts.allOptionalsAreSet || opts.checkOptionalsAreSet?.includes('title')) && typeof o.title !== 'string') return new Error(`${path}.title: is not a string`)
if (opts.title_CustomCheck && !opts.title_CustomCheck(o.title)) return new Error(`${path}.title: custom check failed`)
return null
}

View file

@ -3,10 +3,12 @@ import { bytesToHex } from '@noble/hashes/utils'
import { sha256 } from '@noble/hashes/sha256'
import { base64 } from '@scure/base';
import NewClient, { ClientParams } from './autogenerated/http_client.js'
import * as Types from './autogenerated/types.js'
import { ERROR, getLogger } from '../helpers/logger.js'
const utf8Encoder = new TextEncoder()
export type PushPair = { pubkey: string, privateKey: string }
const nip98Kind = 27235
export type ShockPushNotification = { message: string, body: string, title: string }
export class ShockPush {
private client: ReturnType<typeof NewClient>
private logger: ReturnType<typeof getLogger>
@ -52,8 +54,11 @@ export class ShockPush {
return nip98Header
}
SendNotification = async (message: string, messagingTokens: string[]) => {
const res = await this.client.SendNotification({ recipient_registration_tokens: messagingTokens, data: message })
SendNotification = async ({ body, message, title }: ShockPushNotification, messagingTokens: string[]) => {
const res = await this.client.SendNotification({
recipient_registration_tokens: messagingTokens, data: message,
body, title
})
if (res.status !== 'OK') {
this.logger(ERROR, `failed to send notification: ${res.status}`)
}

View file

@ -18,9 +18,9 @@ export type BalanceInfo = {
channelsBalance: ChannelBalance[];
}
export type AddressPaidCb = (txOutput: TxOutput, address: string, amount: number, used: 'lnd' | 'provider' | 'internal') => Promise<void>
export type AddressPaidCb = (txOutput: TxOutput, address: string, amount: number, used: 'lnd' | 'provider' | 'internal', broadcastHeight?: number) => Promise<void>
export type InvoicePaidCb = (paymentRequest: string, amount: number, used: 'lnd' | 'provider' | 'internal') => Promise<void>
export type NewBlockCb = (height: number) => void
export type NewBlockCb = (height: number, skipMetrics?: boolean) => Promise<void>
export type HtlcCb = (event: HtlcEvent) => void
export type ChannelEventCb = (event: ChannelEventUpdate, channels: Channel[]) => void

View file

@ -31,6 +31,7 @@ import { NotificationsManager } from "./notificationsManager.js"
import { ApplicationUser } from '../storage/entity/ApplicationUser.js'
import SettingsManager from './settingsManager.js'
import { NostrSettings, AppInfo } from '../nostr/nostrPool.js'
import { ShockPushNotification } from '../ShockPush/index.js'
type UserOperationsSub = {
id: string
newIncomingInvoice: (operation: Types.UserOperation) => void
@ -80,7 +81,7 @@ export default class {
this.liquidityManager = new LiquidityManager(this.settings, this.storage, this.utils, this.liquidityProvider, this.lnd, this.rugPullTracker)
this.metricsManager = new MetricsManager(this.storage, this.lnd)
this.paymentManager = new PaymentManager(this.storage, this.metricsManager, this.lnd, adminManager.swaps, this.settings, this.liquidityManager, this.utils, this.addressPaidCb, this.invoicePaidCb)
this.paymentManager = new PaymentManager(this.storage, this.metricsManager, this.lnd, adminManager.swaps, this.settings, this.liquidityManager, this.utils, this.addressPaidCb, this.invoicePaidCb, this.newBlockCb)
this.productManager = new ProductManager(this.storage, this.paymentManager, this.settings)
this.applicationManager = new ApplicationManager(this.storage, this.settings, this.paymentManager)
this.appUserManager = new AppUserManager(this.storage, this.settings, this.applicationManager)
@ -153,18 +154,20 @@ export default class {
this.metricsManager.HtlcCb(e)
}
newBlockCb: NewBlockCb = (height) => {
this.NewBlockHandler(height)
newBlockCb: NewBlockCb = (height, skipMetrics) => {
return this.NewBlockHandler(height, skipMetrics)
}
NewBlockHandler = async (height: number) => {
NewBlockHandler = async (height: number, skipMetrics?: boolean) => {
let confirmed: (PendingTx & { confs: number; })[]
let log = getLogger({})
this.storage.paymentStorage.DeleteExpiredTransactionSwaps(height)
.catch(err => log(ERROR, "failed to delete expired transaction swaps", err.message || err))
try {
const balanceEvents = await this.paymentManager.GetLndBalance()
if (!skipMetrics) {
await this.metricsManager.NewBlockCb(height, balanceEvents)
}
confirmed = await this.paymentManager.CheckNewlyConfirmedTxs(height)
await this.liquidityManager.onNewBlock()
} catch (err: any) {
@ -203,7 +206,7 @@ export default class {
}))
}
addressPaidCb: AddressPaidCb = (txOutput, address, amount, used) => {
addressPaidCb: AddressPaidCb = (txOutput, address, amount, used, broadcastHeight) => {
return this.storage.StartTransaction(async tx => {
// On-chain payments not supported when bypass is enabled
if (this.liquidityProvider.getSettings().useOnlyLiquidityProvider) {
@ -231,7 +234,8 @@ export default class {
const fee = this.paymentManager.getReceiveServiceFee(Types.UserOperationType.INCOMING_TX, amount, isManagedUser)
try {
// This call will fail if the transaction is already registered
const addedTx = await this.storage.paymentStorage.AddAddressReceivingTransaction(userAddress, txOutput.hash, txOutput.index, amount, fee, internal, blockHeight, tx)
const txBroadcastHeight = broadcastHeight ? broadcastHeight : blockHeight
const addedTx = await this.storage.paymentStorage.AddAddressReceivingTransaction(userAddress, txOutput.hash, txOutput.index, amount, fee, internal, txBroadcastHeight, tx)
if (internal) {
const addressData = `${address}:${txOutput.hash}`
this.storage.eventsLog.LogEvent({ type: 'address_paid', userId: userAddress.user.user_id, appId: userAddress.linkedApplication.app_id, appUserId: "", balance: userAddress.user.balance_sats, data: addressData, amount })
@ -381,10 +385,36 @@ export default class {
{ operation: op, requestId: "GetLiveUserOperations", status: 'OK', latest_balance: balance }
const j = JSON.stringify(message)
this.utils.nostrSender.Send({ type: 'app', appId: app.app_id }, { type: 'content', content: j, pub: user.nostr_public_key })
this.SendEncryptedNotification(app, user, op)
this.SendEncryptedNotification(app, user, op, this.getOperationMessage(op))
}
async SendEncryptedNotification(app: Application, appUser: ApplicationUser, op: Types.UserOperation) {
getOperationMessage = (op: Types.UserOperation) => {
switch (op.type) {
case Types.UserOperationType.INCOMING_TX:
case Types.UserOperationType.INCOMING_INVOICE:
case Types.UserOperationType.INCOMING_USER_TO_USER:
return {
body: "You received a new payment",
title: "Payment Received"
}
case Types.UserOperationType.OUTGOING_TX:
case Types.UserOperationType.OUTGOING_INVOICE:
case Types.UserOperationType.OUTGOING_USER_TO_USER:
return {
body: "You sent a new payment",
title: "Payment Sent"
}
default:
return {
body: "Unknown operation",
title: "Unknown Operation"
}
}
}
async SendEncryptedNotification(app: Application, appUser: ApplicationUser, op: Types.UserOperation, { body, title }: { body: string, title: string }) {
const devices = await this.storage.applicationStorage.GetAppUserDevices(appUser.identifier)
if (devices.length === 0 || !app.nostr_public_key || !app.nostr_private_key || !appUser.nostr_public_key) {
return
@ -394,7 +424,12 @@ export default class {
const j = JSON.stringify(op)
const encrypted = nip44.encrypt(j, ck)
const encryptedData: { encrypted: string, app_npub_hex: string } = { encrypted, app_npub_hex: app.nostr_public_key }
this.notificationsManager.SendNotification(JSON.stringify(encryptedData), tokens, {
const notification: ShockPushNotification = {
message: JSON.stringify(encryptedData),
body,
title
}
await this.notificationsManager.SendNotification(notification, tokens, {
pubkey: app.nostr_public_key!,
privateKey: app.nostr_private_key!
})

View file

@ -1,4 +1,4 @@
import { PushPair, ShockPush } from "../ShockPush/index.js"
import { PushPair, ShockPush, ShockPushNotification } from "../ShockPush/index.js"
import { getLogger, PubLogger } from "../helpers/logger.js"
import SettingsManager from "./settingsManager.js"
@ -21,12 +21,12 @@ export class NotificationsManager {
return newClient
}
SendNotification = async (message: string, messagingTokens: string[], pair: PushPair) => {
SendNotification = async (notification: ShockPushNotification, messagingTokens: string[], pair: PushPair) => {
if (!this.settings.getSettings().serviceSettings.shockPushBaseUrl) {
this.logger("ShockPush is not configured, skipping notification")
return
}
const client = this.getClient(pair)
await client.SendNotification(message, messagingTokens)
await client.SendNotification(notification, messagingTokens)
}
}

View file

@ -6,7 +6,7 @@ import { InboundOptionals, defaultInvoiceExpiry } from '../storage/paymentStorag
import LND from '../lnd/lnd.js'
import { Application } from '../storage/entity/Application.js'
import { ERROR, getLogger, PubLogger } from '../helpers/logger.js'
import { AddressPaidCb, InvoicePaidCb } from '../lnd/settings.js'
import { AddressPaidCb, InvoicePaidCb, NewBlockCb } from '../lnd/settings.js'
import { UserReceivingInvoice, ZapInfo } from '../storage/entity/UserReceivingInvoice.js'
import { Payment_PaymentStatus } from '../../../proto/lnd/lightning.js'
import { Event, verifiedSymbol, verifyEvent } from 'nostr-tools'
@ -54,6 +54,7 @@ export default class {
lnd: LND
addressPaidCb: AddressPaidCb
invoicePaidCb: InvoicePaidCb
newBlockCb: NewBlockCb
log = getLogger({ component: "PaymentManager" })
watchDog: Watchdog
liquidityManager: LiquidityManager
@ -61,7 +62,7 @@ export default class {
swaps: Swaps
invoiceLock: InvoiceLock
metrics: Metrics
constructor(storage: Storage, metrics: Metrics, lnd: LND, swaps: Swaps, settings: SettingsManager, liquidityManager: LiquidityManager, utils: Utils, addressPaidCb: AddressPaidCb, invoicePaidCb: InvoicePaidCb) {
constructor(storage: Storage, metrics: Metrics, lnd: LND, swaps: Swaps, settings: SettingsManager, liquidityManager: LiquidityManager, utils: Utils, addressPaidCb: AddressPaidCb, invoicePaidCb: InvoicePaidCb, newBlockCb: NewBlockCb) {
this.storage = storage
this.metrics = metrics
this.settings = settings
@ -72,6 +73,7 @@ export default class {
this.swaps = swaps
this.addressPaidCb = addressPaidCb
this.invoicePaidCb = invoicePaidCb
this.newBlockCb = newBlockCb
this.invoiceLock = new InvoiceLock()
}
@ -185,24 +187,39 @@ export default class {
}
try {
const { txs, currentHeight, lndPubkey } = await this.getLatestTransactions(log)
const { txs, currentHeight, lndPubkey, startHeight } = await this.getLatestTransactions(log)
const recoveredCount = await this.processMissedChainTransactions(txs, log)
const recoveredCount = await this.processMissedChainTransactions(txs, log, startHeight)
// Update latest checked height to current block height
await this.storage.liquidityStorage.UpdateLatestCheckedHeight('lnd', lndPubkey, currentHeight)
if (recoveredCount > 0) {
log(`processed ${recoveredCount} missed chain tx(s)`)
log(`processed ${recoveredCount} missed chain tx(s) triggering new block callback`)
// Call new block callback to process any new transaction that was just added to the database as pending
await this.newBlockCb(currentHeight, true)
} else {
log("no missed chain transactions found")
}
await this.reprocessStuckPendingTx(log, currentHeight)
} catch (err: any) {
log(ERROR, "failed to check for missed chain transactions:", err.message || err)
}
}
private async getLatestTransactions(log: PubLogger): Promise<{ txs: Transaction[], currentHeight: number, lndPubkey: string }> {
reprocessStuckPendingTx = async (log: PubLogger, currentHeight: number) => {
const { incoming } = await this.storage.paymentStorage.GetPendingTransactions()
const found = incoming.find(t => t.broadcast_height < currentHeight - 100)
if (found) {
log("found a possibly stuck pending transaction, reprocessing with full transaction history")
// There is a pending transaction more than 100 blocks old, this is likely a transaction
// that has a broadcast height higher than it actually is, so its not getting picked up when being processed
// by calling new block cb with height of 1, we make sure that even if the transaction has a newer height, it will still be processed
await this.newBlockCb(1, true)
}
}
private async getLatestTransactions(log: PubLogger): Promise<{ txs: Transaction[], currentHeight: number, lndPubkey: string, startHeight: number }> {
const lndInfo = await this.lnd.GetInfo()
const lndPubkey = lndInfo.identityPubkey
@ -212,10 +229,10 @@ export default class {
const { transactions } = await this.lnd.GetTransactions(startHeight)
log(`retrieved ${transactions.length} transactions from LND`)
return { txs: transactions, currentHeight: lndInfo.blockHeight, lndPubkey }
return { txs: transactions, currentHeight: lndInfo.blockHeight, lndPubkey, startHeight }
}
private async processMissedChainTransactions(transactions: Transaction[], log: PubLogger): Promise<number> {
private async processMissedChainTransactions(transactions: Transaction[], log: PubLogger, startHeight: number): Promise<number> {
let recoveredCount = 0
const addresses = await this.lnd.ListAddresses()
for (const tx of transactions) {
@ -231,7 +248,7 @@ export default class {
continue
}
const processed = await this.processUserAddressOutput(output, tx, log)
const processed = await this.processUserAddressOutput(output, tx, log, startHeight)
if (processed) {
recoveredCount++
}
@ -272,7 +289,7 @@ export default class {
return true
}
private async processUserAddressOutput(output: OutputDetail, tx: Transaction, log: PubLogger) {
private async processUserAddressOutput(output: OutputDetail, tx: Transaction, log: PubLogger, startHeight: number) {
const existingTx = await this.storage.paymentStorage.GetAddressReceivingTransactionOwner(
output.address,
tx.txHash
@ -285,7 +302,7 @@ export default class {
const amount = Number(output.amount)
const outputIndex = Number(output.outputIndex)
log(`processing missed chain tx: address=${output.address}, txHash=${tx.txHash}, amount=${amount}, outputIndex=${outputIndex}`)
this.addressPaidCb({ hash: tx.txHash, index: outputIndex }, output.address, amount, 'lnd')
this.addressPaidCb({ hash: tx.txHash, index: outputIndex }, output.address, amount, 'lnd', startHeight)
.catch(err => log(ERROR, "failed to process user address output:", err.message || err))
return true
}