From 0b9a2ee3d3cb959f91f0d9f34555445d584da797 Mon Sep 17 00:00:00 2001 From: boufni95 Date: Wed, 21 Jan 2026 18:25:56 +0000 Subject: [PATCH 1/2] fix missed tx + notification info --- .../ShockPush/autogenerated/http_client.ts | 11 +++- src/services/ShockPush/autogenerated/types.ts | 20 ++++++- src/services/ShockPush/index.ts | 9 ++- src/services/lnd/settings.ts | 4 +- src/services/main/index.ts | 55 +++++++++++++++---- src/services/main/notificationsManager.ts | 6 +- src/services/main/paymentManager.ts | 39 +++++++++---- 7 files changed, 111 insertions(+), 33 deletions(-) diff --git a/src/services/ShockPush/autogenerated/http_client.ts b/src/services/ShockPush/autogenerated/http_client.ts index 1a29dff6..ab3f4646 100644 --- a/src/services/ShockPush/autogenerated/http_client.ts +++ b/src/services/ShockPush/autogenerated/http_client.ts @@ -14,11 +14,18 @@ export type ClientParams = { checkResult?: true } export default (params: ClientParams) => ({ - EnrollServicePub: async (request: Types.ServiceNpub): Promise => { + EnrollServicePub: async (query: Types.EnrollServicePub_Query): Promise => { let finalRoute = '/api/admin/service/enroll' + const initialQuery = query as Record + const finalQuery: Record = {} + 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 diff --git a/src/services/ShockPush/autogenerated/types.ts b/src/services/ShockPush/autogenerated/types.ts index c4908750..c9c310e9 100644 --- a/src/services/ShockPush/autogenerated/types.ts +++ b/src/services/ShockPush/autogenerated/types.ts @@ -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 } diff --git a/src/services/ShockPush/index.ts b/src/services/ShockPush/index.ts index 8e84fd5e..7a88faa5 100644 --- a/src/services/ShockPush/index.ts +++ b/src/services/ShockPush/index.ts @@ -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 private logger: ReturnType @@ -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}`) } diff --git a/src/services/lnd/settings.ts b/src/services/lnd/settings.ts index 842cd57b..aff9777d 100644 --- a/src/services/lnd/settings.ts +++ b/src/services/lnd/settings.ts @@ -18,9 +18,9 @@ export type BalanceInfo = { channelsBalance: ChannelBalance[]; } -export type AddressPaidCb = (txOutput: TxOutput, address: string, amount: number, used: 'lnd' | 'provider' | 'internal') => Promise +export type AddressPaidCb = (txOutput: TxOutput, address: string, amount: number, used: 'lnd' | 'provider' | 'internal', broadcastHeight?: number) => Promise export type InvoicePaidCb = (paymentRequest: string, amount: number, used: 'lnd' | 'provider' | 'internal') => Promise -export type NewBlockCb = (height: number) => void +export type NewBlockCb = (height: number, skipMetrics?: boolean) => Promise export type HtlcCb = (event: HtlcEvent) => void export type ChannelEventCb = (event: ChannelEventUpdate, channels: Channel[]) => void diff --git a/src/services/main/index.ts b/src/services/main/index.ts index ab528b84..3d885681 100644 --- a/src/services/main/index.ts +++ b/src/services/main/index.ts @@ -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() - await this.metricsManager.NewBlockCb(height, balanceEvents) + 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! }) diff --git a/src/services/main/notificationsManager.ts b/src/services/main/notificationsManager.ts index 4e3602b0..b639b716 100644 --- a/src/services/main/notificationsManager.ts +++ b/src/services/main/notificationsManager.ts @@ -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) } } \ No newline at end of file diff --git a/src/services/main/paymentManager.ts b/src/services/main/paymentManager.ts index 3c7812db..c68020dc 100644 --- a/src/services/main/paymentManager.ts +++ b/src/services/main/paymentManager.ts @@ -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 { + private async processMissedChainTransactions(transactions: Transaction[], log: PubLogger, startHeight: number): Promise { 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 } From 01bfb7e0282c3eec2fac8e5c945fd1e760da9dff Mon Sep 17 00:00:00 2001 From: boufni95 Date: Wed, 21 Jan 2026 18:46:32 +0000 Subject: [PATCH 2/2] fix --- src/tests/networkSetup.ts | 4 ++-- src/tests/testBase.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/tests/networkSetup.ts b/src/tests/networkSetup.ts index 38a2e0fb..33277f0e 100644 --- a/src/tests/networkSetup.ts +++ b/src/tests/networkSetup.ts @@ -26,8 +26,8 @@ export const setupNetwork = async (): Promise => { const lndNodeSettings = LoadLndNodeSettingsFromEnv({}) const secondLndNodeSettings = LoadSecondLndSettingsFromEnv() const liquiditySettings: LiquiditySettings = { disableLiquidityProvider: true, liquidityProviderPub: "", useOnlyLiquidityProvider: false, providerRelayUrl: "" } - const alice = new LND(() => ({ lndSettings, lndNodeSettings }), new LiquidityProvider(() => liquiditySettings, setupUtils, async () => { }, async () => { }), async () => { }, setupUtils, async () => { }, async () => { }, () => { }, () => { }, () => { }) - const bob = new LND(() => ({ lndSettings, lndNodeSettings: secondLndNodeSettings }), new LiquidityProvider(() => liquiditySettings, setupUtils, async () => { }, async () => { }), async () => { }, setupUtils, async () => { }, async () => { }, () => { }, () => { }, () => { }) + const alice = new LND(() => ({ lndSettings, lndNodeSettings }), new LiquidityProvider(() => liquiditySettings, setupUtils, async () => { }, async () => { }), async () => { }, setupUtils, async () => { }, async () => { }, async () => { }, () => { }, () => { }) + const bob = new LND(() => ({ lndSettings, lndNodeSettings: secondLndNodeSettings }), new LiquidityProvider(() => liquiditySettings, setupUtils, async () => { }, async () => { }), async () => { }, setupUtils, async () => { }, async () => { }, async () => { }, () => { }, () => { }) await tryUntil(async i => { const peers = await alice.ListPeers() if (peers.peers.length > 0) { diff --git a/src/tests/testBase.ts b/src/tests/testBase.ts index 791bb6ce..ef06aeda 100644 --- a/src/tests/testBase.ts +++ b/src/tests/testBase.ts @@ -89,12 +89,12 @@ export const SetupTest = async (d: Describe, chainTools: ChainTools): Promise ({ lndSettings, lndNodeSettings: secondLndNodeSettings }) - const externalAccessToOtherLnd = new LND(otherLndSetting, new LiquidityProvider(() => liquiditySettings, extermnalUtils, async () => { }, async () => { }), async () => { }, extermnalUtils, async () => { }, async () => { }, () => { }, () => { }, () => { }) + const externalAccessToOtherLnd = new LND(otherLndSetting, new LiquidityProvider(() => liquiditySettings, extermnalUtils, async () => { }, async () => { }), async () => { }, extermnalUtils, async () => { }, async () => { }, async () => { }, () => { }, () => { }) await externalAccessToOtherLnd.Warmup() const thirdLndNodeSettings = LoadThirdLndSettingsFromEnv() const thirdLndSetting = () => ({ lndSettings, lndNodeSettings: thirdLndNodeSettings }) - const externalAccessToThirdLnd = new LND(thirdLndSetting, new LiquidityProvider(() => liquiditySettings, extermnalUtils, async () => { }, async () => { }), async () => { }, extermnalUtils, async () => { }, async () => { }, () => { }, () => { }, () => { }) + const externalAccessToThirdLnd = new LND(thirdLndSetting, new LiquidityProvider(() => liquiditySettings, extermnalUtils, async () => { }, async () => { }), async () => { }, extermnalUtils, async () => { }, async () => { }, async () => { }, () => { }, () => { }) await externalAccessToThirdLnd.Warmup()